diff --git a/apps/api/prisma/migrations/20261007100000_activity_type_defs/migration.sql b/apps/api/prisma/migrations/20261007100000_activity_type_defs/migration.sql
new file mode 100644
index 0000000..7d46906
--- /dev/null
+++ b/apps/api/prisma/migrations/20261007100000_activity_type_defs/migration.sql
@@ -0,0 +1,37 @@
+-- AlterTable
+ALTER TABLE "Activity" ADD COLUMN "fields" JSONB,
+ADD COLUMN "participantIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
+ADD COLUMN "typeDefId" TEXT;
+
+-- CreateTable
+CREATE TABLE "ActivityTypeDef" (
+ "id" TEXT NOT NULL,
+ "organizationId" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "baseType" "ActivityType" NOT NULL DEFAULT 'task',
+ "icon" TEXT NOT NULL DEFAULT 'check',
+ "color" TEXT NOT NULL DEFAULT 'green',
+ "fields" JSONB NOT NULL DEFAULT '[]',
+ "position" INTEGER NOT NULL DEFAULT 0,
+ "isActive" BOOLEAN NOT NULL DEFAULT true,
+ "isSystem" BOOLEAN NOT NULL DEFAULT false,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "ActivityTypeDef_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "ActivityTypeDef_organizationId_position_idx" ON "ActivityTypeDef"("organizationId", "position");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "ActivityTypeDef_organizationId_name_key" ON "ActivityTypeDef"("organizationId", "name");
+
+-- CreateIndex
+CREATE INDEX "Activity_typeDefId_idx" ON "Activity"("typeDefId");
+
+-- AddForeignKey
+ALTER TABLE "ActivityTypeDef" ADD CONSTRAINT "ActivityTypeDef_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Activity" ADD CONSTRAINT "Activity_typeDefId_fkey" FOREIGN KEY ("typeDefId") REFERENCES "ActivityTypeDef"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma
index 4d0000d..87a93e6 100644
--- a/apps/api/prisma/schema.prisma
+++ b/apps/api/prisma/schema.prisma
@@ -207,6 +207,7 @@ model Organization {
leads Lead[]
leadStages LeadStage[]
activityStages ActivityStage[]
+ activityTypeDefs ActivityTypeDef[]
leadEvents LeadEvent[]
leadImportJobs LeadImportJob[]
conversationSettings ConversationSettings?
@@ -646,6 +647,30 @@ model ActivityStage {
@@index([organizationId, position])
}
+/// نوع فعالیت قابل تعریف توسط سازمان با فیلدهای اختصاصی
+model ActivityTypeDef {
+ id String @id @default(cuid())
+ organizationId String
+ organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+ name String
+ /// نوع پایه — مبنای گزارشها، یادآورها و سازگاری
+ baseType ActivityType @default(task)
+ icon String @default("check")
+ color String @default("green")
+ /// تعریف فیلدها: [{ key, label, type, options?, required?, placeholder? }]
+ fields Json @default("[]")
+ position Int @default(0)
+ isActive Boolean @default(true)
+ /// نوع اصلیِ هر baseType — حذفشدنی نیست
+ isSystem Boolean @default(false)
+ activities Activity[]
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@unique([organizationId, name])
+ @@index([organizationId, position])
+}
+
model Activity {
id String @id @default(cuid())
organizationId String
@@ -683,10 +708,18 @@ model Activity {
/// مرحله (ستون کانبان)؛ null یا مرحلهٔ ناهمنوع با status → اولین مرحلهٔ همنوع
stageId String?
stage ActivityStage? @relation(fields: [stageId], references: [id], onDelete: SetNull)
+ /// نوع سفارشی؛ null → نوع اصلیِ همان type
+ typeDefId String?
+ typeDef ActivityTypeDef? @relation(fields: [typeDefId], references: [id], onDelete: SetNull)
+ /// مقادیر فیلدهای اختصاصی نوع (کلید فیلد → مقدار)
+ fields Json?
+ /// کاربران شرکتکننده؛ فعالیت در لیست آنها هم دیده میشود
+ participantIds String[] @default([])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([stageId])
+ @@index([typeDefId])
@@index([organizationId, status, dueAt])
@@index([organizationId, assigneeId])
@@index([organizationId, createdById])
diff --git a/apps/api/src/activities/activities.controller.ts b/apps/api/src/activities/activities.controller.ts
index 831a687..268c70b 100644
--- a/apps/api/src/activities/activities.controller.ts
+++ b/apps/api/src/activities/activities.controller.ts
@@ -25,15 +25,19 @@ import type { Actor } from '../common/record-scope';
import { ActivitiesService } from './activities.service';
import { ActivitiesExcelService } from './activities-excel.service';
import { ActivityStagesService } from './activity-stages.service';
+import { ActivityTypesService } from './activity-types.service';
import {
CreateActivityDto,
CreateActivityStageDto,
+ CreateActivityTypeDto,
ImportActivityRowsDto,
ListActivitiesQueryDto,
MoveActivityStageDto,
ReorderActivityStagesDto,
+ ReorderActivityTypesDto,
UpdateActivityDto,
UpdateActivityStageDto,
+ UpdateActivityTypeDto,
} from './dto';
import { brand } from '@mento/shared';
@@ -49,6 +53,7 @@ export class ActivitiesController {
private readonly activitiesService: ActivitiesService,
private readonly excelService: ActivitiesExcelService,
private readonly stagesService: ActivityStagesService,
+ private readonly typesService: ActivityTypesService,
) {}
@Get('summary')
@@ -94,6 +99,44 @@ export class ActivitiesController {
return { ok: true };
}
+ @Get('types')
+ listTypes(@CurrentUser() user: Actor) {
+ return this.typesService.list(user.organizationId);
+ }
+
+ @Post('types')
+ @UseGuards(RolesGuard)
+ @Roles('owner', 'admin')
+ createType(@CurrentUser() user: Actor, @Body() dto: CreateActivityTypeDto) {
+ return this.typesService.create(user.organizationId, dto);
+ }
+
+ @Patch('types/reorder')
+ @UseGuards(RolesGuard)
+ @Roles('owner', 'admin')
+ reorderTypes(@CurrentUser() user: Actor, @Body() dto: ReorderActivityTypesDto) {
+ return this.typesService.reorder(user.organizationId, dto.typeIds);
+ }
+
+ @Patch('types/:id')
+ @UseGuards(RolesGuard)
+ @Roles('owner', 'admin')
+ updateType(
+ @CurrentUser() user: Actor,
+ @Param('id') id: string,
+ @Body() dto: UpdateActivityTypeDto,
+ ) {
+ return this.typesService.update(user.organizationId, id, dto);
+ }
+
+ @Delete('types/:id')
+ @UseGuards(RolesGuard)
+ @Roles('owner', 'admin')
+ async removeType(@CurrentUser() user: Actor, @Param('id') id: string) {
+ await this.typesService.remove(user.organizationId, id);
+ return { ok: true };
+ }
+
@Get('stage-counts')
stageCounts(
@CurrentUser() user: Actor,
@@ -107,6 +150,7 @@ export class ActivitiesController {
query.desk,
{
type: query.type,
+ typeDefId: query.typeDefId,
status: query.status,
assigneeId: query.assigneeId,
},
@@ -137,6 +181,7 @@ export class ActivitiesController {
query.desk,
{
type: query.type,
+ typeDefId: query.typeDefId,
status: query.status,
assigneeId: query.assigneeId,
},
diff --git a/apps/api/src/activities/activities.module.ts b/apps/api/src/activities/activities.module.ts
index f67e796..fb69c95 100644
--- a/apps/api/src/activities/activities.module.ts
+++ b/apps/api/src/activities/activities.module.ts
@@ -3,6 +3,7 @@ import { ActivitiesController } from './activities.controller';
import { ActivitiesService } from './activities.service';
import { ActivitiesExcelService } from './activities-excel.service';
import { ActivityStagesService } from './activity-stages.service';
+import { ActivityTypesService } from './activity-types.service';
import { NotificationsModule } from '../notifications/notifications.module';
import { AutomationsModule } from '../automations/automations.module';
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
@@ -10,7 +11,12 @@ import { MessageAutomationsModule } from '../message-automations/message-automat
@Module({
imports: [NotificationsModule, AutomationsModule, MessageAutomationsModule],
controllers: [ActivitiesController],
- providers: [ActivitiesService, ActivitiesExcelService, ActivityStagesService],
- exports: [ActivitiesService, ActivityStagesService],
+ providers: [
+ ActivitiesService,
+ ActivitiesExcelService,
+ ActivityStagesService,
+ ActivityTypesService,
+ ],
+ exports: [ActivitiesService, ActivityStagesService, ActivityTypesService],
})
export class ActivitiesModule {}
diff --git a/apps/api/src/activities/activities.service.ts b/apps/api/src/activities/activities.service.ts
index 2ef0eb2..1a261f9 100644
--- a/apps/api/src/activities/activities.service.ts
+++ b/apps/api/src/activities/activities.service.ts
@@ -5,6 +5,7 @@ import {
} from '@nestjs/common';
import type {
ActivityDto,
+ ActivityFieldValues,
ActivityFilter,
ActivitySummaryDto,
ActivityStageCountsDto,
@@ -17,10 +18,22 @@ import { PrismaService } from '../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import { AutomationsService } from '../automations/automations.service';
import { MessageAutomationsService } from '../message-automations/message-automations.service';
-import { type Actor, assigneeWhere, dealAccessWhere, ownerWhere } from '../common/record-scope';
+import {
+ type Actor,
+ activityAccessWhere,
+ 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';
+import {
+ ActivityTypesService,
+ type SystemTypes,
+ type TypeRow,
+} from './activity-types.service';
+
+type DtoContext = { stages?: StageDefaults; types?: SystemTypes };
type ActivityActor = Pick;
@@ -29,6 +42,7 @@ type StageScope = { stageId: string };
type ListFacets = {
type?: ActivityDto['type'];
+ typeDefId?: string;
status?: ActivityDto['status'];
assigneeId?: string;
};
@@ -57,10 +71,14 @@ type ActivityRow = {
assignees: Array<{ id: string; fullName: string }>;
createdBy: { id: string; fullName: string } | null;
stage?: { id: string; kind: ActivityDto['status'] } | 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 } },
+ typeDef: { select: { id: true, name: true, icon: true, color: true } },
person: { select: { id: true, firstName: true, lastName: true } },
company: { select: { id: true, name: true } },
deal: { select: { id: true, title: true } },
@@ -77,14 +95,23 @@ export class ActivitiesService {
private readonly automations: AutomationsService,
private readonly messageAutomations: MessageAutomationsService,
private readonly stages: ActivityStagesService,
+ private readonly types: ActivityTypesService,
) {}
+ private async ctx(organizationId: string): Promise {
+ const [stages, types] = await Promise.all([
+ this.stages.defaults(organizationId),
+ this.types.system(organizationId),
+ ]);
+ return { stages, types };
+ }
+
async summary(
organizationId: string,
actor: ActivityActor,
): Promise {
const { start, end } = dayBounds();
- const scope = assigneeWhere(actor);
+ const scope = activityAccessWhere(actor);
const [todayCount, overdueCount] = await Promise.all([
this.prisma.activity.count({
where: {
@@ -139,10 +166,11 @@ export class ActivitiesService {
}),
]);
+ const ctx = await this.ctx(organizationId);
return {
- today: today.map((row) => this.toDto(row)),
- overdue: overdue.map((row) => this.toDto(row)),
- upcoming: upcoming.map((row) => this.toDto(row)),
+ today: today.map((row) => this.toDto(row, ctx)),
+ overdue: overdue.map((row) => this.toDto(row, ctx)),
+ upcoming: upcoming.map((row) => this.toDto(row, ctx)),
todayCount,
overdueCount,
};
@@ -168,6 +196,7 @@ export class ActivitiesService {
facets?: ListFacets,
stageScope?: StageScope,
): Promise> {
+ const typeRows = await this.types.rows(organizationId);
const where = this.buildWhere(
organizationId,
actor,
@@ -178,13 +207,17 @@ export class ActivitiesService {
desk,
facets,
stageScope,
+ typeRows,
);
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 ctx: DtoContext = {
+ stages: await this.stages.defaults(organizationId),
+ types: this.types.systemOf(typeRows),
+ };
const take = Math.min(Math.max(limit, 1), 200);
const [rows, total] = await this.prisma.$transaction([
this.prisma.activity.findMany({
@@ -198,7 +231,7 @@ export class ActivitiesService {
]);
return {
- items: rows.map((row) => this.toDto(row, defaults)),
+ items: rows.map((row) => this.toDto(row, ctx)),
total,
page,
limit: take,
@@ -227,6 +260,7 @@ export class ActivitiesService {
desk,
facets,
{ stageId: 'all' },
+ await this.types.rows(organizationId),
);
const groups = await this.prisma.activity.groupBy({
by: ['status', 'stageId'],
@@ -276,7 +310,7 @@ export class ActivitiesService {
if (stage.kind === 'done' && existing?.status !== 'done') {
await this.fireDone(organizationId, row);
}
- return this.toDto(row, await this.stages.defaults(organizationId));
+ return this.toDto(row, await this.ctx(organizationId));
}
async get(
@@ -285,11 +319,19 @@ export class ActivitiesService {
actor: ActivityActor,
): Promise {
const row = await this.prisma.activity.findFirst({
- where: { id, organizationId, ...assigneeWhere(actor) },
+ where: { id, organizationId, ...activityAccessWhere(actor) },
include,
});
if (!row) throw new NotFoundException('فعالیت پیدا نشد');
- return this.toDto(row, await this.stages.defaults(organizationId));
+ const dto = this.toDto(row, await this.ctx(organizationId));
+ if (row.participantIds.length) {
+ dto.participantUsers = await this.prisma.user.findMany({
+ where: { id: { in: row.participantIds } },
+ select: { id: true, fullName: true },
+ orderBy: { fullName: 'asc' },
+ });
+ }
+ return dto;
}
async create(
@@ -313,6 +355,15 @@ export class ActivitiesService {
const initialStage = initialStageId
? await this.stages.get(organizationId, initialStageId)
: null;
+ const resolved = await this.types.resolve(organizationId, {
+ typeDefId: dto.typeDefId,
+ type: dto.type,
+ });
+ const { fields, participantIds } = await this.types.sanitizeValues(
+ organizationId,
+ resolved.fields,
+ dto.fields,
+ );
const row = await this.prisma.activity.create({
data: {
@@ -325,7 +376,10 @@ export class ActivitiesService {
}
: {}),
title: dto.title.trim(),
- type: dto.type ?? 'task',
+ type: resolved.baseType,
+ typeDefId: resolved.typeDefId,
+ fields: fields as Prisma.InputJsonValue,
+ participantIds,
priority: dto.priority ?? 'medium',
description: emptyToNull(dto.description),
dueAt: dto.dueAt ? new Date(dto.dueAt) : null,
@@ -358,6 +412,11 @@ export class ActivitiesService {
entityId: row.id,
});
}
+ await this.notifyParticipants(
+ organizationId,
+ row,
+ participantIds.filter((uid) => !assigneeIds.includes(uid) && uid !== userId),
+ );
await this.messageAutomations.onEvent('activity_created', {
organizationId,
@@ -374,7 +433,7 @@ export class ActivitiesService {
await this.fireDone(organizationId, row);
}
- return this.toDto(row, await this.stages.defaults(organizationId));
+ return this.toDto(row, await this.ctx(organizationId));
}
async update(
@@ -386,9 +445,33 @@ export class ActivitiesService {
await this.ensureExists(organizationId, id, actor);
await this.assertLinks(organizationId, actor, dto);
+ const existing = await this.prisma.activity.findFirst({
+ where: { id, organizationId },
+ select: { status: true, type: true, typeDefId: true, fields: true, participantIds: true },
+ });
const data: Prisma.ActivityUncheckedUpdateInput = {};
if (dto.title !== undefined) data.title = dto.title.trim();
- if (dto.type !== undefined) data.type = dto.type;
+ let addedParticipants: string[] = [];
+ const typeChanged = 'typeDefId' in dto || dto.type !== undefined;
+ if (typeChanged || dto.fields !== undefined) {
+ const resolved = await this.types.resolve(
+ organizationId,
+ typeChanged
+ ? { typeDefId: dto.typeDefId, type: dto.type }
+ : { typeDefId: existing?.typeDefId, type: existing?.type },
+ );
+ const { fields, participantIds } = await this.types.sanitizeValues(
+ organizationId,
+ resolved.fields,
+ dto.fields ?? existing?.fields,
+ );
+ data.type = resolved.baseType;
+ data.typeDefId = resolved.typeDefId;
+ data.fields = fields as Prisma.InputJsonValue;
+ data.participantIds = participantIds;
+ const before = new Set(existing?.participantIds ?? []);
+ addedParticipants = participantIds.filter((uid) => !before.has(uid) && uid !== actor.userId);
+ }
if (dto.priority !== undefined) data.priority = dto.priority;
if ('description' in dto) data.description = emptyToNull(dto.description);
if ('dueAt' in dto) data.dueAt = dto.dueAt ? new Date(dto.dueAt) : null;
@@ -434,10 +517,6 @@ export class ActivitiesService {
dto.status === 'done' ? new Date() : dto.status === 'open' ? null : undefined;
}
- const existing = await this.prisma.activity.findFirst({
- where: { id, organizationId },
- select: { status: true },
- });
const row = await this.prisma.activity.update({
where: { id },
data: {
@@ -448,10 +527,21 @@ export class ActivitiesService {
},
include,
});
+ if (addedParticipants.length) {
+ const assigneeSet = new Set([
+ ...(row.assignee ? [row.assignee.id] : []),
+ ...row.assignees.map((a) => a.id),
+ ]);
+ await this.notifyParticipants(
+ organizationId,
+ row,
+ addedParticipants.filter((uid) => !assigneeSet.has(uid)),
+ );
+ }
if (dto.status === 'done' && existing?.status !== 'done') {
await this.fireDone(organizationId, row);
}
- return this.toDto(row, await this.stages.defaults(organizationId));
+ return this.toDto(row, await this.ctx(organizationId));
}
async complete(
@@ -472,7 +562,7 @@ export class ActivitiesService {
if (existing?.status !== 'done') {
await this.fireDone(organizationId, row);
}
- return this.toDto(row, await this.stages.defaults(organizationId));
+ return this.toDto(row, await this.ctx(organizationId));
}
async remove(
@@ -501,6 +591,7 @@ export class ActivitiesService {
desk?: 'mine' | 'team',
facets?: ListFacets,
stageScope?: StageScope,
+ typeRows: TypeRow[] = [],
): Prisma.ActivityWhereInput {
const { start, end } = dayBounds();
const scope: Prisma.ActivityWhereInput =
@@ -509,13 +600,16 @@ export class ActivitiesService {
OR: [
{ assigneeId: actor.userId },
{ assignees: { some: { id: actor.userId } } },
+ { participantIds: { has: actor.userId } },
],
}
- : assigneeWhere(actor);
+ : activityAccessWhere(actor);
const facetAssignee = facets?.assigneeId?.trim();
+ const facetTypeDef = facets?.typeDefId?.trim();
const andParts: Prisma.ActivityWhereInput[] = [
scope,
...(facets?.type ? [{ type: facets.type }] : []),
+ ...(facetTypeDef ? [this.types.whereForType(typeRows, facetTypeDef)] : []),
...(facets?.status ? [{ status: facets.status }] : []),
...(facetAssignee
? [
@@ -547,6 +641,10 @@ export class ActivitiesService {
},
{ company: { name: ci(t) } },
{ deal: { title: ci(t) } },
+ { assignee: { fullName: ci(t) } },
+ { assignees: { some: { fullName: ci(t) } } },
+ ...typesMatching(t).map((type) => ({ type })),
+ ...this.types.searchByName(typeRows, t),
]);
if (search) andParts.push(search);
const base: Prisma.ActivityWhereInput = {
@@ -633,7 +731,7 @@ export class ActivitiesService {
actor: ActivityActor,
) {
const found = await this.prisma.activity.findFirst({
- where: { id, organizationId, ...assigneeWhere(actor) },
+ where: { id, organizationId, ...activityAccessWhere(actor) },
select: { id: true },
});
if (!found) throw new NotFoundException('فعالیت پیدا نشد');
@@ -684,9 +782,37 @@ export class ActivitiesService {
}
}
- private toDto(row: ActivityRow, defaults?: StageDefaults): ActivityDto {
+ private async notifyParticipants(
+ organizationId: string,
+ row: { id: string; title: string },
+ userIds: string[],
+ ) {
+ for (const uid of new Set(userIds)) {
+ await this.notifications.create({
+ organizationId,
+ userId: uid,
+ type: 'activity_assigned',
+ title: 'شما بهعنوان شرکتکننده اضافه شدید',
+ body: row.title,
+ link: `/activities/${row.id}`,
+ entityType: 'activity',
+ entityId: row.id,
+ });
+ }
+ }
+
+ private toDto(row: ActivityRow, ctx?: DtoContext): ActivityDto {
+ const fields =
+ row.fields && typeof row.fields === 'object' && !Array.isArray(row.fields)
+ ? (row.fields as unknown as ActivityFieldValues)
+ : {};
return {
- stageId: this.stages.effectiveStageId(row, defaults),
+ stageId: this.stages.effectiveStageId(row, ctx?.stages),
+ typeDef: row.typeDef
+ ? this.types.toSummary(row.typeDef)
+ : (ctx?.types?.[row.type] ?? null),
+ fields,
+ participantIds: row.participantIds ?? [],
id: row.id,
type: row.type,
status: row.status,
@@ -741,6 +867,22 @@ export class ActivitiesService {
}
}
+const TYPE_SEARCH_TERMS: Record = {
+ call: ['تماس', 'تلفن', 'call'],
+ meeting: ['جلسه', 'ملاقات', 'meeting'],
+ task: ['وظیفه', 'تسک', 'task'],
+ note: ['پیام', 'یادداشت', 'note'],
+};
+
+/** جستجوی «جلسه» یا «تماس» → فیلتر نوع فعالیت */
+function typesMatching(term: string): ActivityDto['type'][] {
+ const t = term.trim().toLowerCase();
+ if (t.length < 2) return [];
+ return (Object.keys(TYPE_SEARCH_TERMS) as ActivityDto['type'][]).filter((type) =>
+ TYPE_SEARCH_TERMS[type].some((label) => label.includes(t) || t.includes(label)),
+ );
+}
+
function emptyToNull(value?: string | null): string | null {
if (value == null) return null;
const trimmed = value.trim();
diff --git a/apps/api/src/activities/activity-types.service.ts b/apps/api/src/activities/activity-types.service.ts
new file mode 100644
index 0000000..8762ca0
--- /dev/null
+++ b/apps/api/src/activities/activity-types.service.ts
@@ -0,0 +1,649 @@
+import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
+import type {
+ ActivityChecklistItem,
+ ActivityFieldDef,
+ ActivityFieldType,
+ ActivityFieldValue,
+ ActivityFieldValues,
+ ActivityParticipantsValue,
+ ActivityStageColor,
+ ActivityType,
+ ActivityTypeDefDto,
+ ActivityTypeIcon,
+ ActivityTypeSummaryDto,
+ CreateActivityTypeRequest,
+ UpdateActivityTypeRequest,
+} from '@mento/shared';
+import {
+ ACTIVITY_FIELD_TYPES,
+ ACTIVITY_FIELD_TYPES_WITH_OPTIONS,
+ ACTIVITY_STAGE_COLORS,
+ ACTIVITY_TYPE_ICONS,
+} from '@mento/shared';
+import { Prisma } from '@prisma/client';
+import { randomUUID } from 'node:crypto';
+import { PrismaService } from '../prisma/prisma.service';
+
+const BASE_TYPES: ActivityType[] = ['call', 'meeting', 'task', 'note'];
+const MAX_FIELDS = 30;
+const MAX_OPTIONS = 50;
+const MAX_CHECKLIST = 100;
+const MAX_PARTICIPANTS = 100;
+
+type Seed = {
+ name: string;
+ baseType: ActivityType;
+ icon: ActivityTypeIcon;
+ color: ActivityStageColor;
+ isSystem?: boolean;
+ fields: ActivityFieldDef[];
+};
+
+const DEFAULT_TYPES: Seed[] = [
+ {
+ name: 'تماس',
+ baseType: 'call',
+ icon: 'phone',
+ color: 'green',
+ isSystem: true,
+ fields: [
+ { key: 'call_direction', label: 'جهت تماس', type: 'select', options: ['خروجی', 'ورودی'] },
+ {
+ key: 'call_result',
+ label: 'نتیجه تماس',
+ type: 'select',
+ options: [
+ 'پاسخ داد',
+ 'پاسخ نداد',
+ 'مشغول بود',
+ 'خاموش / در دسترس نبود',
+ 'شماره اشتباه',
+ 'نیاز به پیگیری مجدد',
+ ],
+ },
+ ],
+ },
+ {
+ name: 'جلسه',
+ baseType: 'meeting',
+ icon: 'users',
+ color: 'purple',
+ isSystem: true,
+ fields: [
+ {
+ key: 'meeting_subject',
+ label: 'موضوع جلسه',
+ type: 'text',
+ placeholder: 'مثلاً: بررسی پیشفاکتور و شرایط پرداخت',
+ },
+ { key: 'meeting_participants', label: 'شرکتکنندگان', type: 'participants' },
+ {
+ key: 'meeting_location',
+ label: 'محل برگزاری / لینک جلسه',
+ type: 'text',
+ placeholder: 'آدرس یا لینک جلسهٔ آنلاین',
+ },
+ { key: 'meeting_minutes', label: 'صورتجلسه', type: 'textarea' },
+ ],
+ },
+ {
+ name: 'وظیفه',
+ baseType: 'task',
+ icon: 'check',
+ color: 'orange',
+ isSystem: true,
+ fields: [{ key: 'task_checklist', label: 'لیست کارها', type: 'checklist' }],
+ },
+ {
+ name: 'پیام',
+ baseType: 'note',
+ icon: 'chat',
+ color: 'blue',
+ isSystem: true,
+ fields: [
+ {
+ key: 'message_channel',
+ label: 'کانال ارسال',
+ type: 'select',
+ options: ['پیامک', 'واتساپ', 'تلگرام', 'ایتا', 'بله', 'ایمیل', 'دایرکت اینستاگرام'],
+ },
+ ],
+ },
+ {
+ name: 'بازدید حضوری',
+ baseType: 'meeting',
+ icon: 'pin',
+ color: 'rose',
+ fields: [
+ { key: 'visit_address', label: 'آدرس محل بازدید', type: 'text' },
+ { key: 'visit_participants', label: 'همراهان بازدید', type: 'participants' },
+ { key: 'visit_result', label: 'نتیجهٔ بازدید', type: 'textarea' },
+ ],
+ },
+ {
+ name: 'ارائه / دمو محصول',
+ baseType: 'meeting',
+ icon: 'star',
+ color: 'purple',
+ fields: [
+ { key: 'demo_product', label: 'محصول / خدمت', type: 'text' },
+ { key: 'demo_participants', label: 'شرکتکنندگان', type: 'participants' },
+ { key: 'demo_link', label: 'لینک جلسه', type: 'url' },
+ {
+ key: 'demo_result',
+ label: 'نتیجهٔ دمو',
+ type: 'select',
+ options: ['علاقهمند', 'نیاز به بررسی بیشتر', 'درخواست پیشفاکتور', 'عدم علاقه'],
+ },
+ ],
+ },
+ {
+ name: 'پیگیری',
+ baseType: 'call',
+ icon: 'bell',
+ color: 'orange',
+ fields: [
+ {
+ key: 'followup_reason',
+ label: 'دلیل پیگیری',
+ type: 'select',
+ options: ['پیشفاکتور', 'پرداخت', 'ارسال کالا', 'پشتیبانی', 'تمدید قرارداد', 'سایر'],
+ },
+ { key: 'followup_result', label: 'نتیجهٔ پیگیری', type: 'textarea' },
+ { key: 'followup_next', label: 'تاریخ پیگیری بعدی', type: 'date' },
+ ],
+ },
+ {
+ name: 'ایمیل',
+ baseType: 'note',
+ icon: 'mail',
+ color: 'slate',
+ fields: [
+ { key: 'email_subject', label: 'موضوع ایمیل', type: 'text' },
+ { key: 'email_to', label: 'گیرنده', type: 'text', placeholder: 'example@domain.com' },
+ ],
+ },
+];
+
+export type TypeRow = {
+ id: string;
+ name: string;
+ baseType: ActivityType;
+ icon: string;
+ color: string;
+ fields: Prisma.JsonValue;
+ position: number;
+ isActive: boolean;
+ isSystem: boolean;
+};
+
+/** نوع اصلی هر baseType — مقصد فعالیتهای بدون typeDefId */
+export type SystemTypes = Record;
+
+export type ResolvedType = {
+ /** مقداری که در Activity.typeDefId ذخیره میشود (نوع اصلی → null) */
+ typeDefId: string | null;
+ baseType: ActivityType;
+ fields: ActivityFieldDef[];
+};
+
+const select = {
+ id: true,
+ name: true,
+ baseType: true,
+ icon: true,
+ color: true,
+ fields: true,
+ position: true,
+ isActive: true,
+ isSystem: true,
+} satisfies Prisma.ActivityTypeDefSelect;
+
+@Injectable()
+export class ActivityTypesService {
+ constructor(private readonly prisma: PrismaService) {}
+
+ async ensureDefaults(organizationId: string): Promise {
+ const count = await this.prisma.activityTypeDef.count({ where: { organizationId } });
+ if (count > 0) return;
+ await this.prisma.activityTypeDef.createMany({
+ data: DEFAULT_TYPES.map((t, i) => ({
+ organizationId,
+ name: t.name,
+ baseType: t.baseType,
+ icon: t.icon,
+ color: t.color,
+ isSystem: Boolean(t.isSystem),
+ fields: t.fields as unknown as Prisma.InputJsonValue,
+ position: i,
+ })),
+ skipDuplicates: true,
+ });
+ }
+
+ async rows(organizationId: string): Promise {
+ await this.ensureDefaults(organizationId);
+ return this.prisma.activityTypeDef.findMany({
+ where: { organizationId },
+ orderBy: [{ position: 'asc' }, { createdAt: 'asc' }],
+ select,
+ });
+ }
+
+ async list(organizationId: string): Promise {
+ return (await this.rows(organizationId)).map((r) => this.toDto(r));
+ }
+
+ systemOf(rows: TypeRow[]): SystemTypes {
+ const out: SystemTypes = { call: null, meeting: null, task: null, note: null };
+ for (const r of rows) {
+ if (r.isSystem && !out[r.baseType]) out[r.baseType] = this.toSummary(r);
+ }
+ return out;
+ }
+
+ async system(organizationId: string): Promise {
+ return this.systemOf(await this.rows(organizationId));
+ }
+
+ /** فیلتر Prisma برای فعالیتهایی که «مؤثراً» از این نوع هستند */
+ whereForType(rows: TypeRow[], typeDefId: string): Prisma.ActivityWhereInput {
+ const def = rows.find((r) => r.id === typeDefId);
+ if (!def) return { id: '__none__' };
+ if (!def.isSystem) return { typeDefId: def.id };
+ return { OR: [{ typeDefId: def.id }, { typeDefId: null, type: def.baseType }] };
+ }
+
+ /** جستجوی نام نوع → شرطهای OR */
+ searchByName(rows: TypeRow[], term: string): Prisma.ActivityWhereInput[] {
+ const t = term.trim();
+ if (t.length < 2) return [];
+ return rows
+ .filter((r) => r.name.includes(t))
+ .map((r) => this.whereForType(rows, r.id));
+ }
+
+ /**
+ * نوع مؤثر برای ذخیره: typeDefId صریح، یا نوع اصلیِ type.
+ * نوع اصلی با typeDefId=null ذخیره میشود تا با دادههای قدیمی یکسان باشد.
+ */
+ async resolve(
+ organizationId: string,
+ input: { typeDefId?: string | null; type?: ActivityType | null },
+ ): Promise {
+ const rows = await this.rows(organizationId);
+ const requested = input.typeDefId?.trim();
+ if (requested) {
+ const def = rows.find((r) => r.id === requested);
+ if (!def) throw new BadRequestException('نوع فعالیت انتخابشده معتبر نیست');
+ return {
+ typeDefId: def.isSystem ? null : def.id,
+ baseType: def.baseType,
+ fields: this.parseFields(def.fields),
+ };
+ }
+ const baseType = input.type && BASE_TYPES.includes(input.type) ? input.type : 'task';
+ const sys = rows.find((r) => r.isSystem && r.baseType === baseType);
+ return { typeDefId: null, baseType, fields: sys ? this.parseFields(sys.fields) : [] };
+ }
+
+ /**
+ * پاکسازی مقادیر بر اساس تعریف فیلدها؛ فقط کلیدهای همین نوع نگه داشته میشوند.
+ * participantIds = کاربران معتبر همهٔ فیلدهای شرکتکنندگان.
+ */
+ async sanitizeValues(
+ organizationId: string,
+ defs: ActivityFieldDef[],
+ raw: unknown,
+ ): Promise<{ fields: ActivityFieldValues; participantIds: string[] }> {
+ const input =
+ raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record) : {};
+ const out: ActivityFieldValues = {};
+ const participantFields: Array<{ key: string; value: ActivityParticipantsValue }> = [];
+
+ for (const def of defs) {
+ const value = this.cleanValue(def, input[def.key]);
+ if (value === null) {
+ if (def.required && def.type !== 'checkbox') {
+ throw new BadRequestException(`فیلد «${def.label}» را تکمیل کنید`);
+ }
+ continue;
+ }
+ if (def.type === 'participants') {
+ participantFields.push({ key: def.key, value: value as ActivityParticipantsValue });
+ }
+ out[def.key] = value;
+ }
+
+ const userIds = [...new Set(participantFields.flatMap((p) => p.value.userIds))];
+ const personIds = [...new Set(participantFields.flatMap((p) => p.value.people.map((x) => x.id)))];
+ const [validUsers, validPeople] = await Promise.all([
+ userIds.length
+ ? this.prisma.user.findMany({
+ where: { id: { in: userIds }, memberships: { some: { organizationId } } },
+ select: { id: true },
+ })
+ : Promise.resolve([]),
+ personIds.length
+ ? this.prisma.person.findMany({
+ where: { id: { in: personIds }, organizationId },
+ select: { id: true, firstName: true, lastName: true },
+ })
+ : Promise.resolve([]),
+ ]);
+ const userSet = new Set(validUsers.map((u) => u.id));
+ const personNames = new Map(
+ validPeople.map((p) => [p.id, `${p.firstName} ${p.lastName}`.trim()]),
+ );
+ for (const p of participantFields) {
+ const next: ActivityParticipantsValue = {
+ userIds: p.value.userIds.filter((id) => userSet.has(id)),
+ people: p.value.people
+ .filter((x) => personNames.has(x.id))
+ .map((x) => ({ id: x.id, name: personNames.get(x.id) || x.name })),
+ guests: p.value.guests,
+ };
+ const def = defs.find((d) => d.key === p.key)!;
+ if (!next.userIds.length && !next.people.length && !next.guests.length) {
+ if (def.required) throw new BadRequestException(`فیلد «${def.label}» را تکمیل کنید`);
+ delete out[p.key];
+ } else {
+ out[p.key] = next;
+ }
+ }
+
+ const participantIds = [
+ ...new Set(
+ Object.values(out)
+ .filter((v): v is ActivityParticipantsValue => isParticipants(v))
+ .flatMap((v) => v.userIds),
+ ),
+ ];
+ return { fields: out, participantIds };
+ }
+
+ async create(
+ organizationId: string,
+ dto: CreateActivityTypeRequest,
+ ): Promise {
+ await this.ensureDefaults(organizationId);
+ const max = await this.prisma.activityTypeDef.aggregate({
+ where: { organizationId },
+ _max: { position: true },
+ });
+ try {
+ const row = await this.prisma.activityTypeDef.create({
+ data: {
+ organizationId,
+ name: this.cleanName(dto.name),
+ baseType: this.cleanBase(dto.baseType),
+ icon: this.cleanIcon(dto.icon),
+ color: this.cleanColor(dto.color),
+ fields: this.cleanFieldDefs(dto.fields ?? []) as unknown as Prisma.InputJsonValue,
+ position: (max._max.position ?? -1) + 1,
+ },
+ select,
+ });
+ return this.toDto(row);
+ } catch (e) {
+ throw this.mapError(e);
+ }
+ }
+
+ async update(
+ organizationId: string,
+ id: string,
+ dto: UpdateActivityTypeRequest,
+ ): Promise {
+ const current = await this.get(organizationId, id);
+ if (current.isSystem && dto.baseType && dto.baseType !== current.baseType) {
+ throw new BadRequestException('نوع پایهٔ انواع اصلی قابل تغییر نیست');
+ }
+ if (current.isSystem && dto.isActive === false) {
+ throw new BadRequestException('انواع اصلی را نمیتوان غیرفعال کرد');
+ }
+ const nextBase = dto.baseType ? this.cleanBase(dto.baseType) : current.baseType;
+ try {
+ const row = await this.prisma.$transaction(async (tx) => {
+ if (nextBase !== current.baseType) {
+ await tx.activity.updateMany({
+ where: { organizationId, typeDefId: id },
+ data: { type: nextBase },
+ });
+ }
+ return tx.activityTypeDef.update({
+ where: { id },
+ data: {
+ ...(dto.name != null ? { name: this.cleanName(dto.name) } : {}),
+ ...(dto.icon != null ? { icon: this.cleanIcon(dto.icon) } : {}),
+ ...(dto.color != null ? { color: this.cleanColor(dto.color) } : {}),
+ ...(dto.fields != null
+ ? { fields: this.cleanFieldDefs(dto.fields) as unknown as Prisma.InputJsonValue }
+ : {}),
+ ...(dto.isActive != null ? { isActive: dto.isActive } : {}),
+ baseType: nextBase,
+ },
+ select,
+ });
+ });
+ return this.toDto(row);
+ } catch (e) {
+ throw this.mapError(e);
+ }
+ }
+
+ async reorder(organizationId: string, typeIds: string[]): Promise {
+ await this.prisma.$transaction(
+ typeIds.map((id, position) =>
+ this.prisma.activityTypeDef.updateMany({
+ where: { id, organizationId },
+ data: { position },
+ }),
+ ),
+ );
+ return this.list(organizationId);
+ }
+
+ async remove(organizationId: string, id: string): Promise {
+ const def = await this.get(organizationId, id);
+ if (def.isSystem) throw new BadRequestException('انواع اصلی قابل حذف نیستند');
+ // onDelete: SetNull → فعالیتها به نوع اصلیِ نوع پایهٔ خود برمیگردند
+ await this.prisma.activityTypeDef.delete({ where: { id } });
+ }
+
+ toSummary(row: Pick): ActivityTypeSummaryDto {
+ return {
+ id: row.id,
+ name: row.name,
+ icon: this.cleanIcon(row.icon),
+ color: this.cleanColor(row.color),
+ };
+ }
+
+ parseFields(raw: Prisma.JsonValue): ActivityFieldDef[] {
+ if (!Array.isArray(raw)) return [];
+ return raw.filter(
+ (f): f is ActivityFieldDef & Prisma.JsonObject =>
+ Boolean(f) && typeof f === 'object' && !Array.isArray(f) && typeof (f as { key?: unknown }).key === 'string',
+ ) as ActivityFieldDef[];
+ }
+
+ private async get(organizationId: string, id: string): Promise {
+ const row = await this.prisma.activityTypeDef.findFirst({
+ where: { id, organizationId },
+ select,
+ });
+ if (!row) throw new NotFoundException('نوع فعالیت پیدا نشد');
+ return row;
+ }
+
+ private cleanValue(def: ActivityFieldDef, raw: unknown): ActivityFieldValue {
+ switch (def.type) {
+ case 'text':
+ return cleanText(raw, 500);
+ case 'textarea':
+ return cleanText(raw, 5000);
+ case 'url':
+ return cleanText(raw, 1000);
+ case 'number': {
+ if (raw === '' || raw == null) return null;
+ const n = typeof raw === 'number' ? raw : Number(String(raw).replace(/,/g, ''));
+ return Number.isFinite(n) ? n : null;
+ }
+ case 'date': {
+ const s = cleanText(raw, 40);
+ return s && /^\d{4}-\d{2}-\d{2}/.test(s) && !Number.isNaN(new Date(s).getTime())
+ ? s
+ : null;
+ }
+ case 'select': {
+ const s = cleanText(raw, 200);
+ return s && (def.options ?? []).includes(s) ? s : null;
+ }
+ case 'multiselect': {
+ if (!Array.isArray(raw)) return null;
+ const opts = new Set(def.options ?? []);
+ const list = [...new Set(raw.filter((v): v is string => typeof v === 'string' && opts.has(v)))];
+ return list.length ? list : null;
+ }
+ case 'checkbox':
+ return raw === true ? true : raw === false ? false : null;
+ case 'checklist': {
+ if (!Array.isArray(raw)) return null;
+ const items: ActivityChecklistItem[] = [];
+ for (const it of raw.slice(0, MAX_CHECKLIST)) {
+ if (!it || typeof it !== 'object') continue;
+ const o = it as Record;
+ const text = cleanText(o.text, 300);
+ if (!text) continue;
+ const id = typeof o.id === 'string' && o.id.trim() ? o.id.trim().slice(0, 64) : randomUUID();
+ items.push({ id, text, done: o.done === true });
+ }
+ return items.length ? items : null;
+ }
+ case 'participants': {
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
+ const o = raw as Record;
+ const userIds = Array.isArray(o.userIds)
+ ? [...new Set(o.userIds.filter((v): v is string => typeof v === 'string' && Boolean(v.trim())))]
+ : [];
+ const people = Array.isArray(o.people)
+ ? o.people
+ .filter(
+ (p): p is { id: string; name?: unknown } =>
+ Boolean(p) && typeof p === 'object' && typeof (p as { id?: unknown }).id === 'string',
+ )
+ .map((p) => ({ id: p.id, name: cleanText(p.name, 200) ?? '' }))
+ : [];
+ const guests = Array.isArray(o.guests)
+ ? [
+ ...new Set(
+ o.guests
+ .map((g) => cleanText(g, 120))
+ .filter((g): g is string => Boolean(g)),
+ ),
+ ]
+ : [];
+ if (!userIds.length && !people.length && !guests.length) return null;
+ return {
+ userIds: userIds.slice(0, MAX_PARTICIPANTS),
+ people: people.slice(0, MAX_PARTICIPANTS),
+ guests: guests.slice(0, MAX_PARTICIPANTS),
+ };
+ }
+ default:
+ return null;
+ }
+ }
+
+ private cleanFieldDefs(raw: ActivityFieldDef[]): ActivityFieldDef[] {
+ if (!Array.isArray(raw)) throw new BadRequestException('تعریف فیلدها نامعتبر است');
+ if (raw.length > MAX_FIELDS) {
+ throw new BadRequestException(`حداکثر ${MAX_FIELDS} فیلد برای هر نوع مجاز است`);
+ }
+ const keys = new Set();
+ return raw.map((f, i) => {
+ const label = typeof f?.label === 'string' ? f.label.trim() : '';
+ if (!label) throw new BadRequestException(`عنوان فیلد ${i + 1} لازم است`);
+ if (label.length > 80) throw new BadRequestException('عنوان فیلد حداکثر ۸۰ کاراکتر است');
+ const type = ACTIVITY_FIELD_TYPES.includes(f.type as ActivityFieldType)
+ ? (f.type as ActivityFieldType)
+ : null;
+ if (!type) throw new BadRequestException(`نوع فیلد «${label}» معتبر نیست`);
+ let key = typeof f.key === 'string' ? f.key.trim().toLowerCase() : '';
+ if (!/^[a-z][a-z0-9_]{0,39}$/.test(key)) key = `f_${randomUUID().slice(0, 8)}`;
+ if (keys.has(key)) throw new BadRequestException(`کلید فیلد «${label}» تکراری است`);
+ keys.add(key);
+ const def: ActivityFieldDef = { key, label, type };
+ if (ACTIVITY_FIELD_TYPES_WITH_OPTIONS.includes(type)) {
+ const options = [
+ ...new Set(
+ (Array.isArray(f.options) ? f.options : [])
+ .map((o) => (typeof o === 'string' ? o.trim() : ''))
+ .filter(Boolean)
+ .map((o) => o.slice(0, 200)),
+ ),
+ ].slice(0, MAX_OPTIONS);
+ if (!options.length) {
+ throw new BadRequestException(`برای فیلد «${label}» حداقل یک گزینه تعریف کنید`);
+ }
+ def.options = options;
+ }
+ if (f.required) def.required = true;
+ const placeholder = typeof f.placeholder === 'string' ? f.placeholder.trim().slice(0, 200) : '';
+ if (placeholder) def.placeholder = placeholder;
+ return def;
+ });
+ }
+
+ private cleanName(raw: string): string {
+ const name = raw?.trim() ?? '';
+ if (!name) throw new BadRequestException('نام نوع فعالیت لازم است');
+ if (name.length > 60) throw new BadRequestException('نام نوع فعالیت حداکثر ۶۰ کاراکتر است');
+ return name;
+ }
+
+ private cleanBase(raw?: ActivityType): ActivityType {
+ return raw && BASE_TYPES.includes(raw) ? raw : 'task';
+ }
+
+ private cleanIcon(raw?: string): ActivityTypeIcon {
+ return (ACTIVITY_TYPE_ICONS as readonly string[]).includes(raw ?? '')
+ ? (raw as ActivityTypeIcon)
+ : 'check';
+ }
+
+ private cleanColor(raw?: string): ActivityStageColor {
+ return ACTIVITY_STAGE_COLORS.includes(raw as ActivityStageColor)
+ ? (raw as ActivityStageColor)
+ : 'green';
+ }
+
+ private mapError(e: unknown) {
+ if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
+ return new BadRequestException('نوع فعالیتی با این نام وجود دارد');
+ }
+ return e;
+ }
+
+ private toDto(row: TypeRow): ActivityTypeDefDto {
+ return {
+ ...this.toSummary(row),
+ baseType: row.baseType,
+ fields: this.parseFields(row.fields),
+ position: row.position,
+ isActive: row.isActive,
+ isSystem: row.isSystem,
+ };
+ }
+}
+
+function cleanText(raw: unknown, max: number): string | null {
+ if (typeof raw !== 'string' && typeof raw !== 'number') return null;
+ const s = String(raw).trim();
+ return s ? s.slice(0, max) : null;
+}
+
+function isParticipants(v: unknown): v is ActivityParticipantsValue {
+ return Boolean(v) && typeof v === 'object' && !Array.isArray(v) && Array.isArray((v as ActivityParticipantsValue).userIds);
+}
diff --git a/apps/api/src/activities/dto.ts b/apps/api/src/activities/dto.ts
index 7ec0e8a..6ec35bc 100644
--- a/apps/api/src/activities/dto.ts
+++ b/apps/api/src/activities/dto.ts
@@ -6,6 +6,7 @@ import {
IsDateString,
IsIn,
IsInt,
+ IsObject,
IsOptional,
IsString,
Max,
@@ -14,6 +15,8 @@ import {
MinLength,
ValidateIf,
} from 'class-validator';
+import type { ActivityFieldDef, ActivityFieldValues, ActivityTypeIcon } from '@mento/shared';
+import { ACTIVITY_TYPE_ICONS } from '@mento/shared';
/** تکههای JSON از ایمپورت مرورگرگرا */
export class ImportActivityRowsDto {
@@ -66,6 +69,11 @@ export class ListActivitiesQueryDto {
@IsString()
assigneeId?: string;
+ /** نوع فعالیت (تعریفشده در سازمان) */
+ @IsOptional()
+ @IsString()
+ typeDefId?: string;
+
/** شناسهٔ مرحله یا 'all' — حالت تب/کانبان مراحل */
@IsOptional()
@IsString()
@@ -151,6 +159,66 @@ export class MoveActivityStageDto {
stageId!: string;
}
+/** محتوای fields در سرویس بر اساس تعریف نوع اعتبارسنجی میشود */
+export class CreateActivityTypeDto {
+ @IsString()
+ @MinLength(1)
+ @MaxLength(60)
+ name!: string;
+
+ @IsOptional()
+ @IsIn(ACTIVITY_TYPES)
+ baseType?: (typeof ACTIVITY_TYPES)[number];
+
+ @IsOptional()
+ @IsIn(ACTIVITY_TYPE_ICONS)
+ icon?: ActivityTypeIcon;
+
+ @IsOptional()
+ @IsIn(STAGE_COLORS)
+ color?: (typeof STAGE_COLORS)[number];
+
+ @IsOptional()
+ @IsArray()
+ @Allow()
+ fields?: ActivityFieldDef[];
+}
+
+export class UpdateActivityTypeDto {
+ @IsOptional()
+ @IsString()
+ @MinLength(1)
+ @MaxLength(60)
+ name?: string;
+
+ @IsOptional()
+ @IsIn(ACTIVITY_TYPES)
+ baseType?: (typeof ACTIVITY_TYPES)[number];
+
+ @IsOptional()
+ @IsIn(ACTIVITY_TYPE_ICONS)
+ icon?: ActivityTypeIcon;
+
+ @IsOptional()
+ @IsIn(STAGE_COLORS)
+ color?: (typeof STAGE_COLORS)[number];
+
+ @IsOptional()
+ @IsArray()
+ @Allow()
+ fields?: ActivityFieldDef[];
+
+ @IsOptional()
+ @IsBoolean()
+ isActive?: boolean;
+}
+
+export class ReorderActivityTypesDto {
+ @IsArray()
+ @IsString({ each: true })
+ typeIds!: string[];
+}
+
export class CreateActivityDto {
@IsString()
@MinLength(2)
@@ -166,6 +234,15 @@ export class CreateActivityDto {
@IsIn(ACTIVITY_TYPES)
type?: (typeof ACTIVITY_TYPES)[number];
+ @IsOptional()
+ @ValidateIf((_, v) => v !== '' && v != null)
+ @IsString()
+ typeDefId?: string | null;
+
+ @IsOptional()
+ @IsObject()
+ fields?: ActivityFieldValues;
+
@IsOptional()
@IsIn(ACTIVITY_PRIORITIES)
priority?: (typeof ACTIVITY_PRIORITIES)[number];
@@ -242,6 +319,15 @@ export class UpdateActivityDto {
@IsIn(ACTIVITY_TYPES)
type?: (typeof ACTIVITY_TYPES)[number];
+ @IsOptional()
+ @ValidateIf((_, v) => v !== '' && v != null)
+ @IsString()
+ typeDefId?: string | null;
+
+ @IsOptional()
+ @IsObject()
+ fields?: ActivityFieldValues;
+
@IsOptional()
@IsIn(ACTIVITY_STATUSES)
status?: (typeof ACTIVITY_STATUSES)[number];
diff --git a/apps/api/src/common/record-scope.ts b/apps/api/src/common/record-scope.ts
index 39b18f7..a1bef18 100644
--- a/apps/api/src/common/record-scope.ts
+++ b/apps/api/src/common/record-scope.ts
@@ -124,6 +124,21 @@ export function assigneeWhere(
};
}
+/** دسترسی به فعالیت: مسئولان یا شرکتکنندگان (فیلد شرکتکنندگان) در محدوده دید */
+export function activityAccessWhere(
+ actor: ScopedActor,
+): Prisma.ActivityWhereInput {
+ const ids = scopedOwnerIds(actor, 'activities');
+ if (!ids) return {};
+ return {
+ OR: [
+ { assigneeId: { in: ids } },
+ { assignees: { some: { id: { in: ids } } } },
+ { participantIds: { hasSome: ids } },
+ ],
+ };
+}
+
/** یادداشت: نویسنده یا پروندههای تحت مالکیت در محدوده دید + محرمانگی شخصی */
export function noteAccessWhere(
actor: ScopedActor,
diff --git a/apps/web/src/components/activities/ActivitiesKanban.vue b/apps/web/src/components/activities/ActivitiesKanban.vue
index bb3ab37..50f27ae 100644
--- a/apps/web/src/components/activities/ActivitiesKanban.vue
+++ b/apps/web/src/components/activities/ActivitiesKanban.vue
@@ -5,10 +5,10 @@ import axios from 'axios';
import type {
ActivityDto,
ActivityStageDto,
- ActivityType,
PaginatedResponse,
} from '@mento/shared';
import { api } from '@/lib/api';
+import { activityTypeView } from '@/lib/activity-types';
import { calendarDaysBetween, formatClockFa, formatJalaliDay, startOfLocalDay, toFaDigits } from '@/lib/jalali';
import PipelineBoard from '@/components/pipeline/PipelineBoard.vue';
import DealCardOverlay from '@/components/pipeline/DealCardOverlay.vue';
@@ -59,13 +59,6 @@ const moving = ref(false);
const stageBusy = ref(false);
const localOrder = ref(null);
-const typeLabel: Record = {
- call: 'تماس',
- meeting: 'جلسه',
- task: 'وظیفه',
- note: 'پیام',
-};
-
const KIND_BADGE: Record = {
open: undefined,
done: 'انجامشده',
@@ -109,7 +102,7 @@ function toCard(item: ActivityDto, stageId: string): PipelineDeal {
return {
id: item.id,
title: item.title,
- description: [typeLabel[item.type], party].filter(Boolean).join(' · '),
+ description: [activityTypeView(item).name, party].filter(Boolean).join(' · '),
amount: null,
date: dateText(item),
initials: initialsFromTitle(party || item.title),
diff --git a/apps/web/src/components/activities/ActivityFieldsEditor.vue b/apps/web/src/components/activities/ActivityFieldsEditor.vue
new file mode 100644
index 0000000..7b5dfa2
--- /dev/null
+++ b/apps/web/src/components/activities/ActivityFieldsEditor.vue
@@ -0,0 +1,674 @@
+
+
+
+
+
+
+ {{ f.label }}
+ *
+
+
+
+
+
+
+
+
+
set(f.key, v || null)"
+ />
+
+
+ — انتخاب کنید —
+ {{ opt }}
+
+
+
+
+
+ {{ opt }}
+
+
+
+
+
+
+ {{ get(f.key, false) === true ? 'بله' : 'خیر' }}
+
+
+
+
+ {{ toFaDigits(checklist(f.key).filter((x) => x.done).length) }} از
+ {{ toFaDigits(checklist(f.key).length) }} انجام شده
+
+
+
+
+
+
+ افزودن
+
+
+
+
+
+
+ همکاران (فعالیت در لیست آنها هم نمایش داده میشود)
+ patchParticipants(f.key, { userIds: ids })"
+ />
+
+
+
اشخاص / مشتریان
+
+
+
+ {{ p.name }}
+ x.id !== p.id) })"
+ >
+ ×
+
+
+
+
+
+
+
+
+ {{ p.fullName }}
+ {{ p.company.name }}
+
+
+
+
+
+
+
مهمانان دیگر (نام آزاد)
+
+
+ {{ g }}
+ x !== g) })"
+ >
+ ×
+
+
+
+
+
+
+
+ افزودن
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/src/components/activities/ActivityFieldsView.vue b/apps/web/src/components/activities/ActivityFieldsView.vue
new file mode 100644
index 0000000..83d9f18
--- /dev/null
+++ b/apps/web/src/components/activities/ActivityFieldsView.vue
@@ -0,0 +1,263 @@
+
+
+
+
+
+
{{ f.label }}
+
+
+
+ {{ toFaDigits(checklist(f.key).filter((x) => x.done).length) }} از
+ {{ toFaDigits(checklist(f.key).length) }} انجام شده
+
+
+
+
+ {{ it.text }}
+
+
+
+
+
+
+ {{ userName.get(uid) || 'همکار' }}
+
+
+
+ {{ p.name }}
+
+ {{ g }}
+
+
+
+ {{ text(f) }}
+
+
+
{{ text(f) }}
+
+
+ اطلاعاتی برای این نوع فعالیت ثبت نشده است.
+
+
+
diff --git a/apps/web/src/components/activities/ActivityTypesManager.vue b/apps/web/src/components/activities/ActivityTypesManager.vue
new file mode 100644
index 0000000..3fb07f3
--- /dev/null
+++ b/apps/web/src/components/activities/ActivityTypesManager.vue
@@ -0,0 +1,821 @@
+
+
+
+
+
+
+ برای هر نوع فعالیت فیلدهای مخصوص خودش را تعریف کنید؛ مثلاً «موضوع جلسه» و «شرکتکنندگان» برای جلسه
+ یا «لیست کارها» برای وظیفه. «نوع پایه» مشخص میکند فعالیت در گزارشها جزو تماس، جلسه، وظیفه یا پیام
+ شمرده شود.
+
+
+
+
+
+
+
+
+
+
+ {{ t.name }}
+
+ {{ ACTIVITY_TYPE_LABELS[t.baseType] }} · {{ toFaDigits(t.fields.length) }} فیلد
+ · غیرفعال
+
+
+
+
+
+ ▲
+
+
+ ▼
+
+
+
+
+
+ نوع فعالیت جدید
+
+
+
+
+
+
+ نام نوع
+
+
+
+ نوع پایه (برای گزارشها)
+
+ {{ ACTIVITY_TYPE_LABELS[b] }}
+
+
+
+ رنگ
+
+
+ {{ ACTIVITY_STAGE_COLOR_LABELS[c] }}
+
+
+
+
+
+ فعال (در فرم فعالیت قابل انتخاب باشد)
+
+
+
+
+
+
+ این نوع اصلی است: حذف و غیرفعال نمیشود، ولی نام، ظاهر و فیلدهایش قابل تغییر است.
+
+
+
+ فیلدهای اختصاصی
+ {{ toFaDigits(draft.fields.length) }} فیلد
+
+
+ هنوز فیلدی تعریف نشده؛ فعالیتهای این نوع فقط فیلدهای عمومی (عنوان، زمان، مسئول، مشتری…) را دارند.
+
+
+
+
+
{{ toFaDigits(index + 1) }}
+
+
+
+ {{ ACTIVITY_FIELD_TYPE_LABELS[ft] }}
+
+
+
+ ▲
+
+ ▼
+
+
+
+
+
+
+
+
+
+
+
+ افزودن فیلد
+
+
+ {{ error }}
+ {{ notice }}
+
+
+
+ {{ draft.id ? 'ذخیرهٔ تغییرات' : 'ساخت نوع فعالیت' }}
+
+
+
+ حذف نوع
+
+
+
+
یک نوع فعالیت را انتخاب کنید.
+
+
+
+
+
+
diff --git a/apps/web/src/lib/activity-types.ts b/apps/web/src/lib/activity-types.ts
new file mode 100644
index 0000000..0b3fdd1
--- /dev/null
+++ b/apps/web/src/lib/activity-types.ts
@@ -0,0 +1,111 @@
+import { computed, ref } from 'vue';
+import type {
+ ActivityDto,
+ ActivityStageColor,
+ ActivityType,
+ ActivityTypeDefDto,
+ ActivityTypeIcon,
+} from '@mento/shared';
+import { ACTIVITY_TYPE_LABELS } from '@mento/shared';
+import { api } from '@/lib/api';
+import { PIPELINE_ACCENT } from '@/lib/pipeline-board';
+
+export const BASE_TYPE_ICON: Record = {
+ call: 'phone',
+ meeting: 'users',
+ task: 'check',
+ note: 'chat',
+};
+
+const BASE_TYPE_COLOR: Record = {
+ call: 'green',
+ meeting: 'purple',
+ task: 'orange',
+ note: 'blue',
+};
+
+export const ACTIVITY_TYPE_ICON_LABELS: Record = {
+ phone: 'تلفن',
+ users: 'جمع',
+ check: 'تیک',
+ chat: 'گفتگو',
+ mail: 'ایمیل',
+ building: 'ساختمان',
+ calendar: 'تقویم',
+ star: 'ستاره',
+ flag: 'پرچم',
+ briefcase: 'کیف',
+ document: 'سند',
+ target: 'هدف',
+ truck: 'ارسال',
+ send: 'ارسال پیام',
+ megaphone: 'بلندگو',
+ pin: 'مکان',
+ bell: 'زنگ',
+ sparkle: 'درخشش',
+};
+
+const types = ref([]);
+const loaded = ref(false);
+let pending: Promise | null = null;
+
+/** انواع فعالیت سازمان (کش مشترک بین صفحات) */
+export function useActivityTypes() {
+ async function load(force = false): Promise {
+ if (loaded.value && !force) return;
+ if (pending && !force) return pending;
+ pending = api
+ .get('/activities/types')
+ .then(({ data }) => {
+ types.value = data;
+ loaded.value = true;
+ })
+ .catch(() => {
+ if (!loaded.value) types.value = [];
+ })
+ .finally(() => {
+ pending = null;
+ });
+ return pending;
+ }
+
+ const active = computed(() => types.value.filter((t) => t.isActive));
+ const byId = computed(() => new Map(types.value.map((t) => [t.id, t])));
+ /** نوع اصلی هر نوع پایه */
+ const systemOf = computed(() => {
+ const out: Partial> = {};
+ for (const t of types.value) if (t.isSystem && !out[t.baseType]) out[t.baseType] = t;
+ return out;
+ });
+
+ /** تعریف کامل نوع مؤثر یک فعالیت */
+ function defOf(a: Pick): ActivityTypeDefDto | null {
+ if (a.typeDef?.id) return byId.value.get(a.typeDef.id) ?? null;
+ return systemOf.value[a.type] ?? null;
+ }
+
+ return { types, active, byId, systemOf, loaded, load, defOf };
+}
+
+export type TypeView = { name: string; icon: ActivityTypeIcon; color: ActivityStageColor };
+
+/** برچسب/آیکون/رنگ قابل نمایش برای یک فعالیت */
+export function activityTypeView(a: Pick): TypeView {
+ if (a.typeDef) {
+ return { name: a.typeDef.name, icon: a.typeDef.icon, color: a.typeDef.color };
+ }
+ return {
+ name: ACTIVITY_TYPE_LABELS[a.type],
+ icon: BASE_TYPE_ICON[a.type],
+ color: BASE_TYPE_COLOR[a.type],
+ };
+}
+
+export function typeColorStyle(color: ActivityStageColor) {
+ const c = PIPELINE_ACCENT[color] ?? PIPELINE_ACCENT.green;
+ return { background: c.soft, color: c.text };
+}
+
+export function newFieldKey(): string {
+ return `f_${Math.random().toString(36).slice(2, 10)}`;
+}
diff --git a/apps/web/src/views/ActivitiesView.vue b/apps/web/src/views/ActivitiesView.vue
index b06645a..e1c4603 100644
--- a/apps/web/src/views/ActivitiesView.vue
+++ b/apps/web/src/views/ActivitiesView.vue
@@ -23,7 +23,9 @@ import UiSegmented from '@/components/ui/UiSegmented.vue';
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
import ActivitiesKanban from '@/components/activities/ActivitiesKanban.vue';
import ActivityStagesManager from '@/components/activities/ActivityStagesManager.vue';
+import ActivityTypesManager from '@/components/activities/ActivityTypesManager.vue';
import { PIPELINE_ACCENT } from '@/lib/pipeline-board';
+import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types';
import {
ACTIVITY_EXCEL_ALIASES,
ACTIVITY_EXCEL_FIELDS,
@@ -56,10 +58,11 @@ import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
const ACT_TABLE_KEY = 'activities';
const ACT_COLUMNS = [
- { key: 'title', label: 'مشتری / عنوان فعالیت', width: 'minmax(0, 2.2fr)' },
+ { key: 'title', label: 'عنوان فعالیت', width: 'minmax(0, 2fr)' },
+ { key: 'who', label: 'کارشناس', width: 'minmax(7rem, 0.9fr)' },
+ { key: 'customer', label: 'مشتری', width: 'minmax(7rem, 1fr)' },
{ key: 'type', label: 'نوع فعالیت', width: '7.6rem' },
{ key: 'when', label: 'زمان شروع', width: '7.2rem' },
- { key: 'who', label: 'کارشناس', width: '8.4rem' },
{ key: 'status', label: 'وضعیت', width: '8.6rem' },
] as const;
type ActColKey = (typeof ACT_COLUMNS)[number]['key'];
@@ -80,7 +83,8 @@ const actGridStyle = computed(() => ({
].join(' '),
}));
-type TypeFilter = 'all' | ActivityType;
+/** 'all' یا شناسهٔ نوع فعالیت */
+type TypeFilter = string;
type StatusFilter = 'all' | ActivityStatus;
type UiStatus = 'done' | 'progress' | 'planned' | 'overdue' | 'canceled';
type DeskScope = 'mine' | 'team';
@@ -106,14 +110,6 @@ const DESK_TABS: Array<{ key: DeskScope; label: string }> = [
{ key: 'team', label: 'همه تیم' },
];
-const TYPE_FILTERS: Array<{ key: TypeFilter; label: string }> = [
- { key: 'all', label: 'همه انواع' },
- { key: 'call', label: 'تماس' },
- { key: 'meeting', label: 'جلسه' },
- { key: 'task', label: 'وظیفه' },
- { key: 'note', label: 'پیام' },
-];
-
const STATUS_FILTERS: Array<{ key: StatusFilter; label: string }> = [
{ key: 'all', label: 'همه وضعیتها' },
{ key: 'open', label: 'باز / انجامنشده' },
@@ -155,6 +151,13 @@ const filter = computed(() => {
const stages = ref([]);
const stagesManagerOpen = ref(false);
+const typesManagerOpen = ref(false);
+
+async function onTypesChanged() {
+ await activityTypes.load(true);
+ kanbanReload.value += 1;
+ await refreshAll();
+}
const kanbanReload = ref(0);
const stageCounts = ref({ counts: {}, total: 0 });
@@ -185,9 +188,25 @@ const ideasOpen = ref(false);
const ACTIVITY_TYPES: ActivityType[] = ['call', 'meeting', 'task', 'note'];
const ACTIVITY_STATUSES: ActivityStatus[] = ['open', 'done', 'canceled'];
+const activityTypes = useActivityTypes();
+const TYPE_FILTERS = computed>(() => [
+ { key: 'all', label: 'همه انواع' },
+ ...activityTypes.types.value
+ .filter((t) => t.isActive || t.id === typeFilter.value)
+ .map((t) => ({ key: t.id, label: t.name })),
+]);
+
+/** پارامتر قدیمی ?type=call → نوع اصلیِ همان نوع پایه */
+const legacyBaseType = computed(() => {
+ const value = typeof route.query.type === 'string' ? route.query.type : '';
+ return ACTIVITY_TYPES.includes(value as ActivityType) ? (value as ActivityType) : null;
+});
+
const typeFilter = computed(() => {
- const value = typeof route.query.type === 'string' ? route.query.type : 'all';
- return ACTIVITY_TYPES.includes(value as ActivityType) ? (value as ActivityType) : 'all';
+ const id = typeof route.query.typeDefId === 'string' ? route.query.typeDefId.trim() : '';
+ if (id) return id;
+ if (legacyBaseType.value) return activityTypes.systemOf.value[legacyBaseType.value]?.id ?? 'all';
+ return 'all';
});
const statusFilter = computed(() => {
@@ -217,20 +236,6 @@ const counts = ref>({
all: 0,
});
-const typeLabel: Record = {
- call: 'تماس',
- meeting: 'جلسه',
- task: 'وظیفه',
- note: 'پیام',
-};
-
-const typeIcon: Record = {
- call: 'phone',
- meeting: 'users',
- task: 'check',
- note: 'chat',
-};
-
const statusLabel: Record = {
done: 'انجام شد',
progress: 'در حال انجام',
@@ -263,7 +268,13 @@ const sharedParams = computed>(() => ({
filter: filter.value,
q: typeof route.query.q === 'string' && route.query.q.trim() ? route.query.q.trim() : undefined,
desk: showDeskTabs.value ? desk.value : undefined,
- type: typeFilter.value === 'all' ? undefined : typeFilter.value,
+ typeDefId: typeof route.query.typeDefId === 'string' && route.query.typeDefId.trim()
+ ? route.query.typeDefId.trim()
+ : undefined,
+ type:
+ !(typeof route.query.typeDefId === 'string' && route.query.typeDefId.trim()) && legacyBaseType.value
+ ? legacyBaseType.value
+ : undefined,
status: statusFilter.value === 'all' ? undefined : statusFilter.value,
assigneeId: assigneeFilter.value || undefined,
}));
@@ -524,7 +535,7 @@ function patchFacetQuery(patch: Record) {
}
function setTypeFilter(next: TypeFilter) {
- patchFacetQuery({ type: next === 'all' ? undefined : next });
+ patchFacetQuery({ typeDefId: next === 'all' ? undefined : next, type: undefined });
}
function setStatusFilter(next: StatusFilter) {
@@ -620,6 +631,7 @@ watch(
route.query.page,
route.query.desk,
route.query.type,
+ route.query.typeDefId,
route.query.status,
route.query.assigneeId,
route.query.stage,
@@ -657,6 +669,7 @@ onMounted(() => {
syncMobile();
mobileQuery.addEventListener('change', syncMobile);
void loadUsers();
+ void activityTypes.load(true);
void loadStages().then(() => {
if (migrateLegacyDoneFilter()) return;
void load();
@@ -745,6 +758,16 @@ onUnmounted(() => {
مدیریت ستونها
+
+
+ انواع فعالیت و فیلدها
+
@@ -800,7 +823,7 @@ onUnmounted(() => {
@@ -962,27 +985,41 @@ onUnmounted(() => {
{{ subtitle(item) }}
-
-
-
- {{ typeLabel[item.type] }}
-
-
-
- {{ clockText(item) }}
- {{ dayText(item) }}
-
{{
item.assignees?.length
? item.assignees.map((a) => a.fullName).join('، ')
: item.assignee?.fullName || '—'
}}
-
-
- {{ item.company?.name || item.person?.fullName }}
+
+
+
+
+ {{ item.company.name }}
+
+
+
+ {{ item.person.fullName }}
+
+ —
+
+
+
+
+ {{ activityTypeView(item).name }}
+
+ {{ clockText(item) }}
+ {{ dayText(item) }}
+
@@ -1068,10 +1105,12 @@ onUnmounted(() => {
انجام شد
-
-
+
+
+
+
+ {{ activityTypeView(item).name }}
- {{ typeLabel[item.type] }}
@@ -1566,7 +1611,7 @@ h1 {
.thead,
.trow {
display: grid;
- grid-template-columns: 2.4rem minmax(0, 2.2fr) 7.6rem 7.2rem 8.4rem 8.6rem 2.4rem;
+ grid-template-columns: 2.4rem minmax(0, 2fr) minmax(7rem, 0.9fr) minmax(7rem, 1fr) 7.6rem 7.2rem 8.6rem 2.4rem;
gap: 0.8rem;
align-items: center;
padding: 0.95rem 1.1rem;
@@ -1682,6 +1727,40 @@ time.note {
gap: 0.25rem;
}
+.customer {
+ display: grid;
+ gap: 0.15rem;
+ min-width: 0;
+}
+
+.customer a {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3rem;
+ min-width: 0;
+ color: var(--ink);
+ font-size: 0.86rem;
+ font-weight: 600;
+ text-decoration: none;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.customer a:hover {
+ color: #0f766e;
+}
+
+.customer a.secondary {
+ color: #94a3b8;
+ font-size: 0.75rem;
+ font-weight: 500;
+}
+
+.customer .none {
+ color: #cbd5e1;
+}
+
.when.late {
color: #dc2626;
}
diff --git a/apps/web/src/views/ActivityDetailView.vue b/apps/web/src/views/ActivityDetailView.vue
index 77a0cbc..63a80db 100644
--- a/apps/web/src/views/ActivityDetailView.vue
+++ b/apps/web/src/views/ActivityDetailView.vue
@@ -12,9 +12,16 @@ import UserAvatar from '@/components/UserAvatar.vue';
import UserMultiSelect from '@/components/UserMultiSelect.vue';
import ActivityCommentSheet from '@/components/activities/ActivityCommentSheet.vue';
import ActivityPostponeSheet from '@/components/activities/ActivityPostponeSheet.vue';
-import type { ActivityDto, ActivityPriority, ActivityType, OrgUserDto } from '@mento/shared';
+import ActivityFieldsView from '@/components/activities/ActivityFieldsView.vue';
+import type {
+ ActivityChecklistItem,
+ ActivityDto,
+ ActivityPriority,
+ OrgUserDto,
+} from '@mento/shared';
import { useAuthStore } from '@/stores/auth';
import { canReassignRecords } from '@/lib/roles';
+import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types';
const route = useRoute();
const router = useRouter();
@@ -44,19 +51,33 @@ const assigneeNames = computed(() => {
return list.length ? list.join('، ') : '—';
});
-const typeLabel: Record = {
- call: 'تماس',
- meeting: 'جلسه',
- task: 'وظیفه',
- note: 'پیام',
-};
+const activityTypes = useActivityTypes();
+const typeView = computed(() => (activity.value ? activityTypeView(activity.value) : null));
+const typeDef = computed(() => (activity.value ? activityTypes.defOf(activity.value) : null));
+const fieldsBusy = ref(false);
-const typeIcon: Record = {
- call: 'phone',
- meeting: 'users',
- task: 'check',
- note: 'chat',
-};
+/** تیک چکلیست بدون باز کردن فرم ویرایش */
+async function toggleChecklistItem(key: string, itemId: string, done: boolean) {
+ const a = activity.value;
+ if (!a || fieldsBusy.value) return;
+ const list = (a.fields?.[key] as ActivityChecklistItem[] | undefined) ?? [];
+ const nextFields = {
+ ...(a.fields ?? {}),
+ [key]: list.map((it) => (it.id === itemId ? { ...it, done } : it)),
+ };
+ activity.value = { ...a, fields: nextFields };
+ fieldsBusy.value = true;
+ error.value = '';
+ try {
+ const { data } = await api.patch(`/activities/${a.id}`, { fields: nextFields });
+ activity.value = { ...data, participantUsers: a.participantUsers };
+ } catch {
+ activity.value = a;
+ error.value = 'ثبت تغییر چکلیست ناموفق بود.';
+ } finally {
+ fieldsBusy.value = false;
+ }
+}
const priorityLabel: Record = {
very_low: 'خیلی کم',
@@ -254,6 +275,7 @@ watch(id, () => {
onMounted(() => {
void load();
void loadUsers();
+ void activityTypes.load();
});
@@ -295,18 +317,18 @@ onMounted(() => {
{{ subtitle }}
-
-
+
+
-
+
نوع فعالیت
- {{ typeLabel[activity.type] }}
+ {{ typeView!.name }}
@@ -369,6 +391,23 @@ onMounted(() => {
+
+
+
+
+
+ اطلاعات {{ typeView!.name }}
+
+
+
+
@@ -573,24 +612,18 @@ h1 {
flex-shrink: 0;
}
-.type-ico.call {
- background: #ecfdf5;
- color: #0f766e;
+.type-ico.sm {
+ width: 28px;
+ height: 28px;
}
-.type-ico.meeting {
- background: #f5f3ff;
- color: #6d28d9;
-}
-
-.type-ico.task {
- background: #fff7ed;
- color: #c2410c;
-}
-
-.type-ico.note {
- background: #eff6ff;
- color: #2563eb;
+.type-card h3 {
+ margin: 0 0 0.4rem;
+ display: flex;
+ align-items: center;
+ gap: 0.45rem;
+ font-size: 0.95rem;
+ color: #0f172a;
}
dl {
diff --git a/apps/web/src/views/ActivityFormView.vue b/apps/web/src/views/ActivityFormView.vue
index c11b6b2..ded9ea6 100644
--- a/apps/web/src/views/ActivityFormView.vue
+++ b/apps/web/src/views/ActivityFormView.vue
@@ -27,10 +27,15 @@ import {
UiPageHeader,
UiTextarea,
} from '@/components/ui';
+import ActivityFieldsEditor from '@/components/activities/ActivityFieldsEditor.vue';
+import { typeColorStyle, useActivityTypes } from '@/lib/activity-types';
import type {
ActivityDto,
+ ActivityFieldValue,
+ ActivityFieldValues,
ActivityPriority,
ActivityType,
+ ActivityTypeDefDto,
CreateActivityRequest,
DealDto,
OrgUserDto,
@@ -71,8 +76,13 @@ const people = ref([]);
const deals = ref([]);
const users = ref([]);
+const activityTypes = useActivityTypes();
const title = ref('');
const type = ref('');
+const typeDefId = ref('');
+const fieldValues = ref({});
+/** نوع غیرفعالی که فعالیت فعلی دارد — در لیست انتخاب نگه داشته میشود */
+const keepTypeId = ref('');
const priority = ref('medium');
const description = ref('');
const tagsRaw = ref('');
@@ -185,16 +195,24 @@ watch(personQuery, (raw) => {
}, 300);
});
-const typeOptions: Array<{
- value: ActivityType;
- label: string;
- icon: 'phone' | 'users' | 'check' | 'chat';
-}> = [
- { value: 'call', label: 'تماس', icon: 'phone' },
- { value: 'meeting', label: 'جلسه', icon: 'users' },
- { value: 'task', label: 'وظیفه', icon: 'check' },
- { value: 'note', label: 'یادداشت', icon: 'chat' },
-];
+const typeOptions = computed(() =>
+ activityTypes.types.value.filter((t) => t.isActive || t.id === keepTypeId.value),
+);
+
+function pickType(def: ActivityTypeDefDto) {
+ typeDefId.value = def.id;
+ type.value = def.baseType;
+ typeOpen.value = false;
+}
+
+function isEmptyValue(v: ActivityFieldValue | undefined): boolean {
+ if (v == null || v === '') return true;
+ if (Array.isArray(v)) return v.length === 0;
+ if (typeof v === 'object') {
+ return !v.userIds?.length && !v.people?.length && !v.guests?.length;
+ }
+ return false;
+}
const priorityOptions: Array<{ value: ActivityPriority; label: string; tone: string }> = [
{ value: 'very_high', label: 'خیلی زیاد', tone: 'very-high' },
@@ -204,7 +222,8 @@ const priorityOptions: Array<{ value: ActivityPriority; label: string; tone: str
{ value: 'very_low', label: 'خیلی کم', tone: 'very-low' },
];
-const typeMeta = computed(() => typeOptions.find((o) => o.value === type.value));
+const typeMeta = computed(() => activityTypes.byId.value.get(typeDefId.value) ?? null);
+const typeFields = computed(() => typeMeta.value?.fields ?? []);
const selectedPerson = computed(() => people.value.find((p) => p.id === personId.value) ?? null);
const selectedAssignees = computed(() =>
assigneeIds.value
@@ -261,6 +280,9 @@ function defaultDue() {
function resetForm() {
title.value = '';
type.value = '';
+ typeDefId.value = '';
+ fieldValues.value = {};
+ keepTypeId.value = '';
priority.value = 'medium';
description.value = '';
tagsRaw.value = '';
@@ -300,6 +322,7 @@ async function load() {
api.get>('/people', { params: { limit: 100 } }),
api.get('/pipelines/board'),
api.get('/users').catch(() => ({ data: [] as OrgUserDto[] })),
+ activityTypes.load(true),
]);
people.value = peopleRes.data.items;
deals.value = boardRes.data.stages.flatMap((s) => s.deals);
@@ -309,6 +332,10 @@ async function load() {
const { data } = await api.get(`/activities/${id.value}`);
title.value = data.title;
type.value = data.type;
+ typeDefId.value =
+ data.typeDef?.id ?? activityTypes.systemOf.value[data.type]?.id ?? '';
+ keepTypeId.value = typeDefId.value;
+ fieldValues.value = { ...(data.fields ?? {}) };
priority.value = data.priority ?? 'medium';
description.value = data.description ?? '';
needsNote.value = Boolean(data.description?.trim());
@@ -342,13 +369,17 @@ async function load() {
await ensurePersonLoaded(incomingPerson);
personId.value = incomingPerson;
}
+ const incomingDef =
+ typeof route.query.typeDefId === 'string'
+ ? activityTypes.byId.value.get(route.query.typeDefId)
+ : undefined;
const incomingType = props.initialType || route.query.type;
- if (
- typeof incomingType === 'string' &&
- typeOptions.some((o) => o.value === incomingType)
- ) {
- type.value = incomingType as ActivityType;
- }
+ const baseDef =
+ typeof incomingType === 'string'
+ ? activityTypes.systemOf.value[incomingType as ActivityType]
+ : undefined;
+ const def = incomingDef?.isActive ? incomingDef : baseDef;
+ if (def) pickType(def);
const incomingTitle =
props.initialTitle ||
(typeof route.query.title === 'string' ? route.query.title : '');
@@ -379,9 +410,14 @@ function payload(): CreateActivityRequest {
const due =
dueDate.value && dueTime.value ? new Date(`${dueDate.value}T${dueTime.value}:00`) : null;
const reminder = parseLocalInput(reminderAt.value);
+ const keys = new Set(typeFields.value.map((f) => f.key));
return {
title: title.value,
type: type.value || 'task',
+ typeDefId: typeDefId.value || null,
+ fields: Object.fromEntries(
+ Object.entries(fieldValues.value).filter(([k, v]) => keys.has(k) && !isEmptyValue(v)),
+ ),
priority: priority.value,
description: description.value || undefined,
tags: parseTags(tagsRaw.value),
@@ -411,6 +447,10 @@ function toggleAssignee(userId: string) {
function validate() {
if (!type.value) return 'نوع فعالیت را انتخاب کنید.';
if (title.value.trim().length < 2) return 'عنوان فعالیت را بنویسید.';
+ const missing = typeFields.value.find(
+ (f) => f.required && f.type !== 'checkbox' && isEmptyValue(fieldValues.value[f.key]),
+ );
+ if (missing) return `فیلد «${missing.label}» را تکمیل کنید.`;
if (!dueDate.value || !dueTime.value) return 'تاریخ و ساعت را مشخص کنید.';
if (!assigneeIds.value.length) return 'حداقل یک مسئول فعالیت را انتخاب کنید.';
if (needsNote.value && !description.value.trim()) return 'توضیح این فعالیت را بنویسید.';
@@ -553,21 +593,28 @@ watch([id, isNew], () => void load(), { immediate: true });
نوع فعالیت *
-
+
- {{ typeMeta?.label || 'انتخاب کنید' }}
+ {{ typeMeta?.name || 'انتخاب کنید' }}
@@ -582,6 +629,16 @@ watch([id, isNew], () => void load(), { immediate: true });
+
+
+
+
+
+ اطلاعات {{ typeMeta!.name }}
+
+
+
+
این فعالیت انجام شده
@@ -844,7 +901,7 @@ watch([id, isNew], () => void load(), { immediate: true });
نوع فعالیت
- {{ typeMeta?.label || '—' }}
+ {{ typeMeta?.name || '—' }}
تاریخ و ساعت
@@ -1160,19 +1217,40 @@ watch([id, isNew], () => void load(), { immediate: true });
flex-shrink: 0;
}
-.type-ico.meeting {
- background: #f5f3ff;
- color: #7c3aed;
+.type-ico.sm {
+ width: 26px;
+ height: 26px;
}
-.type-ico.task {
- background: #fff7ed;
- color: #c2410c;
+.menu button.on {
+ background: #f0fdfa;
}
-.type-ico.note {
- background: #eff6ff;
- color: #2563eb;
+.type-fields-count {
+ margin-inline-start: auto;
+ color: #94a3b8;
+ font-size: 0.72rem;
+}
+
+.menu-empty {
+ padding: 0.6rem;
+ color: #94a3b8;
+ font-size: 0.82rem;
+}
+
+.type-fields {
+ display: grid;
+ gap: 0.85rem;
+}
+
+.type-fields-title {
+ margin: 0;
+ display: flex;
+ align-items: center;
+ gap: 0.45rem;
+ font-weight: 700;
+ font-size: 0.92rem;
+ color: #0f172a;
}
.who {
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 28848fe..f1c4304 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -1199,6 +1199,152 @@ export interface ActivityStageCountsDto {
total: number;
}
+/** برچسب نوع پایه (برای گزارشها و فعالیتهای بدون نوع سفارشی) */
+export const ACTIVITY_TYPE_LABELS: Record
= {
+ call: "تماس",
+ meeting: "جلسه",
+ task: "وظیفه",
+ note: "پیام",
+};
+
+export type ActivityFieldType =
+ | "text"
+ | "textarea"
+ | "number"
+ | "date"
+ | "url"
+ | "select"
+ | "multiselect"
+ | "checkbox"
+ | "checklist"
+ | "participants";
+
+export const ACTIVITY_FIELD_TYPES: ActivityFieldType[] = [
+ "text",
+ "textarea",
+ "number",
+ "date",
+ "url",
+ "select",
+ "multiselect",
+ "checkbox",
+ "checklist",
+ "participants",
+];
+
+export const ACTIVITY_FIELD_TYPE_LABELS: Record = {
+ text: "متن کوتاه",
+ textarea: "متن بلند",
+ number: "عدد",
+ date: "تاریخ",
+ url: "لینک",
+ select: "انتخاب از لیست",
+ multiselect: "چندانتخابی",
+ checkbox: "بله / خیر",
+ checklist: "چکلیست (لیست کارها)",
+ participants: "شرکتکنندگان",
+};
+
+/** فیلدهایی که گزینه لازم دارند */
+export const ACTIVITY_FIELD_TYPES_WITH_OPTIONS: ActivityFieldType[] = ["select", "multiselect"];
+
+export interface ActivityFieldDef {
+ /** کلید پایدار (a-z، 0-9، _) — با تغییر برچسب عوض نمیشود */
+ key: string;
+ label: string;
+ type: ActivityFieldType;
+ options?: string[];
+ required?: boolean;
+ placeholder?: string | null;
+}
+
+export interface ActivityChecklistItem {
+ id: string;
+ text: string;
+ done: boolean;
+}
+
+export interface ActivityParticipantsValue {
+ /** کاربران سازمان — فعالیت در لیست آنها هم دیده میشود */
+ userIds: string[];
+ /** اشخاص (مشتریان) */
+ people: Array<{ id: string; name: string }>;
+ /** مهمانان آزاد (نام) */
+ guests: string[];
+}
+
+export type ActivityFieldValue =
+ | string
+ | number
+ | boolean
+ | string[]
+ | ActivityChecklistItem[]
+ | ActivityParticipantsValue
+ | null;
+
+export type ActivityFieldValues = Record;
+
+export const ACTIVITY_TYPE_ICONS = [
+ "phone",
+ "users",
+ "check",
+ "chat",
+ "mail",
+ "building",
+ "calendar",
+ "star",
+ "flag",
+ "briefcase",
+ "document",
+ "target",
+ "truck",
+ "send",
+ "megaphone",
+ "pin",
+ "bell",
+ "sparkle",
+] as const;
+
+export type ActivityTypeIcon = (typeof ACTIVITY_TYPE_ICONS)[number];
+
+export interface ActivityTypeSummaryDto {
+ id: string;
+ name: string;
+ icon: ActivityTypeIcon;
+ color: ActivityStageColor;
+}
+
+export interface ActivityTypeDefDto extends ActivityTypeSummaryDto {
+ /** نوع پایه — مبنای گزارشها و سازگاری */
+ baseType: ActivityType;
+ fields: ActivityFieldDef[];
+ position: number;
+ isActive: boolean;
+ /** چهار نوع اصلی — حذفشدنی نیستند */
+ isSystem: boolean;
+}
+
+export interface CreateActivityTypeRequest {
+ name: string;
+ baseType?: ActivityType;
+ icon?: ActivityTypeIcon;
+ color?: ActivityStageColor;
+ fields?: ActivityFieldDef[];
+}
+
+export interface UpdateActivityTypeRequest {
+ name?: string;
+ baseType?: ActivityType;
+ icon?: ActivityTypeIcon;
+ color?: ActivityStageColor;
+ fields?: ActivityFieldDef[];
+ isActive?: boolean;
+}
+
+export interface ReorderActivityTypesRequest {
+ typeIds: string[];
+}
+
export type CustomFieldEntity = "person" | "company" | "deal";
export type CustomFieldType = "text" | "number" | "date" | "boolean" | "select";
@@ -1267,6 +1413,14 @@ export interface ActivityDto {
createdBy: UserSummaryDto | null;
/** مرحلهٔ مؤثر (ستون کانبان) */
stageId?: string | null;
+ /** نوع مؤثر (سفارشی یا نوع اصلیِ همان type) */
+ typeDef?: ActivityTypeSummaryDto | null;
+ /** مقادیر فیلدهای اختصاصی نوع */
+ fields?: ActivityFieldValues;
+ /** کاربران شرکتکننده (از فیلدهای شرکتکنندگان) */
+ participantIds?: string[];
+ /** نام کاربران شرکتکننده — فقط در جزئیات فعالیت */
+ participantUsers?: UserSummaryDto[];
createdAt: string;
updatedAt: string;
}
@@ -1276,6 +1430,9 @@ export interface CreateActivityRequest {
/** مرحلهٔ اولیه؛ وضعیت فعالیت از نوع مرحله گرفته میشود */
stageId?: string | null;
type?: ActivityType;
+ /** نوع سفارشی؛ type از نوع پایهٔ آن گرفته میشود */
+ typeDefId?: string | null;
+ fields?: ActivityFieldValues;
priority?: ActivityPriority;
description?: string;
dueAt?: string | null;
@@ -1296,6 +1453,8 @@ export interface CreateActivityRequest {
export interface UpdateActivityRequest {
title?: string;
type?: ActivityType;
+ typeDefId?: string | null;
+ fields?: ActivityFieldValues;
status?: ActivityStatus;
priority?: ActivityPriority;
description?: string | null;