feat(activities): editable stages with tab and kanban views

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-07 11:18:53 +03:30
co-authored by Cursor
parent b4ffef9b6f
commit aa2f39256a
15 changed files with 1860 additions and 88 deletions
@@ -0,0 +1,31 @@
-- CreateTable
CREATE TABLE "ActivityStage" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"position" INTEGER NOT NULL DEFAULT 0,
"color" TEXT NOT NULL DEFAULT 'green',
"kind" "ActivityStatus" NOT NULL DEFAULT 'open',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ActivityStage_pkey" PRIMARY KEY ("id")
);
-- AlterTable
ALTER TABLE "Activity" ADD COLUMN "stageId" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "ActivityStage_organizationId_name_key" ON "ActivityStage"("organizationId", "name");
-- CreateIndex
CREATE INDEX "ActivityStage_organizationId_position_idx" ON "ActivityStage"("organizationId", "position");
-- CreateIndex
CREATE INDEX "Activity_stageId_idx" ON "Activity"("stageId");
-- AddForeignKey
ALTER TABLE "ActivityStage" ADD CONSTRAINT "ActivityStage_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "ActivityStage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+22
View File
@@ -206,6 +206,7 @@ model Organization {
reservationAppointments ReservationAppointment[]
leads Lead[]
leadStages LeadStage[]
activityStages ActivityStage[]
leadEvents LeadEvent[]
leadImportJobs LeadImportJob[]
conversationSettings ConversationSettings?
@@ -628,6 +629,23 @@ model Deal {
@@index([organizationId, sourceRef])
}
/// مراحل/ستون‌های فعالیت (تب و کانبان) — kind وضعیت فعالیت داخل این مرحله را تعیین می‌کند
model ActivityStage {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
name String
position Int @default(0)
color String @default("green")
kind ActivityStatus @default(open)
activities Activity[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([organizationId, name])
@@index([organizationId, position])
}
model Activity {
id String @id @default(cuid())
organizationId String
@@ -662,9 +680,13 @@ model Activity {
createdBy User? @relation("ActivityCreator", fields: [createdById], references: [id], onDelete: SetNull)
notes Note[]
routineTasks RoutineTask[]
/// مرحله (ستون کانبان)؛ null یا مرحلهٔ ناهم‌نوع با status → اولین مرحلهٔ هم‌نوع
stageId String?
stage ActivityStage? @relation(fields: [stageId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([stageId])
@@index([organizationId, status, dueAt])
@@index([organizationId, assigneeId])
@@index([organizationId, createdById])
@@ -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);
+3 -2
View File
@@ -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 {}
+132 -18
View File
@@ -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,
};
}
}
+54
View File
@@ -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];