fix(leads): keep a future follow-up as an activity on the lead
A scheduled follow-up only stored a date, and activities opened from a lead were not linked back, so they disappeared from the lead. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -36,6 +36,7 @@ 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 { leadsDeepWhere } from '../common/data-access';
|
||||
import { ActivityStagesService, type BoardStageContext } from './activity-stages.service';
|
||||
import {
|
||||
ActivityTypesService,
|
||||
@@ -45,7 +46,10 @@ import {
|
||||
|
||||
type DtoContext = { stages?: BoardStageContext; types?: SystemTypes };
|
||||
|
||||
type ActivityActor = Pick<Actor, 'userId' | 'role' | 'priorLens' | 'priorCutoff'>;
|
||||
type ActivityActor = Pick<
|
||||
Actor,
|
||||
'userId' | 'role' | 'priorLens' | 'priorCutoff' | 'leadsAccess' | 'teamPeerIds'
|
||||
>;
|
||||
|
||||
/** stageId = شناسهٔ مرحله یا 'all'؛ وقتی ست باشد فیلتر زمانی وضعیت را تحمیل نمیکند (جز عقبافتاده) */
|
||||
type StageScope = { stageId: string };
|
||||
@@ -324,7 +328,10 @@ export class ActivitiesService {
|
||||
this.prisma.activity.findMany({
|
||||
where,
|
||||
include,
|
||||
orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
|
||||
// روی پروندهٔ لید، تازهترین (از جمله پیگیری آینده) اول بیاید تا از سقف صفحه نیفتد
|
||||
orderBy: entity?.leadId
|
||||
? [{ createdAt: 'desc' }]
|
||||
: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
|
||||
skip: (page - 1) * take,
|
||||
take,
|
||||
}),
|
||||
@@ -628,6 +635,7 @@ export class ActivitiesService {
|
||||
personId: emptyToNull(dto.personId ?? null),
|
||||
companyId: emptyToNull(dto.companyId ?? null),
|
||||
dealId: emptyToNull(dto.dealId ?? null),
|
||||
leadId: emptyToNull(dto.leadId ?? null),
|
||||
assigneeId,
|
||||
assignees: assigneeIds.length
|
||||
? { connect: assigneeIds.map((id) => ({ id })) }
|
||||
@@ -767,6 +775,7 @@ export class ActivitiesService {
|
||||
if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null);
|
||||
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
|
||||
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
|
||||
if ('leadId' in dto) data.leadId = emptyToNull(dto.leadId ?? null);
|
||||
let assigneeConnect: Array<{ id: string }> | undefined;
|
||||
if (dto.assigneeIds !== undefined || 'assigneeId' in dto) {
|
||||
const current = await this.prisma.activity.findFirst({
|
||||
@@ -1283,11 +1292,13 @@ export class ActivitiesService {
|
||||
personId?: string | null;
|
||||
companyId?: string | null;
|
||||
dealId?: string | null;
|
||||
leadId?: string | null;
|
||||
},
|
||||
) {
|
||||
const personId = emptyToNull(dto.personId ?? null);
|
||||
const companyId = emptyToNull(dto.companyId ?? null);
|
||||
const dealId = emptyToNull(dto.dealId ?? null);
|
||||
const leadId = emptyToNull(dto.leadId ?? null);
|
||||
const scope = ownerWhere(actor);
|
||||
|
||||
if (personId) {
|
||||
@@ -1311,6 +1322,13 @@ export class ActivitiesService {
|
||||
});
|
||||
if (!deal) throw new BadRequestException('معامله انتخابشده معتبر نیست');
|
||||
}
|
||||
if (leadId) {
|
||||
const lead = await this.prisma.lead.findFirst({
|
||||
where: { id: leadId, organizationId, ...leadsDeepWhere(actor) },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!lead) throw new BadRequestException('لید انتخابشده معتبر نیست');
|
||||
}
|
||||
}
|
||||
|
||||
private async notifyParticipants(
|
||||
|
||||
@@ -364,6 +364,11 @@ export class CreateActivityDto {
|
||||
@IsString()
|
||||
dealId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
leadId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
@@ -459,6 +464,11 @@ export class UpdateActivityDto {
|
||||
@IsString()
|
||||
dealId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
leadId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
|
||||
@@ -810,14 +810,14 @@ export class LeadsService {
|
||||
? `تماس ثبت شد — ${dto.outcome}`
|
||||
: 'تماس ثبت شد';
|
||||
await this.addEvent(actor, id, 'call', title, dto.body ?? null);
|
||||
const followAt = this.parseFollowUpAt(dto.nextFollowUpAt);
|
||||
if (followAt) {
|
||||
await this.createLeadFollowUpActivity(actor, before, followAt, dto.body);
|
||||
}
|
||||
const data: Prisma.LeadUpdateInput = {
|
||||
lastContactAt: new Date(),
|
||||
...(dto.nextFollowUpAt !== undefined
|
||||
? {
|
||||
nextFollowUpAt: dto.nextFollowUpAt
|
||||
? new Date(dto.nextFollowUpAt)
|
||||
: null,
|
||||
}
|
||||
? { nextFollowUpAt: followAt }
|
||||
: {}),
|
||||
};
|
||||
if (dto.stageId) data.stage = { connect: { id: dto.stageId } };
|
||||
@@ -856,8 +856,10 @@ export class LeadsService {
|
||||
nextFollowUpAt: string,
|
||||
body?: string,
|
||||
): Promise<LeadDto> {
|
||||
await this.findAccessible(actor, id);
|
||||
const at = new Date(nextFollowUpAt);
|
||||
const lead = await this.findAccessible(actor, id);
|
||||
const at = this.parseFollowUpAt(nextFollowUpAt);
|
||||
if (!at) throw new BadRequestException('زمان پیگیری نامعتبر است');
|
||||
await this.createLeadFollowUpActivity(actor, lead, at, body);
|
||||
await this.addEvent(
|
||||
actor,
|
||||
id,
|
||||
@@ -878,6 +880,39 @@ export class LeadsService {
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
/** پیگیری آینده باید خودش یک فعالیت باز و متصل به لید باشد، نه فقط یک تاریخ. */
|
||||
private parseFollowUpAt(value?: string | null): Date | null {
|
||||
if (!value) return null;
|
||||
const at = new Date(value);
|
||||
if (Number.isNaN(at.getTime())) {
|
||||
throw new BadRequestException('زمان پیگیری نامعتبر است');
|
||||
}
|
||||
return at;
|
||||
}
|
||||
|
||||
private async createLeadFollowUpActivity(
|
||||
actor: Actor,
|
||||
lead: { id: string; title: string; assigneeId: string | null },
|
||||
dueAt: Date,
|
||||
body?: string | null,
|
||||
) {
|
||||
const allowOthers = await actorCanAssignToOthers(this.prisma, actor, 'activities');
|
||||
const assigneeId = allowOthers && lead.assigneeId ? lead.assigneeId : actor.userId;
|
||||
await this.prisma.activity.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
type: 'task',
|
||||
title: `پیگیری: ${lead.title}`.slice(0, 200),
|
||||
description: body?.trim() || null,
|
||||
dueAt,
|
||||
leadId: lead.id,
|
||||
assigneeId,
|
||||
assignees: { connect: [{ id: assigneeId }] },
|
||||
createdById: actor.userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async archive(actor: Actor, ids: string[]): Promise<number> {
|
||||
if (!elevated(actor.role) && ids.length > 1) {
|
||||
throw new BadRequestException('حذف گروهی فقط برای مدیر');
|
||||
|
||||
@@ -126,6 +126,7 @@ const dueDate = ref('');
|
||||
const dueTime = ref('10:30');
|
||||
const durationMinutes = ref(30);
|
||||
const personId = ref('');
|
||||
const leadId = ref('');
|
||||
const dealId = ref('');
|
||||
const assigneeIds = ref<string[]>([]);
|
||||
const isDone = ref(false);
|
||||
@@ -519,6 +520,7 @@ async function load() {
|
||||
if (data.person?.id) await ensurePersonLoaded(data.person.id);
|
||||
personId.value = data.person?.id ?? '';
|
||||
dealId.value = data.deal?.id ?? '';
|
||||
leadId.value = data.lead?.id ?? '';
|
||||
assigneeIds.value = data.assignees?.length
|
||||
? data.assignees.map((a) => a.id)
|
||||
: data.assignee?.id
|
||||
@@ -538,6 +540,7 @@ async function load() {
|
||||
await ensurePersonLoaded(incomingPerson);
|
||||
personId.value = incomingPerson;
|
||||
}
|
||||
leadId.value = typeof route.query.leadId === 'string' ? route.query.leadId : '';
|
||||
const incomingDef =
|
||||
typeof route.query.typeDefId === 'string'
|
||||
? activityTypes.byId.value.get(route.query.typeDefId)
|
||||
@@ -599,6 +602,7 @@ function payload(): CreateActivityRequest {
|
||||
personId: personId.value || null,
|
||||
companyId: selectedPerson.value?.company?.id ?? null,
|
||||
dealId: dealId.value || null,
|
||||
leadId: leadId.value || null,
|
||||
assigneeIds: [...assigneeIds.value],
|
||||
assigneeId: assigneeIds.value[0] || null,
|
||||
};
|
||||
|
||||
@@ -845,6 +845,16 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
|
||||
</template>
|
||||
</a-list>
|
||||
<a-empty v-else description="فعالیت مرتبطی نیست." />
|
||||
<RouterLink
|
||||
:to="{ name: 'activity-new', query: { leadId: lead.id } }"
|
||||
custom
|
||||
v-slot="{ navigate }"
|
||||
>
|
||||
<a-button type="dashed" block @click="navigate">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
ایجاد فعالیت
|
||||
</a-button>
|
||||
</RouterLink>
|
||||
|
||||
<a-divider orientation="right">جدول زمانی</a-divider>
|
||||
<a-timeline v-if="lead.events.length">
|
||||
@@ -889,17 +899,6 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
|
||||
مشاهده همه فعالیتها
|
||||
</a-button>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-if="narrow"
|
||||
:to="{ name: 'activity-new' }"
|
||||
custom
|
||||
v-slot="{ navigate }"
|
||||
>
|
||||
<a-button type="dashed" block @click="navigate">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
ایجاد فعالیت
|
||||
</a-button>
|
||||
</RouterLink>
|
||||
</template>
|
||||
</a-card>
|
||||
|
||||
|
||||
@@ -1982,6 +1982,8 @@ export interface CreateActivityRequest {
|
||||
personId?: string | null;
|
||||
companyId?: string | null;
|
||||
dealId?: string | null;
|
||||
/** لید مرتبط؛ فعالیت در پروندهٔ همان لید میماند */
|
||||
leadId?: string | null;
|
||||
/** سازگاری — اگر assigneeIds نباشد از این استفاده میشود */
|
||||
assigneeId?: string | null;
|
||||
/** چند مسئول؛ اولی بهعنوان assigneeId ذخیره میشود */
|
||||
@@ -2007,6 +2009,7 @@ export interface UpdateActivityRequest {
|
||||
personId?: string | null;
|
||||
companyId?: string | null;
|
||||
dealId?: string | null;
|
||||
leadId?: string | null;
|
||||
assigneeId?: string | null;
|
||||
assigneeIds?: string[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user