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:
@@ -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;
|
||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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> کشیده شود خودکار انجامشده ثبت
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user