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:
2026-09-10 01:22:24 +03:30
co-authored by Cursor
parent f1aba1b386
commit b8ad4037b6
40 changed files with 2334 additions and 788 deletions
+21
View File
@@ -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)
+67 -1
View File
@@ -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,
);
}
}
+137 -1
View File
@@ -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;