feat(activities): split customer column, wider search, custom activity types with type-specific fields

- Table: separate assignee and customer columns; search by assignee and activity type
- ActivityTypeDef model with per-type field definitions (text, select, checklist, participants, ...)
- Default types incl. meeting subject/participants and task checklist; admins manage types
- Participants see activities in their lists and get notified

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-07 12:29:23 +03:30
co-authored by Cursor
parent aa2f39256a
commit 13668c4ba4
17 changed files with 3371 additions and 147 deletions
@@ -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;
+33
View File
@@ -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])
@@ -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,
},
+8 -2
View File
@@ -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 {}
+165 -23
View File
@@ -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<Actor, 'userId' | 'role'>;
@@ -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<DtoContext> {
const [stages, types] = await Promise.all([
this.stages.defaults(organizationId),
this.types.system(organizationId),
]);
return { stages, types };
}
async summary(
organizationId: string,
actor: ActivityActor,
): Promise<ActivitySummaryDto> {
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<PaginatedResponse<ActivityDto>> {
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<ActivityDto> {
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<ActivityDto['type'], string[]> = {
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();
@@ -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<ActivityType, ActivityTypeSummaryDto | null>;
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<void> {
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<TypeRow[]> {
await this.ensureDefaults(organizationId);
return this.prisma.activityTypeDef.findMany({
where: { organizationId },
orderBy: [{ position: 'asc' }, { createdAt: 'asc' }],
select,
});
}
async list(organizationId: string): Promise<ActivityTypeDefDto[]> {
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<SystemTypes> {
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<ResolvedType> {
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<string, unknown>) : {};
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<ActivityTypeDefDto> {
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<ActivityTypeDefDto> {
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<ActivityTypeDefDto[]> {
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<void> {
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<TypeRow, 'id' | 'name' | 'icon' | 'color'>): 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<TypeRow> {
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<string, unknown>;
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<string, unknown>;
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<string>();
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);
}
+86
View File
@@ -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];
+15
View File
@@ -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,
@@ -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<string[] | null>(null);
const typeLabel: Record<ActivityType, string> = {
call: 'تماس',
meeting: 'جلسه',
task: 'وظیفه',
note: 'پیام',
};
const KIND_BADGE: Record<ActivityStageDto['kind'], string | undefined> = {
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),
@@ -0,0 +1,674 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import type {
ActivityChecklistItem,
ActivityFieldDef,
ActivityFieldValue,
ActivityFieldValues,
ActivityParticipantsValue,
OrgUserDto,
PaginatedResponse,
PersonDto,
} from '@mento/shared';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import UserMultiSelect from '@/components/UserMultiSelect.vue';
import UiIcon from '@/components/UiIcon.vue';
const props = defineProps<{
fields: ActivityFieldDef[];
users: OrgUserDto[];
}>();
const model = defineModel<ActivityFieldValues>({ required: true });
function get<T extends ActivityFieldValue>(key: string, fallback: T): T {
const v = model.value[key];
return (v ?? fallback) as T;
}
function set(key: string, value: ActivityFieldValue) {
model.value = { ...model.value, [key]: value };
}
function str(key: string): string {
const v = model.value[key];
return typeof v === 'string' || typeof v === 'number' ? String(v) : '';
}
function multi(key: string): string[] {
const v = model.value[key];
return Array.isArray(v) && v.every((x) => typeof x === 'string') ? (v as string[]) : [];
}
function toggleMulti(key: string, opt: string) {
const list = multi(key);
set(key, list.includes(opt) ? list.filter((x) => x !== opt) : [...list, opt]);
}
/* ---------- چک‌لیست ---------- */
const checklistDraft = ref<Record<string, string>>({});
function checklist(key: string): ActivityChecklistItem[] {
const v = model.value[key];
return Array.isArray(v) ? (v as ActivityChecklistItem[]).filter((x) => x && typeof x === 'object') : [];
}
function addChecklistItem(key: string) {
const text = (checklistDraft.value[key] ?? '').trim();
if (!text) return;
const id = `c_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
set(key, [...checklist(key), { id, text, done: false }]);
checklistDraft.value = { ...checklistDraft.value, [key]: '' };
}
function updateChecklistItem(key: string, id: string, patch: Partial<ActivityChecklistItem>) {
set(
key,
checklist(key).map((it) => (it.id === id ? { ...it, ...patch } : it)),
);
}
function removeChecklistItem(key: string, id: string) {
set(
key,
checklist(key).filter((it) => it.id !== id),
);
}
function moveChecklistItem(key: string, index: number, dir: -1 | 1) {
const list = [...checklist(key)];
const to = index + dir;
if (to < 0 || to >= list.length) return;
const [it] = list.splice(index, 1);
list.splice(to, 0, it!);
set(key, list);
}
/* ---------- شرکت‌کنندگان ---------- */
function participants(key: string): ActivityParticipantsValue {
const v = model.value[key] as ActivityParticipantsValue | null | undefined;
return {
userIds: v?.userIds ?? [],
people: v?.people ?? [],
guests: v?.guests ?? [],
};
}
function patchParticipants(key: string, patch: Partial<ActivityParticipantsValue>) {
set(key, { ...participants(key), ...patch });
}
const guestDraft = ref<Record<string, string>>({});
function addGuest(key: string) {
const name = (guestDraft.value[key] ?? '').trim();
if (!name) return;
const p = participants(key);
if (!p.guests.includes(name)) patchParticipants(key, { guests: [...p.guests, name] });
guestDraft.value = { ...guestDraft.value, [key]: '' };
}
const personQuery = ref<Record<string, string>>({});
const personResults = ref<Record<string, PersonDto[]>>({});
const personOpenKey = ref('');
let personTimer: ReturnType<typeof setTimeout> | undefined;
let personSeq = 0;
function searchPeople(key: string) {
personOpenKey.value = key;
if (personTimer) clearTimeout(personTimer);
const q = (personQuery.value[key] ?? '').trim();
if (!q) {
personResults.value = { ...personResults.value, [key]: [] };
return;
}
const seq = ++personSeq;
personTimer = setTimeout(async () => {
try {
const { data } = await api.get<PaginatedResponse<PersonDto>>('/people', {
params: { q, limit: 8 },
});
if (seq === personSeq) personResults.value = { ...personResults.value, [key]: data.items };
} catch {
if (seq === personSeq) personResults.value = { ...personResults.value, [key]: [] };
}
}, 280);
}
function addPerson(key: string, p: PersonDto) {
const cur = participants(key);
if (!cur.people.some((x) => x.id === p.id)) {
patchParticipants(key, { people: [...cur.people, { id: p.id, name: p.fullName }] });
}
personQuery.value = { ...personQuery.value, [key]: '' };
personResults.value = { ...personResults.value, [key]: [] };
personOpenKey.value = '';
}
watch(
() => props.fields.map((f) => f.key).join('|'),
() => {
checklistDraft.value = {};
guestDraft.value = {};
personQuery.value = {};
personResults.value = {};
},
);
</script>
<template>
<div class="fields-grid">
<div
v-for="f in fields"
:key="f.key"
class="cf"
:class="{ wide: ['textarea', 'checklist', 'participants', 'multiselect'].includes(f.type) }"
>
<label :for="`cf-${f.key}`">
{{ f.label }}
<i v-if="f.required">*</i>
</label>
<input
v-if="f.type === 'text' || f.type === 'url'"
:id="`cf-${f.key}`"
:type="f.type === 'url' ? 'url' : 'text'"
:dir="f.type === 'url' ? 'ltr' : undefined"
:value="str(f.key)"
:placeholder="f.placeholder || ''"
maxlength="1000"
@input="set(f.key, ($event.target as HTMLInputElement).value)"
/>
<textarea
v-else-if="f.type === 'textarea'"
:id="`cf-${f.key}`"
:value="str(f.key)"
:placeholder="f.placeholder || ''"
rows="3"
@input="set(f.key, ($event.target as HTMLTextAreaElement).value)"
/>
<input
v-else-if="f.type === 'number'"
:id="`cf-${f.key}`"
type="number"
inputmode="decimal"
dir="ltr"
:value="str(f.key)"
:placeholder="f.placeholder || ''"
@input="
set(
f.key,
($event.target as HTMLInputElement).value === ''
? null
: Number(($event.target as HTMLInputElement).value),
)
"
/>
<JalaliDateInput
v-else-if="f.type === 'date'"
:id="`cf-${f.key}`"
:model-value="str(f.key)"
@update:model-value="(v: string) => set(f.key, v || null)"
/>
<select
v-else-if="f.type === 'select'"
:id="`cf-${f.key}`"
:value="str(f.key)"
@change="set(f.key, ($event.target as HTMLSelectElement).value || null)"
>
<option value="">— انتخاب کنید —</option>
<option v-for="opt in f.options ?? []" :key="opt" :value="opt">{{ opt }}</option>
</select>
<div v-else-if="f.type === 'multiselect'" class="chips">
<button
v-for="opt in f.options ?? []"
:key="opt"
type="button"
class="chip-opt"
:class="{ on: multi(f.key).includes(opt) }"
@click="toggleMulti(f.key, opt)"
>
<UiIcon v-if="multi(f.key).includes(opt)" name="check-plain" :size="12" />
{{ opt }}
</button>
</div>
<label v-else-if="f.type === 'checkbox'" class="switch">
<input
:id="`cf-${f.key}`"
type="checkbox"
:checked="get<boolean>(f.key, false) === true"
@change="set(f.key, ($event.target as HTMLInputElement).checked)"
/>
<i />
<span>{{ get<boolean>(f.key, false) === true ? 'بله' : 'خیر' }}</span>
</label>
<div v-else-if="f.type === 'checklist'" class="checklist">
<p v-if="checklist(f.key).length" class="progress">
{{ toFaDigits(checklist(f.key).filter((x) => x.done).length) }} از
{{ toFaDigits(checklist(f.key).length) }} انجام شده
</p>
<ul>
<li v-for="(it, idx) in checklist(f.key)" :key="it.id" :class="{ done: it.done }">
<input
type="checkbox"
:checked="it.done"
aria-label="انجام شد"
@change="updateChecklistItem(f.key, it.id, { done: ($event.target as HTMLInputElement).checked })"
/>
<input
class="item-text"
:value="it.text"
maxlength="300"
@input="updateChecklistItem(f.key, it.id, { text: ($event.target as HTMLInputElement).value })"
/>
<button type="button" class="mini" :disabled="idx === 0" aria-label="بالا" @click="moveChecklistItem(f.key, idx, -1)">▲</button>
<button
type="button"
class="mini"
:disabled="idx === checklist(f.key).length - 1"
aria-label="پایین"
@click="moveChecklistItem(f.key, idx, 1)"
>
▼
</button>
<button type="button" class="mini danger" aria-label="حذف" @click="removeChecklistItem(f.key, it.id)">
<UiIcon name="x" :size="13" />
</button>
</li>
</ul>
<div class="add-row">
<input
v-model="checklistDraft[f.key]"
maxlength="300"
:placeholder="f.placeholder || 'مورد جدید را بنویسید و Enter بزنید'"
@keydown.enter.prevent="addChecklistItem(f.key)"
/>
<button type="button" class="add-btn" @click="addChecklistItem(f.key)">
<UiIcon name="plus" :size="14" />
افزودن
</button>
</div>
</div>
<div v-else-if="f.type === 'participants'" class="participants">
<div class="p-block">
<small>همکاران (فعالیت در لیست آن‌ها هم نمایش داده می‌شود)</small>
<UserMultiSelect
:model-value="participants(f.key).userIds"
:users="users"
add-label="افزودن همکار"
empty-label="همکاری انتخاب نشده"
@update:model-value="(ids: string[]) => patchParticipants(f.key, { userIds: ids })"
/>
</div>
<div class="p-block">
<small>اشخاص / مشتریان</small>
<div class="tags">
<span v-for="p in participants(f.key).people" :key="p.id" class="tag">
<UiIcon name="user" :size="12" />
{{ p.name }}
<button
type="button"
aria-label="حذف"
@click="patchParticipants(f.key, { people: participants(f.key).people.filter((x) => x.id !== p.id) })"
>
×
</button>
</span>
</div>
<div class="person-search">
<input
v-model="personQuery[f.key]"
type="search"
placeholder="جستجوی نام یا موبایل شخص…"
@input="searchPeople(f.key)"
@focus="personOpenKey = f.key"
/>
<ul v-if="personOpenKey === f.key && (personResults[f.key]?.length ?? 0) > 0" class="results">
<li v-for="p in personResults[f.key]" :key="p.id">
<button type="button" @click="addPerson(f.key, p)">
<b>{{ p.fullName }}</b>
<small v-if="p.company?.name">{{ p.company.name }}</small>
</button>
</li>
</ul>
</div>
</div>
<div class="p-block">
<small>مهمانان دیگر (نام آزاد)</small>
<div class="tags">
<span v-for="g in participants(f.key).guests" :key="g" class="tag guest">
{{ g }}
<button
type="button"
aria-label="حذف"
@click="patchParticipants(f.key, { guests: participants(f.key).guests.filter((x) => x !== g) })"
>
×
</button>
</span>
</div>
<div class="add-row">
<input
v-model="guestDraft[f.key]"
maxlength="120"
placeholder="نام مهمان و Enter"
@keydown.enter.prevent="addGuest(f.key)"
/>
<button type="button" class="add-btn" @click="addGuest(f.key)">
<UiIcon name="plus" :size="14" />
افزودن
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.fields-grid {
display: grid;
gap: 0.85rem;
}
.cf {
display: grid;
gap: 0.35rem;
min-width: 0;
}
.cf > label {
margin: 0;
color: var(--color-text-muted, #64748b);
font-size: var(--fs-xs, 0.78rem);
font-weight: 600;
}
.cf > label i {
color: var(--color-danger, #dc2626);
font-style: normal;
}
.cf input:not([type='checkbox']),
.cf select,
.cf textarea {
width: 100%;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
min-height: var(--control-h);
padding: 0 0.85rem;
font: inherit;
background: #fff;
box-sizing: border-box;
}
.cf textarea {
min-height: 5rem;
padding: 0.6rem 0.85rem;
resize: vertical;
}
.chips,
.tags {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.chip-opt {
display: inline-flex;
align-items: center;
gap: 0.25rem;
border: 1px solid #e2e8f0;
background: #fff;
border-radius: 999px;
padding: 0.3rem 0.75rem;
font: inherit;
font-size: 0.82rem;
cursor: pointer;
}
.chip-opt.on {
border-color: #5eead4;
background: #f0fdfa;
color: #0f766e;
font-weight: 600;
}
.switch {
display: inline-flex;
align-items: center;
gap: 0.6rem;
cursor: pointer;
}
.switch input {
display: none;
}
.switch i {
width: 44px;
height: 26px;
border-radius: 999px;
background: #e2e8f0;
position: relative;
}
.switch i::after {
content: '';
position: absolute;
inset-inline-start: 3px;
top: 3px;
width: 20px;
height: 20px;
border-radius: 999px;
background: #fff;
transition: 0.18s;
}
.switch input:checked + i {
background: #14b8a6;
}
.switch input:checked + i::after {
inset-inline-start: 21px;
}
.checklist ul {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.35rem;
}
.checklist li {
display: flex;
align-items: center;
gap: 0.4rem;
}
.checklist li input[type='checkbox'] {
width: 18px;
height: 18px;
accent-color: #14b8a6;
flex-shrink: 0;
}
.checklist .item-text {
flex: 1;
min-height: 2.2rem !important;
}
.checklist li.done .item-text {
text-decoration: line-through;
color: #94a3b8;
}
.progress {
margin: 0;
font-size: 0.78rem;
color: #0f766e;
font-weight: 600;
}
.mini {
width: 1.9rem;
height: 1.9rem;
flex-shrink: 0;
border: 1px solid #e2e8f0;
background: #fff;
border-radius: 8px;
display: grid;
place-items: center;
font-size: 0.65rem;
color: #64748b;
cursor: pointer;
}
.mini:disabled {
opacity: 0.35;
cursor: default;
}
.mini.danger {
color: #dc2626;
}
.add-row {
display: flex;
gap: 0.4rem;
margin-top: 0.35rem;
}
.add-row input {
flex: 1;
}
.add-btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
border: 1px dashed #5eead4;
background: #f0fdfa;
color: #0f766e;
border-radius: var(--radius);
padding: 0 0.8rem;
font: inherit;
font-size: 0.82rem;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
}
.participants {
display: grid;
gap: 0.75rem;
padding: 0.75rem;
border: 1px dashed #e2e8f0;
border-radius: var(--radius);
}
.p-block {
display: grid;
gap: 0.35rem;
}
.p-block > small {
color: #94a3b8;
font-size: 0.74rem;
}
.tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: #f0fdfa;
color: #0f766e;
border-radius: 999px;
padding: 0.2rem 0.6rem;
font-size: 0.78rem;
font-weight: 600;
}
.tag.guest {
background: #f1f5f9;
color: #334155;
}
.tag button {
border: 0;
background: transparent;
color: #64748b;
cursor: pointer;
font: inherit;
line-height: 1;
padding: 0 0.1rem;
}
.person-search {
position: relative;
}
.results {
position: absolute;
inset-inline: 0;
top: calc(100% + 4px);
z-index: 20;
list-style: none;
margin: 0;
padding: 0.3rem;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14);
max-height: 14rem;
overflow: auto;
}
.results button {
width: 100%;
display: grid;
gap: 0.1rem;
text-align: start;
border: 0;
background: none;
padding: 0.45rem 0.55rem;
border-radius: 8px;
font: inherit;
cursor: pointer;
}
.results button:hover {
background: #f8fafc;
}
.results b {
font-size: 0.86rem;
font-weight: 600;
}
.results small {
color: #94a3b8;
font-size: 0.74rem;
}
@media (min-width: 960px) {
.fields-grid {
grid-template-columns: 1fr 1fr;
}
.cf.wide {
grid-column: 1 / -1;
}
}
</style>
@@ -0,0 +1,263 @@
<script setup lang="ts">
import { computed } from 'vue';
import { RouterLink } from 'vue-router';
import type {
ActivityChecklistItem,
ActivityFieldDef,
ActivityFieldValues,
ActivityParticipantsValue,
UserSummaryDto,
} from '@mento/shared';
import { formatJalaliDay, toFaDigits } from '@/lib/jalali';
import UiIcon from '@/components/UiIcon.vue';
const props = defineProps<{
fields: ActivityFieldDef[];
values: ActivityFieldValues;
participantUsers?: UserSummaryDto[];
/** تیک چک‌لیست قابل تغییر باشد */
editable?: boolean;
busy?: boolean;
}>();
const emit = defineEmits<{
toggleItem: [key: string, itemId: string, done: boolean];
}>();
const userName = computed(
() => new Map((props.participantUsers ?? []).map((u) => [u.id, u.fullName])),
);
const filled = computed(() =>
props.fields.filter((f) => {
const v = props.values[f.key];
if (v == null || v === '') return false;
if (Array.isArray(v)) return v.length > 0;
return true;
}),
);
function text(f: ActivityFieldDef): string {
const v = props.values[f.key];
if (f.type === 'checkbox') return v === true ? 'بله' : 'خیر';
if (f.type === 'date' && typeof v === 'string') {
const d = new Date(v);
return Number.isNaN(d.getTime()) ? v : formatJalaliDay(d);
}
if (f.type === 'number' && typeof v === 'number') return toFaDigits(v.toLocaleString('en-US'));
if (Array.isArray(v)) return (v as string[]).join('، ');
return typeof v === 'string' ? v : '';
}
function checklist(key: string): ActivityChecklistItem[] {
const v = props.values[key];
return Array.isArray(v) ? (v as ActivityChecklistItem[]) : [];
}
function participants(key: string): ActivityParticipantsValue {
const v = props.values[key] as ActivityParticipantsValue | undefined;
return { userIds: v?.userIds ?? [], people: v?.people ?? [], guests: v?.guests ?? [] };
}
function safeUrl(raw: string): string | null {
try {
const u = new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`);
return u.protocol === 'http:' || u.protocol === 'https:' ? u.href : null;
} catch {
return null;
}
}
</script>
<template>
<div v-if="filled.length" class="fv">
<div
v-for="f in filled"
:key="f.key"
class="row"
:class="{ block: ['textarea', 'checklist', 'participants'].includes(f.type) }"
>
<span class="label">{{ f.label }}</span>
<div v-if="f.type === 'checklist'" class="checklist">
<p class="progress">
{{ toFaDigits(checklist(f.key).filter((x) => x.done).length) }} از
{{ toFaDigits(checklist(f.key).length) }} انجام شده
<i
class="bar"
:style="{
'--p': `${(checklist(f.key).filter((x) => x.done).length / Math.max(1, checklist(f.key).length)) * 100}%`,
}"
/>
</p>
<label v-for="it in checklist(f.key)" :key="it.id" class="item" :class="{ done: it.done }">
<input
type="checkbox"
:checked="it.done"
:disabled="!editable || busy"
@change="emit('toggleItem', f.key, it.id, ($event.target as HTMLInputElement).checked)"
/>
<span>{{ it.text }}</span>
</label>
</div>
<div v-else-if="f.type === 'participants'" class="people">
<span v-for="uid in participants(f.key).userIds" :key="uid" class="tag user">
<UiIcon name="user" :size="12" />
{{ userName.get(uid) || 'همکار' }}
</span>
<RouterLink
v-for="p in participants(f.key).people"
:key="p.id"
class="tag person"
:to="{ name: 'person-detail', params: { id: p.id } }"
>
<UiIcon name="users" :size="12" />
{{ p.name }}
</RouterLink>
<span v-for="g in participants(f.key).guests" :key="g" class="tag guest">{{ g }}</span>
</div>
<a
v-else-if="f.type === 'url' && safeUrl(text(f))"
class="value link"
:href="safeUrl(text(f))!"
target="_blank"
rel="noopener noreferrer"
dir="ltr"
>
{{ text(f) }}
</a>
<p v-else class="value" :class="{ multiline: f.type === 'textarea' }">{{ text(f) }}</p>
</div>
</div>
<p v-else class="empty">اطلاعاتی برای این نوع فعالیت ثبت نشده است.</p>
</template>
<style scoped>
.fv {
display: grid;
}
.row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.8rem;
padding: 0.72rem 0;
border-top: 1px solid #f1f5f9;
}
.row.block {
display: grid;
gap: 0.45rem;
justify-content: stretch;
}
.label {
color: #64748b;
font-size: 0.86rem;
}
.value {
margin: 0;
font-weight: 700;
text-align: start;
word-break: break-word;
}
.value.multiline {
font-weight: 500;
white-space: pre-wrap;
line-height: 1.9;
color: #334155;
}
.value.link {
color: #0f766e;
text-decoration: none;
}
.checklist {
display: grid;
gap: 0.35rem;
}
.progress {
margin: 0 0 0.2rem;
display: grid;
gap: 0.3rem;
font-size: 0.78rem;
color: #0f766e;
font-weight: 600;
}
.bar {
display: block;
height: 6px;
border-radius: 999px;
background: linear-gradient(90deg, #14b8a6 var(--p), #e2e8f0 var(--p));
}
.item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.55rem;
border-radius: 8px;
background: #f8fafc;
cursor: pointer;
font-size: 0.9rem;
}
.item input {
width: 18px;
height: 18px;
accent-color: #14b8a6;
flex-shrink: 0;
}
.item.done span {
text-decoration: line-through;
color: #94a3b8;
}
.people {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
border-radius: 999px;
padding: 0.22rem 0.65rem;
font-size: 0.8rem;
font-weight: 600;
text-decoration: none;
}
.tag.user {
background: #f0fdfa;
color: #0f766e;
}
.tag.person {
background: #eff6ff;
color: #1d4ed8;
}
.tag.guest {
background: #f1f5f9;
color: #334155;
}
.empty {
margin: 0.4rem 0 0;
color: #94a3b8;
font-size: 0.86rem;
}
</style>
@@ -0,0 +1,821 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import axios from 'axios';
import type {
ActivityFieldDef,
ActivityFieldType,
ActivityStageColor,
ActivityType,
ActivityTypeDefDto,
ActivityTypeIcon,
} from '@mento/shared';
import {
ACTIVITY_FIELD_TYPE_LABELS,
ACTIVITY_FIELD_TYPES,
ACTIVITY_FIELD_TYPES_WITH_OPTIONS,
ACTIVITY_STAGE_COLOR_LABELS,
ACTIVITY_STAGE_COLORS,
ACTIVITY_TYPE_ICONS,
ACTIVITY_TYPE_LABELS,
} from '@mento/shared';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import {
ACTIVITY_TYPE_ICON_LABELS,
newFieldKey,
typeColorStyle,
useActivityTypes,
} from '@/lib/activity-types';
import { UiButton, UiModal } from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ close: []; changed: [] }>();
const BASE_TYPES: ActivityType[] = ['call', 'meeting', 'task', 'note'];
type FieldDraft = ActivityFieldDef & { optionsText: string };
type Draft = {
id: string | null;
name: string;
baseType: ActivityType;
icon: ActivityTypeIcon;
color: ActivityStageColor;
isActive: boolean;
isSystem: boolean;
fields: FieldDraft[];
};
const { types, load } = useActivityTypes();
const selectedId = ref<string | 'new' | null>(null);
const draft = ref<Draft | null>(null);
const savedSnapshot = ref('');
const busy = ref('');
const error = ref('');
const notice = ref('');
function toDraft(t: ActivityTypeDefDto | null): Draft {
if (!t) {
return {
id: null,
name: '',
baseType: 'task',
icon: 'check',
color: 'blue',
isActive: true,
isSystem: false,
fields: [],
};
}
return {
id: t.id,
name: t.name,
baseType: t.baseType,
icon: t.icon,
color: t.color,
isActive: t.isActive,
isSystem: t.isSystem,
fields: t.fields.map((f) => ({ ...f, optionsText: (f.options ?? []).join('\n') })),
};
}
function snapshot(d: Draft | null) {
return JSON.stringify(d);
}
const dirty = computed(() => snapshot(draft.value) !== savedSnapshot.value);
function select(id: string | 'new') {
if (dirty.value && draft.value && !window.confirm('تغییرات ذخیره نشده از بین می‌رود. ادامه می‌دهید؟')) {
return;
}
selectedId.value = id;
draft.value = toDraft(id === 'new' ? null : (types.value.find((t) => t.id === id) ?? null));
savedSnapshot.value = snapshot(draft.value);
error.value = '';
notice.value = '';
}
watch(
() => props.open,
async (open) => {
if (!open) return;
error.value = '';
notice.value = '';
await load(true);
const first = types.value[0];
selectedId.value = null;
draft.value = null;
if (first) select(first.id);
},
{ immediate: true },
);
function apiMessage(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] } | undefined)?.message;
if (Array.isArray(msg)) return msg[0] ?? fallback;
if (typeof msg === 'string' && msg) return msg;
}
return fallback;
}
function addField() {
if (!draft.value) return;
draft.value.fields.push({
key: newFieldKey(),
label: '',
type: 'text',
required: false,
placeholder: '',
optionsText: '',
});
}
function moveField(index: number, dir: -1 | 1) {
const list = draft.value?.fields;
if (!list) return;
const to = index + dir;
if (to < 0 || to >= list.length) return;
const [f] = list.splice(index, 1);
list.splice(to, 0, f!);
}
function removeField(index: number) {
const f = draft.value?.fields[index];
if (!f) return;
if (
f.label.trim() &&
!window.confirm(
`فیلد «${f.label}» حذف شود؟ مقادیر ثبت‌شدهٔ آن در فعالیت‌های قبلی دیگر نمایش داده نمی‌شود.`,
)
) {
return;
}
draft.value!.fields.splice(index, 1);
}
function needsOptions(type: ActivityFieldType) {
return ACTIVITY_FIELD_TYPES_WITH_OPTIONS.includes(type);
}
function payloadFields(d: Draft): ActivityFieldDef[] {
return d.fields.map((f) => {
const out: ActivityFieldDef = { key: f.key, label: f.label.trim(), type: f.type };
if (needsOptions(f.type)) {
out.options = f.optionsText
.split(/\n|،|,/)
.map((o) => o.trim())
.filter(Boolean);
}
if (f.required) out.required = true;
if (f.placeholder?.trim()) out.placeholder = f.placeholder.trim();
return out;
});
}
function validate(d: Draft): string {
if (!d.name.trim()) return 'نام نوع فعالیت را بنویسید.';
const empty = d.fields.findIndex((f) => !f.label.trim());
if (empty >= 0) return `عنوان فیلد ${toFaDigits(empty + 1)} را بنویسید.`;
const noOpts = d.fields.find(
(f) => needsOptions(f.type) && !f.optionsText.split(/\n|،|,/).some((o) => o.trim()),
);
if (noOpts) return `برای فیلد «${noOpts.label}» حداقل یک گزینه بنویسید.`;
return '';
}
async function save() {
const d = draft.value;
if (!d) return;
const msg = validate(d);
if (msg) {
error.value = msg;
return;
}
busy.value = 'save';
error.value = '';
notice.value = '';
const body = {
name: d.name.trim(),
icon: d.icon,
color: d.color,
fields: payloadFields(d),
...(d.isSystem ? {} : { baseType: d.baseType, ...(d.id ? { isActive: d.isActive } : {}) }),
};
try {
if (d.id) {
const prev = types.value.find((t) => t.id === d.id);
if (
prev &&
!d.isSystem &&
prev.baseType !== d.baseType &&
!window.confirm(
'با تغییر نوع پایه، فعالیت‌های قبلی این نوع هم در گزارش‌ها با نوع پایهٔ جدید شمرده می‌شوند. ادامه می‌دهید؟',
)
) {
busy.value = '';
return;
}
await api.patch(`/activities/types/${d.id}`, body);
} else {
const { data } = await api.post<ActivityTypeDefDto>('/activities/types', body);
d.id = data.id;
}
await load(true);
const savedId = d.id!;
selectedId.value = savedId;
draft.value = toDraft(types.value.find((t) => t.id === savedId) ?? null);
savedSnapshot.value = snapshot(draft.value);
notice.value = 'ذخیره شد.';
emit('changed');
} catch (e) {
error.value = apiMessage(e, 'ذخیرهٔ نوع فعالیت ناموفق بود.');
} finally {
busy.value = '';
}
}
async function remove() {
const d = draft.value;
if (!d?.id || d.isSystem) return;
if (
!window.confirm(
`نوع «${d.name}» حذف شود؟ فعالیت‌های این نوع به نوع اصلی «${ACTIVITY_TYPE_LABELS[d.baseType]}» برمی‌گردند. برای نگه داشتن سوابق، می‌توانید به‌جای حذف آن را غیرفعال کنید.`,
)
) {
return;
}
busy.value = 'remove';
error.value = '';
try {
await api.delete(`/activities/types/${d.id}`);
await load(true);
savedSnapshot.value = snapshot(null);
draft.value = null;
const first = types.value[0];
if (first) select(first.id);
emit('changed');
} catch (e) {
error.value = apiMessage(e, 'حذف نوع فعالیت ناموفق بود.');
} finally {
busy.value = '';
}
}
async function moveType(index: number, dir: -1 | 1) {
const ids = types.value.map((t) => t.id);
const to = index + dir;
if (to < 0 || to >= ids.length) return;
const [moved] = ids.splice(index, 1);
ids.splice(to, 0, moved!);
busy.value = 'order';
error.value = '';
try {
await api.patch('/activities/types/reorder', { typeIds: ids });
await load(true);
emit('changed');
} catch (e) {
error.value = apiMessage(e, 'جابه‌جایی ناموفق بود.');
} finally {
busy.value = '';
}
}
function close() {
if (dirty.value && draft.value && !window.confirm('تغییرات ذخیره نشده از بین می‌رود. بستن؟')) return;
emit('close');
}
</script>
<template>
<UiModal :open="open" title="انواع فعالیت و فیلدهای اختصاصی" @close="close">
<div class="mgr">
<p class="hint">
برای هر نوع فعالیت فیلدهای مخصوص خودش را تعریف کنید؛ مثلاً «موضوع جلسه» و «شرکت‌کنندگان» برای جلسه
یا «لیست کارها» برای وظیفه. «نوع پایه» مشخص می‌کند فعالیت در گزارش‌ها جزو تماس، جلسه، وظیفه یا پیام
شمرده شود.
</p>
<div class="layout">
<aside class="list">
<div
v-for="(t, index) in types"
:key="t.id"
class="type-row"
:class="{ on: selectedId === t.id, off: !t.isActive }"
>
<button type="button" class="pick" @click="select(t.id)">
<span class="ico" :style="typeColorStyle(t.color)">
<UiIcon :name="t.icon" :size="14" />
</span>
<span class="txt">
<b>{{ t.name }}</b>
<small>
{{ ACTIVITY_TYPE_LABELS[t.baseType] }} · {{ toFaDigits(t.fields.length) }} فیلد
<template v-if="!t.isActive"> · غیرفعال</template>
</small>
</span>
</button>
<div class="order">
<button
type="button"
:disabled="index === 0 || Boolean(busy)"
aria-label="انتقال به بالا"
@click="moveType(index, -1)"
>
▲
</button>
<button
type="button"
:disabled="index === types.length - 1 || Boolean(busy)"
aria-label="انتقال به پایین"
@click="moveType(index, 1)"
>
▼
</button>
</div>
</div>
<button type="button" class="add-type" :class="{ on: selectedId === 'new' }" @click="select('new')">
<UiIcon name="plus" :size="15" />
نوع فعالیت جدید
</button>
</aside>
<section v-if="draft" class="editor">
<div class="grid">
<label class="f">
<span>نام نوع</span>
<input v-model="draft.name" maxlength="60" placeholder="مثلاً: بازدید حضوری" />
</label>
<label class="f">
<span>نوع پایه (برای گزارش‌ها)</span>
<select v-model="draft.baseType" :disabled="draft.isSystem">
<option v-for="b in BASE_TYPES" :key="b" :value="b">{{ ACTIVITY_TYPE_LABELS[b] }}</option>
</select>
</label>
<label class="f">
<span>رنگ</span>
<select v-model="draft.color">
<option v-for="c in ACTIVITY_STAGE_COLORS" :key="c" :value="c">
{{ ACTIVITY_STAGE_COLOR_LABELS[c] }}
</option>
</select>
</label>
<label v-if="!draft.isSystem && draft.id" class="f toggle">
<input v-model="draft.isActive" type="checkbox" />
<span>فعال (در فرم فعالیت قابل انتخاب باشد)</span>
</label>
</div>
<div class="f">
<span>آیکون</span>
<div class="icons">
<button
v-for="ic in ACTIVITY_TYPE_ICONS"
:key="ic"
type="button"
:class="{ on: draft.icon === ic }"
:style="draft.icon === ic ? typeColorStyle(draft.color) : undefined"
:title="ACTIVITY_TYPE_ICON_LABELS[ic]"
:aria-label="ACTIVITY_TYPE_ICON_LABELS[ic]"
@click="draft.icon = ic"
>
<UiIcon :name="ic" :size="16" />
</button>
</div>
</div>
<p v-if="draft.isSystem" class="sys-note">
این نوع اصلی است: حذف و غیرفعال نمی‌شود، ولی نام، ظاهر و فیلدهایش قابل تغییر است.
</p>
<div class="fields-head">
<strong>فیلدهای اختصاصی</strong>
<small>{{ toFaDigits(draft.fields.length) }} فیلد</small>
</div>
<p v-if="!draft.fields.length" class="empty">
هنوز فیلدی تعریف نشده؛ فعالیت‌های این نوع فقط فیلدهای عمومی (عنوان، زمان، مسئول، مشتری…) را دارند.
</p>
<div v-for="(f, index) in draft.fields" :key="f.key" class="field-card">
<div class="field-top">
<span class="num">{{ toFaDigits(index + 1) }}</span>
<input v-model="f.label" class="label-input" maxlength="80" placeholder="عنوان فیلد (مثلاً: موضوع جلسه)" />
<select v-model="f.type" aria-label="نوع فیلد">
<option v-for="ft in ACTIVITY_FIELD_TYPES" :key="ft" :value="ft">
{{ ACTIVITY_FIELD_TYPE_LABELS[ft] }}
</option>
</select>
<div class="field-actions">
<button type="button" :disabled="index === 0" aria-label="بالا" @click="moveField(index, -1)">▲</button>
<button
type="button"
:disabled="index === draft.fields.length - 1"
aria-label="پایین"
@click="moveField(index, 1)"
>
▼
</button>
<button type="button" class="danger" aria-label="حذف فیلد" @click="removeField(index)">
<UiIcon name="trash" :size="14" />
</button>
</div>
</div>
<div class="field-extra">
<textarea
v-if="needsOptions(f.type)"
v-model="f.optionsText"
rows="3"
placeholder="گزینه‌ها — هر گزینه در یک خط"
/>
<input
v-if="!['checkbox', 'participants', 'select', 'multiselect', 'date'].includes(f.type)"
v-model="f.placeholder"
maxlength="200"
placeholder="متن راهنما داخل فیلد (اختیاری)"
/>
<label v-if="f.type !== 'checkbox'" class="req">
<input v-model="f.required" type="checkbox" />
اجباری
</label>
<small v-if="f.type === 'participants'" class="tip">
همکارانی که به‌عنوان شرکت‌کننده انتخاب شوند، این فعالیت را در لیست خودشان هم می‌بینند و اعلان
دریافت می‌کنند.
</small>
</div>
</div>
<button type="button" class="add-field" @click="addField">
<UiIcon name="plus" :size="15" />
افزودن فیلد
</button>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="notice" class="notice">{{ notice }}</p>
<div class="actions">
<UiButton
variant="primary"
type="button"
:disabled="!dirty || Boolean(busy)"
:loading="busy === 'save'"
@click="save"
>
{{ draft.id ? 'ذخیرهٔ تغییرات' : 'ساخت نوع فعالیت' }}
</UiButton>
<UiButton
v-if="draft.id && !draft.isSystem"
variant="ghost"
type="button"
:disabled="Boolean(busy)"
:loading="busy === 'remove'"
@click="remove"
>
<UiIcon name="trash" :size="15" />
حذف نوع
</UiButton>
</div>
</section>
<p v-else class="empty">یک نوع فعالیت را انتخاب کنید.</p>
</div>
</div>
</UiModal>
</template>
<style scoped>
.mgr {
display: grid;
gap: 0.9rem;
}
.hint {
margin: 0;
color: #64748b;
font-size: 0.84rem;
line-height: 1.8;
}
.layout {
display: grid;
gap: 1rem;
}
.list {
display: grid;
gap: 0.4rem;
align-content: start;
}
.type-row {
display: flex;
align-items: center;
gap: 0.3rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
}
.type-row.on {
border-color: #5eead4;
background: #f0fdfa;
}
.type-row.off {
opacity: 0.6;
}
.pick {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 0.55rem;
border: 0;
background: none;
padding: 0.5rem 0.6rem;
font: inherit;
text-align: start;
cursor: pointer;
}
.ico {
width: 30px;
height: 30px;
border-radius: 999px;
display: grid;
place-items: center;
flex-shrink: 0;
}
.txt {
display: grid;
min-width: 0;
}
.txt b {
font-size: 0.88rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.txt small {
color: #94a3b8;
font-size: 0.72rem;
}
.order {
display: grid;
padding-inline-end: 0.3rem;
}
.order button,
.field-actions button {
border: 0;
background: none;
color: #94a3b8;
font-size: 0.62rem;
cursor: pointer;
padding: 0.1rem 0.3rem;
}
.order button:disabled,
.field-actions button:disabled {
opacity: 0.3;
cursor: default;
}
.add-type,
.add-field {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
min-height: var(--control-h);
border: 1px dashed #5eead4;
background: #f0fdfa;
color: #0f766e;
border-radius: var(--radius);
font: inherit;
font-weight: 700;
font-size: 0.86rem;
cursor: pointer;
}
.add-type.on {
border-style: solid;
}
.editor {
display: grid;
gap: 0.8rem;
align-content: start;
min-width: 0;
}
.grid {
display: grid;
gap: 0.65rem;
}
.f {
display: grid;
gap: 0.3rem;
min-width: 0;
}
.f > span {
color: #64748b;
font-size: 0.78rem;
font-weight: 600;
}
.f.toggle {
display: flex;
align-items: center;
gap: 0.45rem;
align-self: end;
min-height: var(--control-h);
}
.f.toggle span {
color: #334155;
font-weight: 500;
}
input:not([type='checkbox']),
select,
textarea {
min-height: var(--control-h);
padding: 0 0.65rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
font: inherit;
font-size: 0.86rem;
min-width: 0;
width: 100%;
box-sizing: border-box;
}
textarea {
padding: 0.5rem 0.65rem;
resize: vertical;
}
select:disabled {
background: #f8fafc;
color: #94a3b8;
}
input[type='checkbox'] {
accent-color: #14b8a6;
width: 16px;
height: 16px;
}
.icons {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.icons button {
width: 2.2rem;
height: 2.2rem;
display: grid;
place-items: center;
border: 1px solid var(--line);
border-radius: 10px;
background: #fff;
color: #64748b;
cursor: pointer;
}
.icons button.on {
border-color: currentColor;
}
.sys-note {
margin: 0;
padding: 0.5rem 0.7rem;
border-radius: var(--radius);
background: #f8fafc;
color: #475569;
font-size: 0.8rem;
}
.fields-head {
display: flex;
align-items: baseline;
justify-content: space-between;
padding-top: 0.6rem;
border-top: 1px solid var(--line);
}
.fields-head small {
color: #94a3b8;
}
.empty {
margin: 0;
color: #94a3b8;
font-size: 0.84rem;
}
.field-card {
display: grid;
gap: 0.5rem;
padding: 0.6rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fcfdfe;
}
.field-top {
display: grid;
grid-template-columns: 1.4rem minmax(0, 1fr) 10rem auto;
gap: 0.45rem;
align-items: center;
}
.num {
color: #94a3b8;
font-size: 0.8rem;
text-align: center;
}
.field-actions {
display: flex;
align-items: center;
}
.field-actions .danger {
color: #dc2626;
font-size: inherit;
}
.field-extra {
display: grid;
gap: 0.45rem;
padding-inline-start: 1.85rem;
}
.req {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.82rem;
color: #334155;
}
.tip {
color: #0f766e;
font-size: 0.75rem;
line-height: 1.7;
}
.error {
margin: 0;
color: #b91c1c;
}
.notice {
margin: 0;
color: #0f766e;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
position: sticky;
bottom: 0;
padding: 0.6rem 0 0.2rem;
background: #fff;
}
@media (min-width: 760px) {
.layout {
grid-template-columns: 14rem minmax(0, 1fr);
align-items: start;
}
.grid {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 560px) {
.field-top {
grid-template-columns: 1.4rem minmax(0, 1fr) auto;
}
.field-top select {
grid-column: 2 / -1;
grid-row: 2;
}
}
</style>
+111
View File
@@ -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<ActivityType, ActivityTypeIcon> = {
call: 'phone',
meeting: 'users',
task: 'check',
note: 'chat',
};
const BASE_TYPE_COLOR: Record<ActivityType, ActivityStageColor> = {
call: 'green',
meeting: 'purple',
task: 'orange',
note: 'blue',
};
export const ACTIVITY_TYPE_ICON_LABELS: Record<ActivityTypeIcon, string> = {
phone: 'تلفن',
users: 'جمع',
check: 'تیک',
chat: 'گفتگو',
mail: 'ایمیل',
building: 'ساختمان',
calendar: 'تقویم',
star: 'ستاره',
flag: 'پرچم',
briefcase: 'کیف',
document: 'سند',
target: 'هدف',
truck: 'ارسال',
send: 'ارسال پیام',
megaphone: 'بلندگو',
pin: 'مکان',
bell: 'زنگ',
sparkle: 'درخشش',
};
const types = ref<ActivityTypeDefDto[]>([]);
const loaded = ref(false);
let pending: Promise<void> | null = null;
/** انواع فعالیت سازمان (کش مشترک بین صفحات) */
export function useActivityTypes() {
async function load(force = false): Promise<void> {
if (loaded.value && !force) return;
if (pending && !force) return pending;
pending = api
.get<ActivityTypeDefDto[]>('/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<Record<ActivityType, ActivityTypeDefDto>> = {};
for (const t of types.value) if (t.isSystem && !out[t.baseType]) out[t.baseType] = t;
return out;
});
/** تعریف کامل نوع مؤثر یک فعالیت */
function defOf(a: Pick<ActivityDto, 'type' | 'typeDef'>): 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<ActivityDto, 'type' | 'typeDef'>): 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)}`;
}
+126 -47
View File
@@ -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<TimeFilter>(() => {
const stages = ref<ActivityStageDto[]>([]);
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<ActivityStageCountsDto>({ 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<Array<{ key: TypeFilter; label: string }>>(() => [
{ 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<ActivityType | null>(() => {
const value = typeof route.query.type === 'string' ? route.query.type : '';
return ACTIVITY_TYPES.includes(value as ActivityType) ? (value as ActivityType) : null;
});
const typeFilter = computed<TypeFilter>(() => {
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<StatusFilter>(() => {
@@ -217,20 +236,6 @@ const counts = ref<Record<TimeFilter, number>>({
all: 0,
});
const typeLabel: Record<ActivityType, string> = {
call: 'تماس',
meeting: 'جلسه',
task: 'وظیفه',
note: 'پیام',
};
const typeIcon: Record<ActivityType, 'phone' | 'users' | 'check' | 'chat'> = {
call: 'phone',
meeting: 'users',
task: 'check',
note: 'chat',
};
const statusLabel: Record<UiStatus, string> = {
done: 'انجام شد',
progress: 'در حال انجام',
@@ -263,7 +268,13 @@ const sharedParams = computed<Record<string, string | undefined>>(() => ({
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<string, string | undefined>) {
}
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(() => {
<UiIcon name="settings" :size="15" />
مدیریت ستون‌ها
</UiButton>
<UiButton
v-if="canManageStages"
variant="ghost"
size="sm"
type="button"
@click="typesManagerOpen = true"
>
<UiIcon name="sliders" :size="15" />
انواع فعالیت و فیلدها
</UiButton>
<UiSegmented v-model="viewModel" :options="VIEW_OPTIONS">
<template #icon="{ option }">
<UiIcon :name="option.icon ?? 'list'" :size="15" />
@@ -800,7 +823,7 @@ onUnmounted(() => {
<input
v-model="q"
type="search"
placeholder="جستجو در عنوان، مشتری، نوع فعالیت..."
placeholder="جستجو در عنوان، مشتری، کارشناس، نوع فعالیت..."
/>
</form>
<div class="filter-wrap" @click.stop>
@@ -962,27 +985,41 @@ onUnmounted(() => {
<p v-if="subtitle(item)" class="sub">{{ subtitle(item) }}</p>
</div>
</div>
<div v-if="colOn('type')" class="type-cell">
<span class="type-pill" :class="item.type">
<UiIcon :name="typeIcon[item.type]" :size="13" />
{{ typeLabel[item.type] }}
</span>
</div>
<div v-if="colOn('when')" class="when" :class="{ late: uiStatus(item) === 'overdue' }">
<strong>{{ clockText(item) }}</strong>
<span>{{ dayText(item) }}</span>
</div>
<div v-if="colOn('who')" class="who">
<strong>{{
item.assignees?.length
? item.assignees.map((a) => a.fullName).join('، ')
: item.assignee?.fullName || '—'
}}</strong>
<span v-if="item.company || item.person">
<UiIcon name="building" :size="12" />
{{ item.company?.name || item.person?.fullName }}
</div>
<div v-if="colOn('customer')" class="customer">
<RouterLink
v-if="item.company"
:to="{ name: 'company-detail', params: { id: item.company.id } }"
>
<UiIcon name="building" :size="13" />
{{ item.company.name }}
</RouterLink>
<RouterLink
v-if="item.person"
:to="{ name: 'person-detail', params: { id: item.person.id } }"
:class="{ secondary: Boolean(item.company) }"
>
<UiIcon name="user" :size="13" />
{{ item.person.fullName }}
</RouterLink>
<span v-if="!item.company && !item.person" class="none">—</span>
</div>
<div v-if="colOn('type')" class="type-cell">
<span class="type-pill" :style="typeColorStyle(activityTypeView(item).color)">
<UiIcon :name="activityTypeView(item).icon" :size="13" />
{{ activityTypeView(item).name }}
</span>
</div>
<div v-if="colOn('when')" class="when" :class="{ late: uiStatus(item) === 'overdue' }">
<strong>{{ clockText(item) }}</strong>
<span>{{ dayText(item) }}</span>
</div>
<div v-if="colOn('status')" class="status-cell">
<span class="status" :class="uiStatus(item)">
<UiIcon :name="statusIcon[uiStatus(item)]" :size="13" />
@@ -1068,10 +1105,12 @@ onUnmounted(() => {
انجام شد
</button>
</UiDropdownMenu>
<span class="type-ico" :class="item.type">
<UiIcon :name="typeIcon[item.type]" :size="18" />
<span class="type-ico" :style="typeColorStyle(activityTypeView(item).color)">
<UiIcon :name="activityTypeView(item).icon" :size="18" />
</span>
<span class="type-pill" :style="typeColorStyle(activityTypeView(item).color)">
{{ activityTypeView(item).name }}
</span>
<span class="type-pill" :class="item.type">{{ typeLabel[item.type] }}</span>
</div>
</div>
<footer class="card-foot">
@@ -1121,6 +1160,12 @@ onUnmounted(() => {
@close="stagesManagerOpen = false"
@changed="onStagesChanged(); kanbanReload += 1"
/>
<ActivityTypesManager
v-if="canManageStages"
:open="typesManagerOpen"
@close="typesManagerOpen = false"
@changed="onTypesChanged"
/>
</section>
</template>
@@ -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;
}
+66 -33
View File
@@ -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<ActivityType, string> = {
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<ActivityType, 'phone' | 'users' | 'check' | 'chat'> = {
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<ActivityDto>(`/activities/${a.id}`, { fields: nextFields });
activity.value = { ...data, participantUsers: a.participantUsers };
} catch {
activity.value = a;
error.value = 'ثبت تغییر چک‌لیست ناموفق بود.';
} finally {
fieldsBusy.value = false;
}
}
const priorityLabel: Record<ActivityPriority, string> = {
very_low: 'خیلی کم',
@@ -254,6 +275,7 @@ watch(id, () => {
onMounted(() => {
void load();
void loadUsers();
void activityTypes.load();
});
</script>
@@ -295,18 +317,18 @@ onMounted(() => {
{{ subtitle }}
</p>
</div>
<span class="type-ico" :class="activity.type">
<UiIcon :name="typeIcon[activity.type]" :size="22" />
<span class="type-ico" :style="typeColorStyle(typeView!.color)">
<UiIcon :name="typeView!.icon" :size="22" />
</span>
</header>
<dl>
<div>
<dt>
<UiIcon :name="typeIcon[activity.type]" :size="15" />
<UiIcon :name="typeView!.icon" :size="15" />
نوع فعالیت
</dt>
<dd>{{ typeLabel[activity.type] }}</dd>
<dd>{{ typeView!.name }}</dd>
</div>
<div>
<dt>
@@ -369,6 +391,23 @@ onMounted(() => {
</dl>
</article>
<article v-if="typeDef?.fields.length" class="card type-card">
<h3>
<span class="type-ico sm" :style="typeColorStyle(typeView!.color)">
<UiIcon :name="typeView!.icon" :size="14" />
</span>
اطلاعات {{ typeView!.name }}
</h3>
<ActivityFieldsView
:fields="typeDef.fields"
:values="activity.fields ?? {}"
:participant-users="activity.participantUsers"
:busy="fieldsBusy"
editable
@toggle-item="toggleChecklistItem"
/>
</article>
<article class="card note">
<button class="note-head" type="button" @click="noteOpen = !noteOpen">
<strong>
@@ -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 {
+111 -33
View File
@@ -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<PersonDto[]>([]);
const deals = ref<DealDto[]>([]);
const users = ref<OrgUserDto[]>([]);
const activityTypes = useActivityTypes();
const title = ref('');
const type = ref<ActivityType | ''>('');
const typeDefId = ref('');
const fieldValues = ref<ActivityFieldValues>({});
/** نوع غیرفعالی که فعالیت فعلی دارد — در لیست انتخاب نگه داشته می‌شود */
const keepTypeId = ref('');
const priority = ref<ActivityPriority>('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<ActivityTypeDefDto[]>(() =>
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<PaginatedResponse<PersonDto>>('/people', { params: { limit: 100 } }),
api.get<PipelineBoardDto>('/pipelines/board'),
api.get<OrgUserDto[]>('/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<ActivityDto>(`/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 });
<div class="field">
<label>نوع فعالیت <i>*</i></label>
<button class="picker type-pick" type="button" @click.stop="typeOpen = !typeOpen">
<span class="type-ico" :class="type || 'empty'">
<span
class="type-ico"
:style="typeMeta ? typeColorStyle(typeMeta.color) : undefined"
>
<UiIcon :name="typeMeta?.icon || 'phone'" :size="16" />
</span>
<span :class="{ ghost: !typeMeta }">{{ typeMeta?.label || 'انتخاب کنید' }}</span>
<span :class="{ ghost: !typeMeta }">{{ typeMeta?.name || 'انتخاب کنید' }}</span>
<UiIcon name="chevron" :size="16" />
</button>
<ul v-if="typeOpen" class="menu">
<li v-for="opt in typeOptions" :key="opt.value">
<button type="button" @click="type = opt.value; typeOpen = false">
<span class="type-ico" :class="opt.value">
<li v-for="opt in typeOptions" :key="opt.id">
<button type="button" :class="{ on: opt.id === typeDefId }" @click="pickType(opt)">
<span class="type-ico" :style="typeColorStyle(opt.color)">
<UiIcon :name="opt.icon" :size="14" />
</span>
{{ opt.label }}
{{ opt.name }}
<small v-if="opt.fields.length" class="type-fields-count">
{{ toFaDigits(opt.fields.length) }} فیلد
</small>
</button>
</li>
<li v-if="!typeOptions.length" class="menu-empty">نوع فعالیتی تعریف نشده است.</li>
</ul>
</div>
<div class="field">
@@ -582,6 +629,16 @@ watch([id, isNew], () => void load(), { immediate: true });
</div>
</div>
<div v-if="typeFields.length" class="card type-fields">
<p class="type-fields-title">
<span class="type-ico sm" :style="typeColorStyle(typeMeta!.color)">
<UiIcon :name="typeMeta!.icon" :size="13" />
</span>
اطلاعات {{ typeMeta!.name }}
</p>
<ActivityFieldsEditor v-model="fieldValues" :fields="typeFields" :users="users" />
</div>
<div class="card toggles">
<label class="switch">
<span>این فعالیت انجام شده</span>
@@ -844,7 +901,7 @@ watch([id, isNew], () => void load(), { immediate: true });
<dl>
<div>
<dt>نوع فعالیت</dt>
<dd>{{ typeMeta?.label || '—' }}</dd>
<dd>{{ typeMeta?.name || '—' }}</dd>
</div>
<div>
<dt>تاریخ و ساعت</dt>
@@ -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 {