feat(activities): editable stages with tab and kanban views
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user