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:
2026-10-20 17:23:27 +03:30
parent 9127fa334b
commit e6aa4c64f0
11 changed files with 769 additions and 58 deletions
+44 -7
View File
@@ -30,14 +30,14 @@ import { GamificationService } from '../gamification/gamification.service';
import { tehranDayKey } from '../routines/routine-dates';
import { buildSearchWhere, ci } from '../common/search';
import { activityPriorWhere } from '../common/prior-archive';
import { ActivityStagesService, type StageDefaults } from './activity-stages.service';
import { ActivityStagesService, type BoardStageContext } from './activity-stages.service';
import {
ActivityTypesService,
type SystemTypes,
type TypeRow,
} from './activity-types.service';
type DtoContext = { stages?: StageDefaults; types?: SystemTypes };
type DtoContext = { stages?: BoardStageContext; types?: SystemTypes };
type ActivityActor = Pick<Actor, 'userId' | 'role' | 'priorLens' | 'priorCutoff'>;
@@ -49,6 +49,8 @@ type ListFacets = {
typeDefId?: string;
status?: ActivityDto['status'];
assigneeId?: string;
/** گروه فعالیت؛ خالی = همهٔ گروه‌ها */
boardId?: string;
};
type ActivityRow = {
@@ -77,14 +79,15 @@ 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;
boardId?: string | null;
stage?: { id: string; kind: ActivityDto['status']; boardId: string } | null;
typeDef?: { id: string; name: string; icon: string; color: string } | null;
fields?: Prisma.JsonValue | null;
participantIds?: string[];
};
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 } },
person: { select: { id: true, firstName: true, lastName: true } },
company: { select: { id: true, name: true } },
@@ -109,7 +112,7 @@ export class ActivitiesService {
private async ctx(organizationId: string): Promise<DtoContext> {
const [stages, types] = await Promise.all([
this.stages.defaults(organizationId),
this.stages.boardContext(organizationId),
this.types.system(organizationId),
]);
return { stages, types };
@@ -255,13 +258,20 @@ export class ActivitiesService {
stageScope,
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') {
(where.AND as Prisma.ActivityWhereInput[]).push(
await this.stages.whereForStage(organizationId, stageScope.stageId),
);
}
const ctx: DtoContext = {
stages: await this.stages.defaults(organizationId),
stages: boardCtx,
types: this.types.systemOf(typeRows),
};
const take = Math.min(Math.max(limit, 1), 200);
@@ -293,7 +303,9 @@ export class ActivitiesService {
desk?: 'mine' | 'team',
facets?: ListFacets,
): 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 kindOf = new Map(rows.map((r) => [r.id, r.kind]));
const where = this.buildWhere(
@@ -308,6 +320,9 @@ export class ActivitiesService {
{ stageId: 'all' },
await this.types.rows(organizationId),
);
(where.AND as Prisma.ActivityWhereInput[]).push(
this.stages.boardWhere(boardId, defaultBoard.id),
);
const groups = await this.prisma.activity.groupBy({
by: ['status', 'stageId'],
where,
@@ -341,6 +356,7 @@ export class ActivitiesService {
where: { id },
data: {
stageId: stage.id,
boardId: stage.boardId,
status: stage.kind,
completedAt:
stage.kind === 'done'
@@ -401,6 +417,12 @@ export class ActivitiesService {
const initialStage = initialStageId
? await this.stages.get(organizationId, initialStageId)
: 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, {
typeDefId: dto.typeDefId,
type: dto.type,
@@ -414,6 +436,7 @@ export class ActivitiesService {
const row = await this.prisma.activity.create({
data: {
organizationId,
boardId,
...(initialStage
? {
stageId: initialStage.id,
@@ -513,6 +536,19 @@ export class ActivitiesService {
});
const data: Prisma.ActivityUncheckedUpdateInput = {};
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[] = [];
const typeChanged = 'typeDefId' in dto || dto.type !== undefined;
if (typeChanged || dto.fields !== undefined) {
@@ -922,6 +958,7 @@ export class ActivitiesService {
? (row.fields as unknown as ActivityFieldValues)
: {};
return {
boardId: this.stages.effectiveBoardId(row, ctx?.stages),
stageId: this.stages.effectiveStageId(row, ctx?.stages),
typeDef: row.typeDef
? this.types.toSummary(row.typeDef)