Unify the web UI on shared controls and let pipeline columns be managed on the board.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -21,6 +21,27 @@ export class CreatePipelineDto {
|
||||
name!: string;
|
||||
}
|
||||
|
||||
export class CreatePipelineStageDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(80)
|
||||
name!: string;
|
||||
}
|
||||
|
||||
export class UpdatePipelineStageDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(80)
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export class ReorderPipelineStagesDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
stageIds!: string[];
|
||||
}
|
||||
|
||||
export class DealLineItemInputDto {
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
@@ -10,7 +13,12 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { CreatePipelineDto } from './dto';
|
||||
import {
|
||||
CreatePipelineDto,
|
||||
CreatePipelineStageDto,
|
||||
ReorderPipelineStagesDto,
|
||||
UpdatePipelineStageDto,
|
||||
} from './dto';
|
||||
import { PipelinesService } from './pipelines.service';
|
||||
|
||||
@Controller('pipelines')
|
||||
@@ -42,4 +50,62 @@ export class PipelinesController {
|
||||
) {
|
||||
return this.pipelinesService.create(user.organizationId, dto.name);
|
||||
}
|
||||
|
||||
@Post(':pipelineId/stages')
|
||||
@Roles('owner', 'admin')
|
||||
createStage(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Param('pipelineId') pipelineId: string,
|
||||
@Body() dto: CreatePipelineStageDto,
|
||||
) {
|
||||
return this.pipelinesService.createStage(
|
||||
user.organizationId,
|
||||
pipelineId,
|
||||
dto.name,
|
||||
);
|
||||
}
|
||||
|
||||
@Patch(':pipelineId/stages/order')
|
||||
@Roles('owner', 'admin')
|
||||
reorderStages(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Param('pipelineId') pipelineId: string,
|
||||
@Body() dto: ReorderPipelineStagesDto,
|
||||
) {
|
||||
return this.pipelinesService.reorderStages(
|
||||
user.organizationId,
|
||||
pipelineId,
|
||||
dto.stageIds,
|
||||
);
|
||||
}
|
||||
|
||||
@Patch(':pipelineId/stages/:stageId')
|
||||
@Roles('owner', 'admin')
|
||||
updateStage(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Param('pipelineId') pipelineId: string,
|
||||
@Param('stageId') stageId: string,
|
||||
@Body() dto: UpdatePipelineStageDto,
|
||||
) {
|
||||
return this.pipelinesService.updateStage(
|
||||
user.organizationId,
|
||||
pipelineId,
|
||||
stageId,
|
||||
dto.name,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(':pipelineId/stages/:stageId')
|
||||
@Roles('owner', 'admin')
|
||||
deleteStage(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Param('pipelineId') pipelineId: string,
|
||||
@Param('stageId') stageId: string,
|
||||
) {
|
||||
return this.pipelinesService.deleteStage(
|
||||
user.organizationId,
|
||||
pipelineId,
|
||||
stageId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
DealDto,
|
||||
PipelineBoardDto,
|
||||
@@ -114,6 +118,121 @@ export class PipelinesService {
|
||||
return this.toPipelineDto(created);
|
||||
}
|
||||
|
||||
async createStage(
|
||||
organizationId: string,
|
||||
pipelineId: string,
|
||||
name: string,
|
||||
): Promise<PipelineDto> {
|
||||
const pipeline = await this.getOwnedPipeline(organizationId, pipelineId);
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) throw new BadRequestException('نام ستون لازم است');
|
||||
|
||||
const firstClosed = pipeline.stages.findIndex((s) => s.isWon || s.isLost);
|
||||
const insertAt = firstClosed === -1 ? pipeline.stages.length : firstClosed;
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.pipelineStage.updateMany({
|
||||
where: { pipelineId: pipeline.id, position: { gte: insertAt } },
|
||||
data: { position: { increment: 1 } },
|
||||
});
|
||||
await tx.pipelineStage.create({
|
||||
data: {
|
||||
pipelineId: pipeline.id,
|
||||
name: trimmed,
|
||||
position: insertAt,
|
||||
isWon: false,
|
||||
isLost: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return this.getOwnedPipelineDto(organizationId, pipeline.id);
|
||||
}
|
||||
|
||||
async updateStage(
|
||||
organizationId: string,
|
||||
pipelineId: string,
|
||||
stageId: string,
|
||||
name?: string,
|
||||
): Promise<PipelineDto> {
|
||||
const pipeline = await this.getOwnedPipeline(organizationId, pipelineId);
|
||||
const stage = pipeline.stages.find((s) => s.id === stageId);
|
||||
if (!stage) throw new NotFoundException('ستون پیدا نشد');
|
||||
|
||||
const trimmed = name?.trim() ?? '';
|
||||
if (!trimmed) throw new BadRequestException('نام ستون لازم است');
|
||||
|
||||
await this.prisma.pipelineStage.update({
|
||||
where: { id: stage.id },
|
||||
data: { name: trimmed },
|
||||
});
|
||||
|
||||
return this.getOwnedPipelineDto(organizationId, pipeline.id);
|
||||
}
|
||||
|
||||
async deleteStage(
|
||||
organizationId: string,
|
||||
pipelineId: string,
|
||||
stageId: string,
|
||||
): Promise<PipelineDto> {
|
||||
const pipeline = await this.getOwnedPipeline(organizationId, pipelineId);
|
||||
const stage = pipeline.stages.find((s) => s.id === stageId);
|
||||
if (!stage) throw new NotFoundException('ستون پیدا نشد');
|
||||
if (pipeline.stages.length <= 1) {
|
||||
throw new BadRequestException('حداقل یک ستون باید باقی بماند');
|
||||
}
|
||||
|
||||
const dealCount = await this.prisma.deal.count({
|
||||
where: { organizationId, stageId: stage.id },
|
||||
});
|
||||
if (dealCount > 0) {
|
||||
throw new BadRequestException(
|
||||
'ابتدا معاملات این ستون را به ستون دیگری منتقل کنید',
|
||||
);
|
||||
}
|
||||
|
||||
const remaining = pipeline.stages.filter((s) => s.id !== stage.id);
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.pipelineStage.delete({ where: { id: stage.id } }),
|
||||
...remaining.map((s, index) =>
|
||||
this.prisma.pipelineStage.update({
|
||||
where: { id: s.id },
|
||||
data: { position: index },
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
return this.getOwnedPipelineDto(organizationId, pipeline.id);
|
||||
}
|
||||
|
||||
async reorderStages(
|
||||
organizationId: string,
|
||||
pipelineId: string,
|
||||
stageIds: string[],
|
||||
): Promise<PipelineDto> {
|
||||
const pipeline = await this.getOwnedPipeline(organizationId, pipelineId);
|
||||
const currentIds = pipeline.stages.map((s) => s.id);
|
||||
const unique = new Set(stageIds);
|
||||
if (
|
||||
stageIds.length !== currentIds.length ||
|
||||
unique.size !== currentIds.length ||
|
||||
currentIds.some((id) => !unique.has(id))
|
||||
) {
|
||||
throw new BadRequestException('ترتیب ستونها نامعتبر است');
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(
|
||||
stageIds.map((id, index) =>
|
||||
this.prisma.pipelineStage.update({
|
||||
where: { id },
|
||||
data: { position: index },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return this.getOwnedPipelineDto(organizationId, pipeline.id);
|
||||
}
|
||||
|
||||
async getBoard(
|
||||
organizationId: string,
|
||||
pipelineId?: string,
|
||||
@@ -232,6 +351,23 @@ export class PipelinesService {
|
||||
};
|
||||
}
|
||||
|
||||
private async getOwnedPipeline(organizationId: string, pipelineId: string) {
|
||||
const pipeline = await this.prisma.pipeline.findFirst({
|
||||
where: { id: pipelineId, organizationId },
|
||||
include: { stages: { orderBy: { position: 'asc' } } },
|
||||
});
|
||||
if (!pipeline) throw new NotFoundException('کاریز پیدا نشد');
|
||||
return pipeline;
|
||||
}
|
||||
|
||||
private async getOwnedPipelineDto(
|
||||
organizationId: string,
|
||||
pipelineId: string,
|
||||
): Promise<PipelineDto> {
|
||||
const pipeline = await this.getOwnedPipeline(organizationId, pipelineId);
|
||||
return this.toPipelineDto(pipeline);
|
||||
}
|
||||
|
||||
private toPipelineDto(row: {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
Reference in New Issue
Block a user