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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-07 11:18:53 +03:30
co-authored by Cursor
parent b4ffef9b6f
commit aa2f39256a
15 changed files with 1860 additions and 88 deletions
+132 -18
View File
@@ -7,6 +7,7 @@ import type {
ActivityDto,
ActivityFilter,
ActivitySummaryDto,
ActivityStageCountsDto,
CreateActivityRequest,
PaginatedResponse,
UpdateActivityRequest,
@@ -19,9 +20,19 @@ import { MessageAutomationsService } from '../message-automations/message-automa
import { type Actor, assigneeWhere, dealAccessWhere, ownerWhere } from '../common/record-scope';
import { normalizeTags } from '../common/tags';
import { buildSearchWhere, ci } from '../common/search';
import { ActivityStagesService, type StageDefaults } from './activity-stages.service';
type ActivityActor = Pick<Actor, 'userId' | 'role'>;
/** stageId = شناسهٔ مرحله یا 'all'؛ وقتی ست باشد فیلتر زمانی وضعیت را تحمیل نمی‌کند (جز عقب‌افتاده) */
type StageScope = { stageId: string };
type ListFacets = {
type?: ActivityDto['type'];
status?: ActivityDto['status'];
assigneeId?: string;
};
type ActivityRow = {
id: string;
type: ActivityDto['type'];
@@ -45,9 +56,11 @@ type ActivityRow = {
assignee: { id: string; fullName: string } | null;
assignees: Array<{ id: string; fullName: string }>;
createdBy: { id: string; fullName: string } | null;
stage?: { id: string; kind: ActivityDto['status'] } | null;
};
const include = {
stage: { select: { id: true, kind: true } },
person: { select: { id: true, firstName: true, lastName: true } },
company: { select: { id: true, name: true } },
deal: { select: { id: true, title: true } },
@@ -63,6 +76,7 @@ export class ActivitiesService {
private readonly notifications: NotificationsService,
private readonly automations: AutomationsService,
private readonly messageAutomations: MessageAutomationsService,
private readonly stages: ActivityStagesService,
) {}
async summary(
@@ -151,11 +165,8 @@ export class ActivitiesService {
to?: string;
},
desk?: 'mine' | 'team',
facets?: {
type?: ActivityDto['type'];
status?: ActivityDto['status'];
assigneeId?: string;
},
facets?: ListFacets,
stageScope?: StageScope,
): Promise<PaginatedResponse<ActivityDto>> {
const where = this.buildWhere(
organizationId,
@@ -166,7 +177,14 @@ export class ActivitiesService {
range,
desk,
facets,
stageScope,
);
if (stageScope && stageScope.stageId !== 'all') {
(where.AND as Prisma.ActivityWhereInput[]).push(
await this.stages.whereForStage(organizationId, stageScope.stageId),
);
}
const defaults = await this.stages.defaults(organizationId);
const take = Math.min(Math.max(limit, 1), 200);
const [rows, total] = await this.prisma.$transaction([
this.prisma.activity.findMany({
@@ -180,13 +198,87 @@ export class ActivitiesService {
]);
return {
items: rows.map((row) => this.toDto(row)),
items: rows.map((row) => this.toDto(row, defaults)),
total,
page,
limit: take,
};
}
/** تعداد فعالیت هر مرحله با همان فیلترهای لیست (زمان، جستجو، میز، نوع، کارشناس) */
async stageCounts(
organizationId: string,
actor: ActivityActor,
filter: ActivityFilter,
q?: string,
desk?: 'mine' | 'team',
facets?: ListFacets,
): Promise<ActivityStageCountsDto> {
const rows = await this.stages.rows(organizationId);
const defaults = this.stages.defaultsOf(rows);
const kindOf = new Map(rows.map((r) => [r.id, r.kind]));
const where = this.buildWhere(
organizationId,
actor,
filter,
q,
undefined,
undefined,
desk,
facets,
{ stageId: 'all' },
);
const groups = await this.prisma.activity.groupBy({
by: ['status', 'stageId'],
where,
_count: { _all: true },
});
const counts: Record<string, number> = Object.fromEntries(rows.map((r) => [r.id, 0]));
let total = 0;
for (const g of groups) {
const n = g._count._all;
total += n;
const target =
g.stageId && kindOf.get(g.stageId) === g.status ? g.stageId : defaults[g.status];
if (target) counts[target] = (counts[target] ?? 0) + n;
}
return { counts, total };
}
async moveToStage(
organizationId: string,
id: string,
actor: ActivityActor,
stageId: string,
): Promise<ActivityDto> {
await this.ensureExists(organizationId, id, actor);
const stage = await this.stages.get(organizationId, stageId);
const existing = await this.prisma.activity.findFirst({
where: { id, organizationId },
select: { status: true, completedAt: true },
});
const row = await this.prisma.activity.update({
where: { id },
data: {
stageId: stage.id,
status: stage.kind,
completedAt:
stage.kind === 'done'
? existing?.status === 'done'
? existing.completedAt ?? new Date()
: new Date()
: stage.kind === 'open'
? null
: undefined,
},
include,
});
if (stage.kind === 'done' && existing?.status !== 'done') {
await this.fireDone(organizationId, row);
}
return this.toDto(row, await this.stages.defaults(organizationId));
}
async get(
organizationId: string,
id: string,
@@ -197,7 +289,7 @@ export class ActivitiesService {
include,
});
if (!row) throw new NotFoundException('فعالیت پیدا نشد');
return this.toDto(row);
return this.toDto(row, await this.stages.defaults(organizationId));
}
async create(
@@ -217,10 +309,21 @@ export class ActivitiesService {
: [userId],
);
const assigneeId = assigneeIds[0] ?? userId;
const initialStageId = emptyToNull(dto.stageId ?? null);
const initialStage = initialStageId
? await this.stages.get(organizationId, initialStageId)
: null;
const row = await this.prisma.activity.create({
data: {
organizationId,
...(initialStage
? {
stageId: initialStage.id,
status: initialStage.kind,
completedAt: initialStage.kind === 'done' ? new Date() : null,
}
: {}),
title: dto.title.trim(),
type: dto.type ?? 'task',
priority: dto.priority ?? 'medium',
@@ -267,8 +370,11 @@ export class ActivitiesService {
assigneeId: row.assignee?.id ?? null,
ownerId: row.assignee?.id ?? null,
});
if (initialStage?.kind === 'done') {
await this.fireDone(organizationId, row);
}
return this.toDto(row);
return this.toDto(row, await this.stages.defaults(organizationId));
}
async update(
@@ -345,7 +451,7 @@ export class ActivitiesService {
if (dto.status === 'done' && existing?.status !== 'done') {
await this.fireDone(organizationId, row);
}
return this.toDto(row);
return this.toDto(row, await this.stages.defaults(organizationId));
}
async complete(
@@ -366,7 +472,7 @@ export class ActivitiesService {
if (existing?.status !== 'done') {
await this.fireDone(organizationId, row);
}
return this.toDto(row);
return this.toDto(row, await this.stages.defaults(organizationId));
}
async remove(
@@ -393,11 +499,8 @@ export class ActivitiesService {
to?: string;
},
desk?: 'mine' | 'team',
facets?: {
type?: ActivityDto['type'];
status?: ActivityDto['status'];
assigneeId?: string;
},
facets?: ListFacets,
stageScope?: StageScope,
): Prisma.ActivityWhereInput {
const { start, end } = dayBounds();
const scope: Prisma.ActivityWhereInput =
@@ -466,14 +569,24 @@ export class ActivitiesService {
}
// Explicit status facet overrides the status implied by time tabs.
const impliedOpen = facets?.status ? { status: facets.status } : { status: 'open' as const };
// With stage tabs/columns the stage decides status; time filters only narrow dueAt.
const impliedOpen = facets?.status
? { status: facets.status }
: stageScope
? {}
: { status: 'open' as const };
const impliedDone = facets?.status ? { status: facets.status } : { status: 'done' as const };
switch (filter) {
case 'today':
return { ...base, ...impliedOpen, dueAt: { gte: start, lt: end } };
case 'overdue':
return { ...base, ...impliedOpen, dueAt: { lt: start } };
// عقب‌افتاده ذاتاً یعنی باز و موعدگذشته
return {
...base,
...(facets?.status ? { status: facets.status } : { status: 'open' as const }),
dueAt: { lt: start },
};
case 'upcoming':
return {
...base,
@@ -571,8 +684,9 @@ export class ActivitiesService {
}
}
private toDto(row: ActivityRow): ActivityDto {
private toDto(row: ActivityRow, defaults?: StageDefaults): ActivityDto {
return {
stageId: this.stages.effectiveStageId(row, defaults),
id: row.id,
type: row.type,
status: row.status,