feat(activities): editable stages with tab and kanban views
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -18,15 +18,22 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import type { Response } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { ActivitiesService } from './activities.service';
|
||||
import { ActivitiesExcelService } from './activities-excel.service';
|
||||
import { ActivityStagesService } from './activity-stages.service';
|
||||
import {
|
||||
CreateActivityDto,
|
||||
CreateActivityStageDto,
|
||||
ImportActivityRowsDto,
|
||||
ListActivitiesQueryDto,
|
||||
MoveActivityStageDto,
|
||||
ReorderActivityStagesDto,
|
||||
UpdateActivityDto,
|
||||
UpdateActivityStageDto,
|
||||
} from './dto';
|
||||
import { brand } from '@mento/shared';
|
||||
|
||||
@@ -41,6 +48,7 @@ export class ActivitiesController {
|
||||
constructor(
|
||||
private readonly activitiesService: ActivitiesService,
|
||||
private readonly excelService: ActivitiesExcelService,
|
||||
private readonly stagesService: ActivityStagesService,
|
||||
) {}
|
||||
|
||||
@Get('summary')
|
||||
@@ -48,6 +56,63 @@ export class ActivitiesController {
|
||||
return this.activitiesService.summary(user.organizationId, user);
|
||||
}
|
||||
|
||||
@Get('stages')
|
||||
listStages(@CurrentUser() user: Actor) {
|
||||
return this.stagesService.list(user.organizationId);
|
||||
}
|
||||
|
||||
@Post('stages')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
createStage(@CurrentUser() user: Actor, @Body() dto: CreateActivityStageDto) {
|
||||
return this.stagesService.create(user.organizationId, dto);
|
||||
}
|
||||
|
||||
@Patch('stages/reorder')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
reorderStages(@CurrentUser() user: Actor, @Body() dto: ReorderActivityStagesDto) {
|
||||
return this.stagesService.reorder(user.organizationId, dto.stageIds);
|
||||
}
|
||||
|
||||
@Patch('stages/:id')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
updateStage(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateActivityStageDto,
|
||||
) {
|
||||
return this.stagesService.update(user.organizationId, id, dto);
|
||||
}
|
||||
|
||||
@Delete('stages/:id')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
async removeStage(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
await this.stagesService.remove(user.organizationId, id);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Get('stage-counts')
|
||||
stageCounts(
|
||||
@CurrentUser() user: Actor,
|
||||
@Query() query: ListActivitiesQueryDto,
|
||||
) {
|
||||
return this.activitiesService.stageCounts(
|
||||
user.organizationId,
|
||||
user,
|
||||
query.filter ?? 'all',
|
||||
query.q,
|
||||
query.desk,
|
||||
{
|
||||
type: query.type,
|
||||
status: query.status,
|
||||
assigneeId: query.assigneeId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@CurrentUser() user: Actor,
|
||||
@@ -75,6 +140,7 @@ export class ActivitiesController {
|
||||
status: query.status,
|
||||
assigneeId: query.assigneeId,
|
||||
},
|
||||
query.stageId ? { stageId: query.stageId } : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -174,6 +240,15 @@ export class ActivitiesController {
|
||||
return this.activitiesService.update(user.organizationId, id, user, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/stage')
|
||||
moveStage(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: MoveActivityStageDto,
|
||||
) {
|
||||
return this.activitiesService.moveToStage(user.organizationId, id, user, dto.stageId);
|
||||
}
|
||||
|
||||
@Patch(':id/complete')
|
||||
complete(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
return this.activitiesService.complete(user.organizationId, id, user);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
import { ActivitiesController } from './activities.controller';
|
||||
import { ActivitiesService } from './activities.service';
|
||||
import { ActivitiesExcelService } from './activities-excel.service';
|
||||
import { ActivityStagesService } from './activity-stages.service';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { AutomationsModule } from '../automations/automations.module';
|
||||
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
|
||||
@@ -9,7 +10,7 @@ import { MessageAutomationsModule } from '../message-automations/message-automat
|
||||
@Module({
|
||||
imports: [NotificationsModule, AutomationsModule, MessageAutomationsModule],
|
||||
controllers: [ActivitiesController],
|
||||
providers: [ActivitiesService, ActivitiesExcelService],
|
||||
exports: [ActivitiesService],
|
||||
providers: [ActivitiesService, ActivitiesExcelService, ActivityStagesService],
|
||||
exports: [ActivitiesService, ActivityStagesService],
|
||||
})
|
||||
export class ActivitiesModule {}
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
ActivityDto,
|
||||
ActivityFilter,
|
||||
ActivitySummaryDto,
|
||||
ActivityStageCountsDto,
|
||||
CreateActivityRequest,
|
||||
PaginatedResponse,
|
||||
UpdateActivityRequest,
|
||||
@@ -19,9 +20,19 @@ import { MessageAutomationsService } from '../message-automations/message-automa
|
||||
import { type Actor, assigneeWhere, dealAccessWhere, ownerWhere } from '../common/record-scope';
|
||||
import { normalizeTags } from '../common/tags';
|
||||
import { buildSearchWhere, ci } from '../common/search';
|
||||
import { ActivityStagesService, type StageDefaults } from './activity-stages.service';
|
||||
|
||||
type ActivityActor = Pick<Actor, 'userId' | 'role'>;
|
||||
|
||||
/** stageId = شناسهٔ مرحله یا 'all'؛ وقتی ست باشد فیلتر زمانی وضعیت را تحمیل نمیکند (جز عقبافتاده) */
|
||||
type StageScope = { stageId: string };
|
||||
|
||||
type ListFacets = {
|
||||
type?: ActivityDto['type'];
|
||||
status?: ActivityDto['status'];
|
||||
assigneeId?: string;
|
||||
};
|
||||
|
||||
type ActivityRow = {
|
||||
id: string;
|
||||
type: ActivityDto['type'];
|
||||
@@ -45,9 +56,11 @@ type ActivityRow = {
|
||||
assignee: { id: string; fullName: string } | null;
|
||||
assignees: Array<{ id: string; fullName: string }>;
|
||||
createdBy: { id: string; fullName: string } | null;
|
||||
stage?: { id: string; kind: ActivityDto['status'] } | null;
|
||||
};
|
||||
|
||||
const include = {
|
||||
stage: { select: { id: true, kind: true } },
|
||||
person: { select: { id: true, firstName: true, lastName: true } },
|
||||
company: { select: { id: true, name: true } },
|
||||
deal: { select: { id: true, title: true } },
|
||||
@@ -63,6 +76,7 @@ export class ActivitiesService {
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly automations: AutomationsService,
|
||||
private readonly messageAutomations: MessageAutomationsService,
|
||||
private readonly stages: ActivityStagesService,
|
||||
) {}
|
||||
|
||||
async summary(
|
||||
@@ -151,11 +165,8 @@ export class ActivitiesService {
|
||||
to?: string;
|
||||
},
|
||||
desk?: 'mine' | 'team',
|
||||
facets?: {
|
||||
type?: ActivityDto['type'];
|
||||
status?: ActivityDto['status'];
|
||||
assigneeId?: string;
|
||||
},
|
||||
facets?: ListFacets,
|
||||
stageScope?: StageScope,
|
||||
): Promise<PaginatedResponse<ActivityDto>> {
|
||||
const where = this.buildWhere(
|
||||
organizationId,
|
||||
@@ -166,7 +177,14 @@ export class ActivitiesService {
|
||||
range,
|
||||
desk,
|
||||
facets,
|
||||
stageScope,
|
||||
);
|
||||
if (stageScope && stageScope.stageId !== 'all') {
|
||||
(where.AND as Prisma.ActivityWhereInput[]).push(
|
||||
await this.stages.whereForStage(organizationId, stageScope.stageId),
|
||||
);
|
||||
}
|
||||
const defaults = await this.stages.defaults(organizationId);
|
||||
const take = Math.min(Math.max(limit, 1), 200);
|
||||
const [rows, total] = await this.prisma.$transaction([
|
||||
this.prisma.activity.findMany({
|
||||
@@ -180,13 +198,87 @@ export class ActivitiesService {
|
||||
]);
|
||||
|
||||
return {
|
||||
items: rows.map((row) => this.toDto(row)),
|
||||
items: rows.map((row) => this.toDto(row, defaults)),
|
||||
total,
|
||||
page,
|
||||
limit: take,
|
||||
};
|
||||
}
|
||||
|
||||
/** تعداد فعالیت هر مرحله با همان فیلترهای لیست (زمان، جستجو، میز، نوع، کارشناس) */
|
||||
async stageCounts(
|
||||
organizationId: string,
|
||||
actor: ActivityActor,
|
||||
filter: ActivityFilter,
|
||||
q?: string,
|
||||
desk?: 'mine' | 'team',
|
||||
facets?: ListFacets,
|
||||
): Promise<ActivityStageCountsDto> {
|
||||
const rows = await this.stages.rows(organizationId);
|
||||
const defaults = this.stages.defaultsOf(rows);
|
||||
const kindOf = new Map(rows.map((r) => [r.id, r.kind]));
|
||||
const where = this.buildWhere(
|
||||
organizationId,
|
||||
actor,
|
||||
filter,
|
||||
q,
|
||||
undefined,
|
||||
undefined,
|
||||
desk,
|
||||
facets,
|
||||
{ stageId: 'all' },
|
||||
);
|
||||
const groups = await this.prisma.activity.groupBy({
|
||||
by: ['status', 'stageId'],
|
||||
where,
|
||||
_count: { _all: true },
|
||||
});
|
||||
const counts: Record<string, number> = Object.fromEntries(rows.map((r) => [r.id, 0]));
|
||||
let total = 0;
|
||||
for (const g of groups) {
|
||||
const n = g._count._all;
|
||||
total += n;
|
||||
const target =
|
||||
g.stageId && kindOf.get(g.stageId) === g.status ? g.stageId : defaults[g.status];
|
||||
if (target) counts[target] = (counts[target] ?? 0) + n;
|
||||
}
|
||||
return { counts, total };
|
||||
}
|
||||
|
||||
async moveToStage(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
actor: ActivityActor,
|
||||
stageId: string,
|
||||
): Promise<ActivityDto> {
|
||||
await this.ensureExists(organizationId, id, actor);
|
||||
const stage = await this.stages.get(organizationId, stageId);
|
||||
const existing = await this.prisma.activity.findFirst({
|
||||
where: { id, organizationId },
|
||||
select: { status: true, completedAt: true },
|
||||
});
|
||||
const row = await this.prisma.activity.update({
|
||||
where: { id },
|
||||
data: {
|
||||
stageId: stage.id,
|
||||
status: stage.kind,
|
||||
completedAt:
|
||||
stage.kind === 'done'
|
||||
? existing?.status === 'done'
|
||||
? existing.completedAt ?? new Date()
|
||||
: new Date()
|
||||
: stage.kind === 'open'
|
||||
? null
|
||||
: undefined,
|
||||
},
|
||||
include,
|
||||
});
|
||||
if (stage.kind === 'done' && existing?.status !== 'done') {
|
||||
await this.fireDone(organizationId, row);
|
||||
}
|
||||
return this.toDto(row, await this.stages.defaults(organizationId));
|
||||
}
|
||||
|
||||
async get(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
@@ -197,7 +289,7 @@ export class ActivitiesService {
|
||||
include,
|
||||
});
|
||||
if (!row) throw new NotFoundException('فعالیت پیدا نشد');
|
||||
return this.toDto(row);
|
||||
return this.toDto(row, await this.stages.defaults(organizationId));
|
||||
}
|
||||
|
||||
async create(
|
||||
@@ -217,10 +309,21 @@ export class ActivitiesService {
|
||||
: [userId],
|
||||
);
|
||||
const assigneeId = assigneeIds[0] ?? userId;
|
||||
const initialStageId = emptyToNull(dto.stageId ?? null);
|
||||
const initialStage = initialStageId
|
||||
? await this.stages.get(organizationId, initialStageId)
|
||||
: null;
|
||||
|
||||
const row = await this.prisma.activity.create({
|
||||
data: {
|
||||
organizationId,
|
||||
...(initialStage
|
||||
? {
|
||||
stageId: initialStage.id,
|
||||
status: initialStage.kind,
|
||||
completedAt: initialStage.kind === 'done' ? new Date() : null,
|
||||
}
|
||||
: {}),
|
||||
title: dto.title.trim(),
|
||||
type: dto.type ?? 'task',
|
||||
priority: dto.priority ?? 'medium',
|
||||
@@ -267,8 +370,11 @@ export class ActivitiesService {
|
||||
assigneeId: row.assignee?.id ?? null,
|
||||
ownerId: row.assignee?.id ?? null,
|
||||
});
|
||||
if (initialStage?.kind === 'done') {
|
||||
await this.fireDone(organizationId, row);
|
||||
}
|
||||
|
||||
return this.toDto(row);
|
||||
return this.toDto(row, await this.stages.defaults(organizationId));
|
||||
}
|
||||
|
||||
async update(
|
||||
@@ -345,7 +451,7 @@ export class ActivitiesService {
|
||||
if (dto.status === 'done' && existing?.status !== 'done') {
|
||||
await this.fireDone(organizationId, row);
|
||||
}
|
||||
return this.toDto(row);
|
||||
return this.toDto(row, await this.stages.defaults(organizationId));
|
||||
}
|
||||
|
||||
async complete(
|
||||
@@ -366,7 +472,7 @@ export class ActivitiesService {
|
||||
if (existing?.status !== 'done') {
|
||||
await this.fireDone(organizationId, row);
|
||||
}
|
||||
return this.toDto(row);
|
||||
return this.toDto(row, await this.stages.defaults(organizationId));
|
||||
}
|
||||
|
||||
async remove(
|
||||
@@ -393,11 +499,8 @@ export class ActivitiesService {
|
||||
to?: string;
|
||||
},
|
||||
desk?: 'mine' | 'team',
|
||||
facets?: {
|
||||
type?: ActivityDto['type'];
|
||||
status?: ActivityDto['status'];
|
||||
assigneeId?: string;
|
||||
},
|
||||
facets?: ListFacets,
|
||||
stageScope?: StageScope,
|
||||
): Prisma.ActivityWhereInput {
|
||||
const { start, end } = dayBounds();
|
||||
const scope: Prisma.ActivityWhereInput =
|
||||
@@ -466,14 +569,24 @@ export class ActivitiesService {
|
||||
}
|
||||
|
||||
// Explicit status facet overrides the status implied by time tabs.
|
||||
const impliedOpen = facets?.status ? { status: facets.status } : { status: 'open' as const };
|
||||
// With stage tabs/columns the stage decides status; time filters only narrow dueAt.
|
||||
const impliedOpen = facets?.status
|
||||
? { status: facets.status }
|
||||
: stageScope
|
||||
? {}
|
||||
: { status: 'open' as const };
|
||||
const impliedDone = facets?.status ? { status: facets.status } : { status: 'done' as const };
|
||||
|
||||
switch (filter) {
|
||||
case 'today':
|
||||
return { ...base, ...impliedOpen, dueAt: { gte: start, lt: end } };
|
||||
case 'overdue':
|
||||
return { ...base, ...impliedOpen, dueAt: { lt: start } };
|
||||
// عقبافتاده ذاتاً یعنی باز و موعدگذشته
|
||||
return {
|
||||
...base,
|
||||
...(facets?.status ? { status: facets.status } : { status: 'open' as const }),
|
||||
dueAt: { lt: start },
|
||||
};
|
||||
case 'upcoming':
|
||||
return {
|
||||
...base,
|
||||
@@ -571,8 +684,9 @@ export class ActivitiesService {
|
||||
}
|
||||
}
|
||||
|
||||
private toDto(row: ActivityRow): ActivityDto {
|
||||
private toDto(row: ActivityRow, defaults?: StageDefaults): ActivityDto {
|
||||
return {
|
||||
stageId: this.stages.effectiveStageId(row, defaults),
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
status: row.status,
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import type {
|
||||
ActivityStageColor,
|
||||
ActivityStageDto,
|
||||
ActivityStatus,
|
||||
} from '@mento/shared';
|
||||
import { ACTIVITY_STAGE_COLORS } from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const DEFAULT_STAGES: Array<{ name: string; color: ActivityStageColor; kind: ActivityStatus }> = [
|
||||
{ name: 'در صف', color: 'orange', kind: 'open' },
|
||||
{ name: 'در حال انجام', color: 'blue', kind: 'open' },
|
||||
{ name: 'انجامشده', color: 'green', kind: 'done' },
|
||||
{ name: 'لغو شده', color: 'rose', kind: 'canceled' },
|
||||
];
|
||||
|
||||
const KINDS: ActivityStatus[] = ['open', 'done', 'canceled'];
|
||||
|
||||
type StageRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
position: number;
|
||||
color: string;
|
||||
kind: ActivityStatus;
|
||||
};
|
||||
|
||||
export type StageDefaults = Record<ActivityStatus, string | null>;
|
||||
|
||||
@Injectable()
|
||||
export class ActivityStagesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async ensureDefaults(organizationId: string): Promise<void> {
|
||||
const count = await this.prisma.activityStage.count({ where: { organizationId } });
|
||||
if (count > 0) return;
|
||||
await this.prisma.activityStage.createMany({
|
||||
data: DEFAULT_STAGES.map((s, i) => ({
|
||||
organizationId,
|
||||
name: s.name,
|
||||
color: s.color,
|
||||
kind: s.kind,
|
||||
position: i,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
async rows(organizationId: string): Promise<StageRow[]> {
|
||||
await this.ensureDefaults(organizationId);
|
||||
return this.prisma.activityStage.findMany({
|
||||
where: { organizationId },
|
||||
orderBy: [{ position: 'asc' }, { createdAt: 'asc' }],
|
||||
select: { id: true, name: true, position: true, color: true, kind: true },
|
||||
});
|
||||
}
|
||||
|
||||
async list(organizationId: string): Promise<ActivityStageDto[]> {
|
||||
return (await this.rows(organizationId)).map((r) => this.toDto(r));
|
||||
}
|
||||
|
||||
/** اولین مرحلهٔ هر نوع = مقصد فعالیتهای بدون مرحله یا با مرحلهٔ ناهمنوع */
|
||||
defaultsOf(rows: StageRow[]): StageDefaults {
|
||||
const out: StageDefaults = { open: null, done: null, canceled: null };
|
||||
for (const r of rows) {
|
||||
if (!out[r.kind]) out[r.kind] = r.id;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async defaults(organizationId: string): Promise<StageDefaults> {
|
||||
return this.defaultsOf(await this.rows(organizationId));
|
||||
}
|
||||
|
||||
/** شرط Prisma برای فعالیتهایی که «مؤثراً» در این مرحله هستند */
|
||||
async whereForStage(
|
||||
organizationId: string,
|
||||
stageId: string,
|
||||
): Promise<Prisma.ActivityWhereInput> {
|
||||
const rows = await this.rows(organizationId);
|
||||
const stage = rows.find((r) => r.id === stageId);
|
||||
if (!stage) throw new BadRequestException('مرحلهٔ انتخابشده معتبر نیست');
|
||||
const isDefault = this.defaultsOf(rows)[stage.kind] === stage.id;
|
||||
if (!isDefault) return { status: stage.kind, stageId: stage.id };
|
||||
return {
|
||||
status: stage.kind,
|
||||
OR: [
|
||||
{ stageId: stage.id },
|
||||
{ stageId: null },
|
||||
{ stage: { kind: { not: stage.kind } } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
effectiveStageId(
|
||||
row: { status: ActivityStatus; stage?: { id: string; kind: ActivityStatus } | null },
|
||||
defaults?: StageDefaults,
|
||||
): string | null {
|
||||
if (row.stage && row.stage.kind === row.status) return row.stage.id;
|
||||
return defaults?.[row.status] ?? null;
|
||||
}
|
||||
|
||||
async get(organizationId: string, id: string): Promise<StageRow> {
|
||||
const row = await this.prisma.activityStage.findFirst({
|
||||
where: { id, organizationId },
|
||||
select: { id: true, name: true, position: true, color: true, kind: true },
|
||||
});
|
||||
if (!row) throw new NotFoundException('مرحله پیدا نشد');
|
||||
return row;
|
||||
}
|
||||
|
||||
async create(
|
||||
organizationId: string,
|
||||
dto: { name: string; color?: string; kind?: ActivityStatus },
|
||||
): Promise<ActivityStageDto> {
|
||||
await this.ensureDefaults(organizationId);
|
||||
const max = await this.prisma.activityStage.aggregate({
|
||||
where: { organizationId },
|
||||
_max: { position: true },
|
||||
});
|
||||
try {
|
||||
const row = await this.prisma.activityStage.create({
|
||||
data: {
|
||||
organizationId,
|
||||
name: this.cleanName(dto.name),
|
||||
color: this.cleanColor(dto.color),
|
||||
kind: dto.kind && KINDS.includes(dto.kind) ? dto.kind : 'open',
|
||||
position: (max._max.position ?? -1) + 1,
|
||||
},
|
||||
});
|
||||
return this.toDto(row);
|
||||
} catch (e) {
|
||||
throw this.mapError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async update(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
dto: { name?: string; color?: string; kind?: ActivityStatus },
|
||||
): Promise<ActivityStageDto> {
|
||||
const stage = await this.get(organizationId, id);
|
||||
const nextKind = dto.kind && KINDS.includes(dto.kind) ? dto.kind : stage.kind;
|
||||
if (nextKind !== stage.kind) {
|
||||
await this.assertNotLastOfKind(organizationId, stage);
|
||||
}
|
||||
try {
|
||||
const row = await this.prisma.$transaction(async (tx) => {
|
||||
if (nextKind !== stage.kind) {
|
||||
// فعالیتها وضعیتشان را نگه میدارند و به اولین مرحلهٔ همنوع خودشان برمیگردند
|
||||
await tx.activity.updateMany({
|
||||
where: { organizationId, stageId: id },
|
||||
data: { stageId: null },
|
||||
});
|
||||
}
|
||||
return tx.activityStage.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.name != null ? { name: this.cleanName(dto.name) } : {}),
|
||||
...(dto.color != null ? { color: this.cleanColor(dto.color) } : {}),
|
||||
kind: nextKind,
|
||||
},
|
||||
});
|
||||
});
|
||||
return this.toDto(row);
|
||||
} catch (e) {
|
||||
throw this.mapError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async reorder(organizationId: string, stageIds: string[]): Promise<ActivityStageDto[]> {
|
||||
await this.prisma.$transaction(
|
||||
stageIds.map((id, position) =>
|
||||
this.prisma.activityStage.updateMany({
|
||||
where: { id, organizationId },
|
||||
data: { position },
|
||||
}),
|
||||
),
|
||||
);
|
||||
return this.list(organizationId);
|
||||
}
|
||||
|
||||
async remove(organizationId: string, id: string): Promise<void> {
|
||||
const stage = await this.get(organizationId, id);
|
||||
await this.assertNotLastOfKind(organizationId, stage);
|
||||
// onDelete: SetNull → فعالیتها به اولین مرحلهٔ همنوع وضعیتشان منتقل میشوند
|
||||
await this.prisma.activityStage.delete({ where: { id } });
|
||||
}
|
||||
|
||||
private async assertNotLastOfKind(organizationId: string, stage: StageRow) {
|
||||
const sameKind = await this.prisma.activityStage.count({
|
||||
where: { organizationId, kind: stage.kind },
|
||||
});
|
||||
if (sameKind <= 1) {
|
||||
const label =
|
||||
stage.kind === 'done' ? 'انجامشده' : stage.kind === 'canceled' ? 'لغو شده' : 'باز';
|
||||
throw new BadRequestException(
|
||||
`حداقل یک ستون از نوع «${label}» لازم است؛ اول یک ستون دیگر از همین نوع بسازید`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private cleanName(raw: string): string {
|
||||
const name = raw?.trim() ?? '';
|
||||
if (!name) throw new BadRequestException('نام ستون لازم است');
|
||||
if (name.length > 80) throw new BadRequestException('نام ستون حداکثر ۸۰ کاراکتر است');
|
||||
return name;
|
||||
}
|
||||
|
||||
private cleanColor(raw?: string): ActivityStageColor {
|
||||
return ACTIVITY_STAGE_COLORS.includes(raw as ActivityStageColor)
|
||||
? (raw as ActivityStageColor)
|
||||
: 'green';
|
||||
}
|
||||
|
||||
private mapError(e: unknown) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
return new BadRequestException('ستونی با این نام وجود دارد');
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
private toDto(row: StageRow): ActivityStageDto {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
position: row.position,
|
||||
color: this.cleanColor(row.color),
|
||||
kind: row.kind,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,11 @@ export class ListActivitiesQueryDto {
|
||||
@IsString()
|
||||
assigneeId?: string;
|
||||
|
||||
/** شناسهٔ مرحله یا 'all' — حالت تب/کانبان مراحل */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
stageId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
personId?: string;
|
||||
@@ -102,12 +107,61 @@ export class ListActivitiesQueryDto {
|
||||
limit?: number = 50;
|
||||
}
|
||||
|
||||
const STAGE_COLORS = ['green', 'orange', 'purple', 'blue', 'rose', 'slate'] as const;
|
||||
|
||||
export class CreateActivityStageDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(80)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(STAGE_COLORS)
|
||||
color?: (typeof STAGE_COLORS)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(ACTIVITY_STATUSES)
|
||||
kind?: (typeof ACTIVITY_STATUSES)[number];
|
||||
}
|
||||
|
||||
export class UpdateActivityStageDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(80)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(STAGE_COLORS)
|
||||
color?: (typeof STAGE_COLORS)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(ACTIVITY_STATUSES)
|
||||
kind?: (typeof ACTIVITY_STATUSES)[number];
|
||||
}
|
||||
|
||||
export class ReorderActivityStagesDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
stageIds!: string[];
|
||||
}
|
||||
|
||||
export class MoveActivityStageDto {
|
||||
@IsString()
|
||||
stageId!: string;
|
||||
}
|
||||
|
||||
export class CreateActivityDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(200)
|
||||
title!: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
stageId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(ACTIVITY_TYPES)
|
||||
type?: (typeof ACTIVITY_TYPES)[number];
|
||||
|
||||
Reference in New Issue
Block a user