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[] leads Lead[]
leadStages LeadStage[] leadStages LeadStage[]
activityStages ActivityStage[] activityStages ActivityStage[]
activityTypeDefs ActivityTypeDef[]
leadEvents LeadEvent[] leadEvents LeadEvent[]
leadImportJobs LeadImportJob[] leadImportJobs LeadImportJob[]
conversationSettings ConversationSettings? conversationSettings ConversationSettings?
@@ -646,6 +647,30 @@ model ActivityStage {
@@index([organizationId, position]) @@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 { model Activity {
id String @id @default(cuid()) id String @id @default(cuid())
organizationId String organizationId String
@@ -683,10 +708,18 @@ model Activity {
/// مرحله (ستون کانبان)؛ null یا مرحلهٔ ناهم‌نوع با status → اولین مرحلهٔ هم‌نوع /// مرحله (ستون کانبان)؛ null یا مرحلهٔ ناهم‌نوع با status → اولین مرحلهٔ هم‌نوع
stageId String? stageId String?
stage ActivityStage? @relation(fields: [stageId], references: [id], onDelete: SetNull) 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()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([stageId]) @@index([stageId])
@@index([typeDefId])
@@index([organizationId, status, dueAt]) @@index([organizationId, status, dueAt])
@@index([organizationId, assigneeId]) @@index([organizationId, assigneeId])
@@index([organizationId, createdById]) @@index([organizationId, createdById])
@@ -25,15 +25,19 @@ import type { Actor } from '../common/record-scope';
import { ActivitiesService } from './activities.service'; import { ActivitiesService } from './activities.service';
import { ActivitiesExcelService } from './activities-excel.service'; import { ActivitiesExcelService } from './activities-excel.service';
import { ActivityStagesService } from './activity-stages.service'; import { ActivityStagesService } from './activity-stages.service';
import { ActivityTypesService } from './activity-types.service';
import { import {
CreateActivityDto, CreateActivityDto,
CreateActivityStageDto, CreateActivityStageDto,
CreateActivityTypeDto,
ImportActivityRowsDto, ImportActivityRowsDto,
ListActivitiesQueryDto, ListActivitiesQueryDto,
MoveActivityStageDto, MoveActivityStageDto,
ReorderActivityStagesDto, ReorderActivityStagesDto,
ReorderActivityTypesDto,
UpdateActivityDto, UpdateActivityDto,
UpdateActivityStageDto, UpdateActivityStageDto,
UpdateActivityTypeDto,
} from './dto'; } from './dto';
import { brand } from '@mento/shared'; import { brand } from '@mento/shared';
@@ -49,6 +53,7 @@ export class ActivitiesController {
private readonly activitiesService: ActivitiesService, private readonly activitiesService: ActivitiesService,
private readonly excelService: ActivitiesExcelService, private readonly excelService: ActivitiesExcelService,
private readonly stagesService: ActivityStagesService, private readonly stagesService: ActivityStagesService,
private readonly typesService: ActivityTypesService,
) {} ) {}
@Get('summary') @Get('summary')
@@ -94,6 +99,44 @@ export class ActivitiesController {
return { ok: true }; 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') @Get('stage-counts')
stageCounts( stageCounts(
@CurrentUser() user: Actor, @CurrentUser() user: Actor,
@@ -107,6 +150,7 @@ export class ActivitiesController {
query.desk, query.desk,
{ {
type: query.type, type: query.type,
typeDefId: query.typeDefId,
status: query.status, status: query.status,
assigneeId: query.assigneeId, assigneeId: query.assigneeId,
}, },
@@ -137,6 +181,7 @@ export class ActivitiesController {
query.desk, query.desk,
{ {
type: query.type, type: query.type,
typeDefId: query.typeDefId,
status: query.status, status: query.status,
assigneeId: query.assigneeId, assigneeId: query.assigneeId,
}, },
+8 -2
View File
@@ -3,6 +3,7 @@ import { ActivitiesController } from './activities.controller';
import { ActivitiesService } from './activities.service'; import { ActivitiesService } from './activities.service';
import { ActivitiesExcelService } from './activities-excel.service'; import { ActivitiesExcelService } from './activities-excel.service';
import { ActivityStagesService } from './activity-stages.service'; import { ActivityStagesService } from './activity-stages.service';
import { ActivityTypesService } from './activity-types.service';
import { NotificationsModule } from '../notifications/notifications.module'; import { NotificationsModule } from '../notifications/notifications.module';
import { AutomationsModule } from '../automations/automations.module'; import { AutomationsModule } from '../automations/automations.module';
import { MessageAutomationsModule } from '../message-automations/message-automations.module'; import { MessageAutomationsModule } from '../message-automations/message-automations.module';
@@ -10,7 +11,12 @@ import { MessageAutomationsModule } from '../message-automations/message-automat
@Module({ @Module({
imports: [NotificationsModule, AutomationsModule, MessageAutomationsModule], imports: [NotificationsModule, AutomationsModule, MessageAutomationsModule],
controllers: [ActivitiesController], controllers: [ActivitiesController],
providers: [ActivitiesService, ActivitiesExcelService, ActivityStagesService], providers: [
exports: [ActivitiesService, ActivityStagesService], ActivitiesService,
ActivitiesExcelService,
ActivityStagesService,
ActivityTypesService,
],
exports: [ActivitiesService, ActivityStagesService, ActivityTypesService],
}) })
export class ActivitiesModule {} export class ActivitiesModule {}
+165 -23
View File
@@ -5,6 +5,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import type { import type {
ActivityDto, ActivityDto,
ActivityFieldValues,
ActivityFilter, ActivityFilter,
ActivitySummaryDto, ActivitySummaryDto,
ActivityStageCountsDto, ActivityStageCountsDto,
@@ -17,10 +18,22 @@ import { PrismaService } from '../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service'; import { NotificationsService } from '../notifications/notifications.service';
import { AutomationsService } from '../automations/automations.service'; import { AutomationsService } from '../automations/automations.service';
import { MessageAutomationsService } from '../message-automations/message-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 { normalizeTags } from '../common/tags';
import { buildSearchWhere, ci } from '../common/search'; import { buildSearchWhere, ci } from '../common/search';
import { ActivityStagesService, type StageDefaults } from './activity-stages.service'; 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'>; type ActivityActor = Pick<Actor, 'userId' | 'role'>;
@@ -29,6 +42,7 @@ type StageScope = { stageId: string };
type ListFacets = { type ListFacets = {
type?: ActivityDto['type']; type?: ActivityDto['type'];
typeDefId?: string;
status?: ActivityDto['status']; status?: ActivityDto['status'];
assigneeId?: string; assigneeId?: string;
}; };
@@ -57,10 +71,14 @@ type ActivityRow = {
assignees: Array<{ id: string; fullName: string }>; assignees: Array<{ id: string; fullName: string }>;
createdBy: { id: string; fullName: string } | null; createdBy: { id: string; fullName: string } | null;
stage?: { id: string; kind: ActivityDto['status'] } | 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 = { const include = {
stage: { select: { id: true, kind: true } }, stage: { select: { id: true, kind: true } },
typeDef: { select: { id: true, name: true, icon: true, color: true } },
person: { select: { id: true, firstName: true, lastName: true } }, person: { select: { id: true, firstName: true, lastName: true } },
company: { select: { id: true, name: true } }, company: { select: { id: true, name: true } },
deal: { select: { id: true, title: true } }, deal: { select: { id: true, title: true } },
@@ -77,14 +95,23 @@ export class ActivitiesService {
private readonly automations: AutomationsService, private readonly automations: AutomationsService,
private readonly messageAutomations: MessageAutomationsService, private readonly messageAutomations: MessageAutomationsService,
private readonly stages: ActivityStagesService, 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( async summary(
organizationId: string, organizationId: string,
actor: ActivityActor, actor: ActivityActor,
): Promise<ActivitySummaryDto> { ): Promise<ActivitySummaryDto> {
const { start, end } = dayBounds(); const { start, end } = dayBounds();
const scope = assigneeWhere(actor); const scope = activityAccessWhere(actor);
const [todayCount, overdueCount] = await Promise.all([ const [todayCount, overdueCount] = await Promise.all([
this.prisma.activity.count({ this.prisma.activity.count({
where: { where: {
@@ -139,10 +166,11 @@ export class ActivitiesService {
}), }),
]); ]);
const ctx = await this.ctx(organizationId);
return { return {
today: today.map((row) => this.toDto(row)), today: today.map((row) => this.toDto(row, ctx)),
overdue: overdue.map((row) => this.toDto(row)), overdue: overdue.map((row) => this.toDto(row, ctx)),
upcoming: upcoming.map((row) => this.toDto(row)), upcoming: upcoming.map((row) => this.toDto(row, ctx)),
todayCount, todayCount,
overdueCount, overdueCount,
}; };
@@ -168,6 +196,7 @@ export class ActivitiesService {
facets?: ListFacets, facets?: ListFacets,
stageScope?: StageScope, stageScope?: StageScope,
): Promise<PaginatedResponse<ActivityDto>> { ): Promise<PaginatedResponse<ActivityDto>> {
const typeRows = await this.types.rows(organizationId);
const where = this.buildWhere( const where = this.buildWhere(
organizationId, organizationId,
actor, actor,
@@ -178,13 +207,17 @@ export class ActivitiesService {
desk, desk,
facets, facets,
stageScope, stageScope,
typeRows,
); );
if (stageScope && stageScope.stageId !== 'all') { if (stageScope && stageScope.stageId !== 'all') {
(where.AND as Prisma.ActivityWhereInput[]).push( (where.AND as Prisma.ActivityWhereInput[]).push(
await this.stages.whereForStage(organizationId, stageScope.stageId), 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 take = Math.min(Math.max(limit, 1), 200);
const [rows, total] = await this.prisma.$transaction([ const [rows, total] = await this.prisma.$transaction([
this.prisma.activity.findMany({ this.prisma.activity.findMany({
@@ -198,7 +231,7 @@ export class ActivitiesService {
]); ]);
return { return {
items: rows.map((row) => this.toDto(row, defaults)), items: rows.map((row) => this.toDto(row, ctx)),
total, total,
page, page,
limit: take, limit: take,
@@ -227,6 +260,7 @@ export class ActivitiesService {
desk, desk,
facets, facets,
{ stageId: 'all' }, { stageId: 'all' },
await this.types.rows(organizationId),
); );
const groups = await this.prisma.activity.groupBy({ const groups = await this.prisma.activity.groupBy({
by: ['status', 'stageId'], by: ['status', 'stageId'],
@@ -276,7 +310,7 @@ export class ActivitiesService {
if (stage.kind === 'done' && existing?.status !== 'done') { if (stage.kind === 'done' && existing?.status !== 'done') {
await this.fireDone(organizationId, row); await this.fireDone(organizationId, row);
} }
return this.toDto(row, await this.stages.defaults(organizationId)); return this.toDto(row, await this.ctx(organizationId));
} }
async get( async get(
@@ -285,11 +319,19 @@ export class ActivitiesService {
actor: ActivityActor, actor: ActivityActor,
): Promise<ActivityDto> { ): Promise<ActivityDto> {
const row = await this.prisma.activity.findFirst({ const row = await this.prisma.activity.findFirst({
where: { id, organizationId, ...assigneeWhere(actor) }, where: { id, organizationId, ...activityAccessWhere(actor) },
include, include,
}); });
if (!row) throw new NotFoundException('فعالیت پیدا نشد'); 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( async create(
@@ -313,6 +355,15 @@ export class ActivitiesService {
const initialStage = initialStageId const initialStage = initialStageId
? await this.stages.get(organizationId, initialStageId) ? await this.stages.get(organizationId, initialStageId)
: null; : 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({ const row = await this.prisma.activity.create({
data: { data: {
@@ -325,7 +376,10 @@ export class ActivitiesService {
} }
: {}), : {}),
title: dto.title.trim(), title: dto.title.trim(),
type: dto.type ?? 'task', type: resolved.baseType,
typeDefId: resolved.typeDefId,
fields: fields as Prisma.InputJsonValue,
participantIds,
priority: dto.priority ?? 'medium', priority: dto.priority ?? 'medium',
description: emptyToNull(dto.description), description: emptyToNull(dto.description),
dueAt: dto.dueAt ? new Date(dto.dueAt) : null, dueAt: dto.dueAt ? new Date(dto.dueAt) : null,
@@ -358,6 +412,11 @@ export class ActivitiesService {
entityId: row.id, entityId: row.id,
}); });
} }
await this.notifyParticipants(
organizationId,
row,
participantIds.filter((uid) => !assigneeIds.includes(uid) && uid !== userId),
);
await this.messageAutomations.onEvent('activity_created', { await this.messageAutomations.onEvent('activity_created', {
organizationId, organizationId,
@@ -374,7 +433,7 @@ export class ActivitiesService {
await this.fireDone(organizationId, row); await this.fireDone(organizationId, row);
} }
return this.toDto(row, await this.stages.defaults(organizationId)); return this.toDto(row, await this.ctx(organizationId));
} }
async update( async update(
@@ -386,9 +445,33 @@ export class ActivitiesService {
await this.ensureExists(organizationId, id, actor); await this.ensureExists(organizationId, id, actor);
await this.assertLinks(organizationId, actor, dto); 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 = {}; const data: Prisma.ActivityUncheckedUpdateInput = {};
if (dto.title !== undefined) data.title = dto.title.trim(); 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 (dto.priority !== undefined) data.priority = dto.priority;
if ('description' in dto) data.description = emptyToNull(dto.description); if ('description' in dto) data.description = emptyToNull(dto.description);
if ('dueAt' in dto) data.dueAt = dto.dueAt ? new Date(dto.dueAt) : null; 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; 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({ const row = await this.prisma.activity.update({
where: { id }, where: { id },
data: { data: {
@@ -448,10 +527,21 @@ export class ActivitiesService {
}, },
include, 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') { if (dto.status === 'done' && existing?.status !== 'done') {
await this.fireDone(organizationId, row); await this.fireDone(organizationId, row);
} }
return this.toDto(row, await this.stages.defaults(organizationId)); return this.toDto(row, await this.ctx(organizationId));
} }
async complete( async complete(
@@ -472,7 +562,7 @@ export class ActivitiesService {
if (existing?.status !== 'done') { if (existing?.status !== 'done') {
await this.fireDone(organizationId, row); await this.fireDone(organizationId, row);
} }
return this.toDto(row, await this.stages.defaults(organizationId)); return this.toDto(row, await this.ctx(organizationId));
} }
async remove( async remove(
@@ -501,6 +591,7 @@ export class ActivitiesService {
desk?: 'mine' | 'team', desk?: 'mine' | 'team',
facets?: ListFacets, facets?: ListFacets,
stageScope?: StageScope, stageScope?: StageScope,
typeRows: TypeRow[] = [],
): Prisma.ActivityWhereInput { ): Prisma.ActivityWhereInput {
const { start, end } = dayBounds(); const { start, end } = dayBounds();
const scope: Prisma.ActivityWhereInput = const scope: Prisma.ActivityWhereInput =
@@ -509,13 +600,16 @@ export class ActivitiesService {
OR: [ OR: [
{ assigneeId: actor.userId }, { assigneeId: actor.userId },
{ assignees: { some: { id: actor.userId } } }, { assignees: { some: { id: actor.userId } } },
{ participantIds: { has: actor.userId } },
], ],
} }
: assigneeWhere(actor); : activityAccessWhere(actor);
const facetAssignee = facets?.assigneeId?.trim(); const facetAssignee = facets?.assigneeId?.trim();
const facetTypeDef = facets?.typeDefId?.trim();
const andParts: Prisma.ActivityWhereInput[] = [ const andParts: Prisma.ActivityWhereInput[] = [
scope, scope,
...(facets?.type ? [{ type: facets.type }] : []), ...(facets?.type ? [{ type: facets.type }] : []),
...(facetTypeDef ? [this.types.whereForType(typeRows, facetTypeDef)] : []),
...(facets?.status ? [{ status: facets.status }] : []), ...(facets?.status ? [{ status: facets.status }] : []),
...(facetAssignee ...(facetAssignee
? [ ? [
@@ -547,6 +641,10 @@ export class ActivitiesService {
}, },
{ company: { name: ci(t) } }, { company: { name: ci(t) } },
{ deal: { title: 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); if (search) andParts.push(search);
const base: Prisma.ActivityWhereInput = { const base: Prisma.ActivityWhereInput = {
@@ -633,7 +731,7 @@ export class ActivitiesService {
actor: ActivityActor, actor: ActivityActor,
) { ) {
const found = await this.prisma.activity.findFirst({ const found = await this.prisma.activity.findFirst({
where: { id, organizationId, ...assigneeWhere(actor) }, where: { id, organizationId, ...activityAccessWhere(actor) },
select: { id: true }, select: { id: true },
}); });
if (!found) throw new NotFoundException('فعالیت پیدا نشد'); 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 { 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, id: row.id,
type: row.type, type: row.type,
status: row.status, 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 { function emptyToNull(value?: string | null): string | null {
if (value == null) return null; if (value == null) return null;
const trimmed = value.trim(); 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, IsDateString,
IsIn, IsIn,
IsInt, IsInt,
IsObject,
IsOptional, IsOptional,
IsString, IsString,
Max, Max,
@@ -14,6 +15,8 @@ import {
MinLength, MinLength,
ValidateIf, ValidateIf,
} from 'class-validator'; } from 'class-validator';
import type { ActivityFieldDef, ActivityFieldValues, ActivityTypeIcon } from '@mento/shared';
import { ACTIVITY_TYPE_ICONS } from '@mento/shared';
/** تکه‌های JSON از ایمپورت مرورگرگرا */ /** تکه‌های JSON از ایمپورت مرورگرگرا */
export class ImportActivityRowsDto { export class ImportActivityRowsDto {
@@ -66,6 +69,11 @@ export class ListActivitiesQueryDto {
@IsString() @IsString()
assigneeId?: string; assigneeId?: string;
/** نوع فعالیت (تعریف‌شده در سازمان) */
@IsOptional()
@IsString()
typeDefId?: string;
/** شناسهٔ مرحله یا 'all' — حالت تب/کانبان مراحل */ /** شناسهٔ مرحله یا 'all' — حالت تب/کانبان مراحل */
@IsOptional() @IsOptional()
@IsString() @IsString()
@@ -151,6 +159,66 @@ export class MoveActivityStageDto {
stageId!: string; 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 { export class CreateActivityDto {
@IsString() @IsString()
@MinLength(2) @MinLength(2)
@@ -166,6 +234,15 @@ export class CreateActivityDto {
@IsIn(ACTIVITY_TYPES) @IsIn(ACTIVITY_TYPES)
type?: (typeof ACTIVITY_TYPES)[number]; type?: (typeof ACTIVITY_TYPES)[number];
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
typeDefId?: string | null;
@IsOptional()
@IsObject()
fields?: ActivityFieldValues;
@IsOptional() @IsOptional()
@IsIn(ACTIVITY_PRIORITIES) @IsIn(ACTIVITY_PRIORITIES)
priority?: (typeof ACTIVITY_PRIORITIES)[number]; priority?: (typeof ACTIVITY_PRIORITIES)[number];
@@ -242,6 +319,15 @@ export class UpdateActivityDto {
@IsIn(ACTIVITY_TYPES) @IsIn(ACTIVITY_TYPES)
type?: (typeof ACTIVITY_TYPES)[number]; type?: (typeof ACTIVITY_TYPES)[number];
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
typeDefId?: string | null;
@IsOptional()
@IsObject()
fields?: ActivityFieldValues;
@IsOptional() @IsOptional()
@IsIn(ACTIVITY_STATUSES) @IsIn(ACTIVITY_STATUSES)
status?: (typeof ACTIVITY_STATUSES)[number]; 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( export function noteAccessWhere(
actor: ScopedActor, actor: ScopedActor,
@@ -5,10 +5,10 @@ import axios from 'axios';
import type { import type {
ActivityDto, ActivityDto,
ActivityStageDto, ActivityStageDto,
ActivityType,
PaginatedResponse, PaginatedResponse,
} from '@mento/shared'; } from '@mento/shared';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { activityTypeView } from '@/lib/activity-types';
import { calendarDaysBetween, formatClockFa, formatJalaliDay, startOfLocalDay, toFaDigits } from '@/lib/jalali'; import { calendarDaysBetween, formatClockFa, formatJalaliDay, startOfLocalDay, toFaDigits } from '@/lib/jalali';
import PipelineBoard from '@/components/pipeline/PipelineBoard.vue'; import PipelineBoard from '@/components/pipeline/PipelineBoard.vue';
import DealCardOverlay from '@/components/pipeline/DealCardOverlay.vue'; import DealCardOverlay from '@/components/pipeline/DealCardOverlay.vue';
@@ -59,13 +59,6 @@ const moving = ref(false);
const stageBusy = ref(false); const stageBusy = ref(false);
const localOrder = ref<string[] | null>(null); const localOrder = ref<string[] | null>(null);
const typeLabel: Record<ActivityType, string> = {
call: 'تماس',
meeting: 'جلسه',
task: 'وظیفه',
note: 'پیام',
};
const KIND_BADGE: Record<ActivityStageDto['kind'], string | undefined> = { const KIND_BADGE: Record<ActivityStageDto['kind'], string | undefined> = {
open: undefined, open: undefined,
done: 'انجام‌شده', done: 'انجام‌شده',
@@ -109,7 +102,7 @@ function toCard(item: ActivityDto, stageId: string): PipelineDeal {
return { return {
id: item.id, id: item.id,
title: item.title, title: item.title,
description: [typeLabel[item.type], party].filter(Boolean).join(' · '), description: [activityTypeView(item).name, party].filter(Boolean).join(' · '),
amount: null, amount: null,
date: dateText(item), date: dateText(item),
initials: initialsFromTitle(party || item.title), 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 ExcelImportFlow from '@/components/ExcelImportFlow.vue';
import ActivitiesKanban from '@/components/activities/ActivitiesKanban.vue'; import ActivitiesKanban from '@/components/activities/ActivitiesKanban.vue';
import ActivityStagesManager from '@/components/activities/ActivityStagesManager.vue'; import ActivityStagesManager from '@/components/activities/ActivityStagesManager.vue';
import ActivityTypesManager from '@/components/activities/ActivityTypesManager.vue';
import { PIPELINE_ACCENT } from '@/lib/pipeline-board'; import { PIPELINE_ACCENT } from '@/lib/pipeline-board';
import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types';
import { import {
ACTIVITY_EXCEL_ALIASES, ACTIVITY_EXCEL_ALIASES,
ACTIVITY_EXCEL_FIELDS, ACTIVITY_EXCEL_FIELDS,
@@ -56,10 +58,11 @@ import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
const ACT_TABLE_KEY = 'activities'; const ACT_TABLE_KEY = 'activities';
const ACT_COLUMNS = [ 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: 'type', label: 'نوع فعالیت', width: '7.6rem' },
{ key: 'when', label: 'زمان شروع', width: '7.2rem' }, { key: 'when', label: 'زمان شروع', width: '7.2rem' },
{ key: 'who', label: 'کارشناس', width: '8.4rem' },
{ key: 'status', label: 'وضعیت', width: '8.6rem' }, { key: 'status', label: 'وضعیت', width: '8.6rem' },
] as const; ] as const;
type ActColKey = (typeof ACT_COLUMNS)[number]['key']; type ActColKey = (typeof ACT_COLUMNS)[number]['key'];
@@ -80,7 +83,8 @@ const actGridStyle = computed(() => ({
].join(' '), ].join(' '),
})); }));
type TypeFilter = 'all' | ActivityType; /** 'all' یا شناسهٔ نوع فعالیت */
type TypeFilter = string;
type StatusFilter = 'all' | ActivityStatus; type StatusFilter = 'all' | ActivityStatus;
type UiStatus = 'done' | 'progress' | 'planned' | 'overdue' | 'canceled'; type UiStatus = 'done' | 'progress' | 'planned' | 'overdue' | 'canceled';
type DeskScope = 'mine' | 'team'; type DeskScope = 'mine' | 'team';
@@ -106,14 +110,6 @@ const DESK_TABS: Array<{ key: DeskScope; label: string }> = [
{ key: 'team', label: 'همه تیم' }, { 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 }> = [ const STATUS_FILTERS: Array<{ key: StatusFilter; label: string }> = [
{ key: 'all', label: 'همه وضعیت‌ها' }, { key: 'all', label: 'همه وضعیت‌ها' },
{ key: 'open', label: 'باز / انجام‌نشده' }, { key: 'open', label: 'باز / انجام‌نشده' },
@@ -155,6 +151,13 @@ const filter = computed<TimeFilter>(() => {
const stages = ref<ActivityStageDto[]>([]); const stages = ref<ActivityStageDto[]>([]);
const stagesManagerOpen = ref(false); 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 kanbanReload = ref(0);
const stageCounts = ref<ActivityStageCountsDto>({ counts: {}, total: 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_TYPES: ActivityType[] = ['call', 'meeting', 'task', 'note'];
const ACTIVITY_STATUSES: ActivityStatus[] = ['open', 'done', 'canceled']; 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 typeFilter = computed<TypeFilter>(() => {
const value = typeof route.query.type === 'string' ? route.query.type : 'all'; const id = typeof route.query.typeDefId === 'string' ? route.query.typeDefId.trim() : '';
return ACTIVITY_TYPES.includes(value as ActivityType) ? (value as ActivityType) : 'all'; if (id) return id;
if (legacyBaseType.value) return activityTypes.systemOf.value[legacyBaseType.value]?.id ?? 'all';
return 'all';
}); });
const statusFilter = computed<StatusFilter>(() => { const statusFilter = computed<StatusFilter>(() => {
@@ -217,20 +236,6 @@ const counts = ref<Record<TimeFilter, number>>({
all: 0, 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> = { const statusLabel: Record<UiStatus, string> = {
done: 'انجام شد', done: 'انجام شد',
progress: 'در حال انجام', progress: 'در حال انجام',
@@ -263,7 +268,13 @@ const sharedParams = computed<Record<string, string | undefined>>(() => ({
filter: filter.value, filter: filter.value,
q: typeof route.query.q === 'string' && route.query.q.trim() ? route.query.q.trim() : undefined, q: typeof route.query.q === 'string' && route.query.q.trim() ? route.query.q.trim() : undefined,
desk: showDeskTabs.value ? desk.value : 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, status: statusFilter.value === 'all' ? undefined : statusFilter.value,
assigneeId: assigneeFilter.value || undefined, assigneeId: assigneeFilter.value || undefined,
})); }));
@@ -524,7 +535,7 @@ function patchFacetQuery(patch: Record<string, string | undefined>) {
} }
function setTypeFilter(next: TypeFilter) { function setTypeFilter(next: TypeFilter) {
patchFacetQuery({ type: next === 'all' ? undefined : next }); patchFacetQuery({ typeDefId: next === 'all' ? undefined : next, type: undefined });
} }
function setStatusFilter(next: StatusFilter) { function setStatusFilter(next: StatusFilter) {
@@ -620,6 +631,7 @@ watch(
route.query.page, route.query.page,
route.query.desk, route.query.desk,
route.query.type, route.query.type,
route.query.typeDefId,
route.query.status, route.query.status,
route.query.assigneeId, route.query.assigneeId,
route.query.stage, route.query.stage,
@@ -657,6 +669,7 @@ onMounted(() => {
syncMobile(); syncMobile();
mobileQuery.addEventListener('change', syncMobile); mobileQuery.addEventListener('change', syncMobile);
void loadUsers(); void loadUsers();
void activityTypes.load(true);
void loadStages().then(() => { void loadStages().then(() => {
if (migrateLegacyDoneFilter()) return; if (migrateLegacyDoneFilter()) return;
void load(); void load();
@@ -745,6 +758,16 @@ onUnmounted(() => {
<UiIcon name="settings" :size="15" /> <UiIcon name="settings" :size="15" />
مدیریت ستون‌ها مدیریت ستون‌ها
</UiButton> </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"> <UiSegmented v-model="viewModel" :options="VIEW_OPTIONS">
<template #icon="{ option }"> <template #icon="{ option }">
<UiIcon :name="option.icon ?? 'list'" :size="15" /> <UiIcon :name="option.icon ?? 'list'" :size="15" />
@@ -800,7 +823,7 @@ onUnmounted(() => {
<input <input
v-model="q" v-model="q"
type="search" type="search"
placeholder="جستجو در عنوان، مشتری، نوع فعالیت..." placeholder="جستجو در عنوان، مشتری، کارشناس، نوع فعالیت..."
/> />
</form> </form>
<div class="filter-wrap" @click.stop> <div class="filter-wrap" @click.stop>
@@ -962,27 +985,41 @@ onUnmounted(() => {
<p v-if="subtitle(item)" class="sub">{{ subtitle(item) }}</p> <p v-if="subtitle(item)" class="sub">{{ subtitle(item) }}</p>
</div> </div>
</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"> <div v-if="colOn('who')" class="who">
<strong>{{ <strong>{{
item.assignees?.length item.assignees?.length
? item.assignees.map((a) => a.fullName).join('، ') ? item.assignees.map((a) => a.fullName).join('، ')
: item.assignee?.fullName || '—' : item.assignee?.fullName || '—'
}}</strong> }}</strong>
<span v-if="item.company || item.person"> </div>
<UiIcon name="building" :size="12" /> <div v-if="colOn('customer')" class="customer">
{{ item.company?.name || item.person?.fullName }} <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> </span>
</div> </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"> <div v-if="colOn('status')" class="status-cell">
<span class="status" :class="uiStatus(item)"> <span class="status" :class="uiStatus(item)">
<UiIcon :name="statusIcon[uiStatus(item)]" :size="13" /> <UiIcon :name="statusIcon[uiStatus(item)]" :size="13" />
@@ -1068,10 +1105,12 @@ onUnmounted(() => {
انجام شد انجام شد
</button> </button>
</UiDropdownMenu> </UiDropdownMenu>
<span class="type-ico" :class="item.type"> <span class="type-ico" :style="typeColorStyle(activityTypeView(item).color)">
<UiIcon :name="typeIcon[item.type]" :size="18" /> <UiIcon :name="activityTypeView(item).icon" :size="18" />
</span>
<span class="type-pill" :style="typeColorStyle(activityTypeView(item).color)">
{{ activityTypeView(item).name }}
</span> </span>
<span class="type-pill" :class="item.type">{{ typeLabel[item.type] }}</span>
</div> </div>
</div> </div>
<footer class="card-foot"> <footer class="card-foot">
@@ -1121,6 +1160,12 @@ onUnmounted(() => {
@close="stagesManagerOpen = false" @close="stagesManagerOpen = false"
@changed="onStagesChanged(); kanbanReload += 1" @changed="onStagesChanged(); kanbanReload += 1"
/> />
<ActivityTypesManager
v-if="canManageStages"
:open="typesManagerOpen"
@close="typesManagerOpen = false"
@changed="onTypesChanged"
/>
</section> </section>
</template> </template>
@@ -1566,7 +1611,7 @@ h1 {
.thead, .thead,
.trow { .trow {
display: grid; 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; gap: 0.8rem;
align-items: center; align-items: center;
padding: 0.95rem 1.1rem; padding: 0.95rem 1.1rem;
@@ -1682,6 +1727,40 @@ time.note {
gap: 0.25rem; 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 { .when.late {
color: #dc2626; color: #dc2626;
} }
+66 -33
View File
@@ -12,9 +12,16 @@ import UserAvatar from '@/components/UserAvatar.vue';
import UserMultiSelect from '@/components/UserMultiSelect.vue'; import UserMultiSelect from '@/components/UserMultiSelect.vue';
import ActivityCommentSheet from '@/components/activities/ActivityCommentSheet.vue'; import ActivityCommentSheet from '@/components/activities/ActivityCommentSheet.vue';
import ActivityPostponeSheet from '@/components/activities/ActivityPostponeSheet.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 { useAuthStore } from '@/stores/auth';
import { canReassignRecords } from '@/lib/roles'; import { canReassignRecords } from '@/lib/roles';
import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -44,19 +51,33 @@ const assigneeNames = computed(() => {
return list.length ? list.join('، ') : '—'; return list.length ? list.join('، ') : '—';
}); });
const typeLabel: Record<ActivityType, string> = { const activityTypes = useActivityTypes();
call: 'تماس', const typeView = computed(() => (activity.value ? activityTypeView(activity.value) : null));
meeting: 'جلسه', const typeDef = computed(() => (activity.value ? activityTypes.defOf(activity.value) : null));
task: 'وظیفه', const fieldsBusy = ref(false);
note: 'پیام',
};
const typeIcon: Record<ActivityType, 'phone' | 'users' | 'check' | 'chat'> = { /** تیک چک‌لیست بدون باز کردن فرم ویرایش */
call: 'phone', async function toggleChecklistItem(key: string, itemId: string, done: boolean) {
meeting: 'users', const a = activity.value;
task: 'check', if (!a || fieldsBusy.value) return;
note: 'chat', 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> = { const priorityLabel: Record<ActivityPriority, string> = {
very_low: 'خیلی کم', very_low: 'خیلی کم',
@@ -254,6 +275,7 @@ watch(id, () => {
onMounted(() => { onMounted(() => {
void load(); void load();
void loadUsers(); void loadUsers();
void activityTypes.load();
}); });
</script> </script>
@@ -295,18 +317,18 @@ onMounted(() => {
{{ subtitle }} {{ subtitle }}
</p> </p>
</div> </div>
<span class="type-ico" :class="activity.type"> <span class="type-ico" :style="typeColorStyle(typeView!.color)">
<UiIcon :name="typeIcon[activity.type]" :size="22" /> <UiIcon :name="typeView!.icon" :size="22" />
</span> </span>
</header> </header>
<dl> <dl>
<div> <div>
<dt> <dt>
<UiIcon :name="typeIcon[activity.type]" :size="15" /> <UiIcon :name="typeView!.icon" :size="15" />
نوع فعالیت نوع فعالیت
</dt> </dt>
<dd>{{ typeLabel[activity.type] }}</dd> <dd>{{ typeView!.name }}</dd>
</div> </div>
<div> <div>
<dt> <dt>
@@ -369,6 +391,23 @@ onMounted(() => {
</dl> </dl>
</article> </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"> <article class="card note">
<button class="note-head" type="button" @click="noteOpen = !noteOpen"> <button class="note-head" type="button" @click="noteOpen = !noteOpen">
<strong> <strong>
@@ -573,24 +612,18 @@ h1 {
flex-shrink: 0; flex-shrink: 0;
} }
.type-ico.call { .type-ico.sm {
background: #ecfdf5; width: 28px;
color: #0f766e; height: 28px;
} }
.type-ico.meeting { .type-card h3 {
background: #f5f3ff; margin: 0 0 0.4rem;
color: #6d28d9; display: flex;
} align-items: center;
gap: 0.45rem;
.type-ico.task { font-size: 0.95rem;
background: #fff7ed; color: #0f172a;
color: #c2410c;
}
.type-ico.note {
background: #eff6ff;
color: #2563eb;
} }
dl { dl {
+111 -33
View File
@@ -27,10 +27,15 @@ import {
UiPageHeader, UiPageHeader,
UiTextarea, UiTextarea,
} from '@/components/ui'; } from '@/components/ui';
import ActivityFieldsEditor from '@/components/activities/ActivityFieldsEditor.vue';
import { typeColorStyle, useActivityTypes } from '@/lib/activity-types';
import type { import type {
ActivityDto, ActivityDto,
ActivityFieldValue,
ActivityFieldValues,
ActivityPriority, ActivityPriority,
ActivityType, ActivityType,
ActivityTypeDefDto,
CreateActivityRequest, CreateActivityRequest,
DealDto, DealDto,
OrgUserDto, OrgUserDto,
@@ -71,8 +76,13 @@ const people = ref<PersonDto[]>([]);
const deals = ref<DealDto[]>([]); const deals = ref<DealDto[]>([]);
const users = ref<OrgUserDto[]>([]); const users = ref<OrgUserDto[]>([]);
const activityTypes = useActivityTypes();
const title = ref(''); const title = ref('');
const type = ref<ActivityType | ''>(''); const type = ref<ActivityType | ''>('');
const typeDefId = ref('');
const fieldValues = ref<ActivityFieldValues>({});
/** نوع غیرفعالی که فعالیت فعلی دارد — در لیست انتخاب نگه داشته می‌شود */
const keepTypeId = ref('');
const priority = ref<ActivityPriority>('medium'); const priority = ref<ActivityPriority>('medium');
const description = ref(''); const description = ref('');
const tagsRaw = ref(''); const tagsRaw = ref('');
@@ -185,16 +195,24 @@ watch(personQuery, (raw) => {
}, 300); }, 300);
}); });
const typeOptions: Array<{ const typeOptions = computed<ActivityTypeDefDto[]>(() =>
value: ActivityType; activityTypes.types.value.filter((t) => t.isActive || t.id === keepTypeId.value),
label: string; );
icon: 'phone' | 'users' | 'check' | 'chat';
}> = [ function pickType(def: ActivityTypeDefDto) {
{ value: 'call', label: 'تماس', icon: 'phone' }, typeDefId.value = def.id;
{ value: 'meeting', label: 'جلسه', icon: 'users' }, type.value = def.baseType;
{ value: 'task', label: 'وظیفه', icon: 'check' }, typeOpen.value = false;
{ value: 'note', label: 'یادداشت', icon: 'chat' }, }
];
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 }> = [ const priorityOptions: Array<{ value: ActivityPriority; label: string; tone: string }> = [
{ value: 'very_high', label: 'خیلی زیاد', tone: 'very-high' }, { 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' }, { 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 selectedPerson = computed(() => people.value.find((p) => p.id === personId.value) ?? null);
const selectedAssignees = computed(() => const selectedAssignees = computed(() =>
assigneeIds.value assigneeIds.value
@@ -261,6 +280,9 @@ function defaultDue() {
function resetForm() { function resetForm() {
title.value = ''; title.value = '';
type.value = ''; type.value = '';
typeDefId.value = '';
fieldValues.value = {};
keepTypeId.value = '';
priority.value = 'medium'; priority.value = 'medium';
description.value = ''; description.value = '';
tagsRaw.value = ''; tagsRaw.value = '';
@@ -300,6 +322,7 @@ async function load() {
api.get<PaginatedResponse<PersonDto>>('/people', { params: { limit: 100 } }), api.get<PaginatedResponse<PersonDto>>('/people', { params: { limit: 100 } }),
api.get<PipelineBoardDto>('/pipelines/board'), api.get<PipelineBoardDto>('/pipelines/board'),
api.get<OrgUserDto[]>('/users').catch(() => ({ data: [] as OrgUserDto[] })), api.get<OrgUserDto[]>('/users').catch(() => ({ data: [] as OrgUserDto[] })),
activityTypes.load(true),
]); ]);
people.value = peopleRes.data.items; people.value = peopleRes.data.items;
deals.value = boardRes.data.stages.flatMap((s) => s.deals); 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}`); const { data } = await api.get<ActivityDto>(`/activities/${id.value}`);
title.value = data.title; title.value = data.title;
type.value = data.type; 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'; priority.value = data.priority ?? 'medium';
description.value = data.description ?? ''; description.value = data.description ?? '';
needsNote.value = Boolean(data.description?.trim()); needsNote.value = Boolean(data.description?.trim());
@@ -342,13 +369,17 @@ async function load() {
await ensurePersonLoaded(incomingPerson); await ensurePersonLoaded(incomingPerson);
personId.value = 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; const incomingType = props.initialType || route.query.type;
if ( const baseDef =
typeof incomingType === 'string' && typeof incomingType === 'string'
typeOptions.some((o) => o.value === incomingType) ? activityTypes.systemOf.value[incomingType as ActivityType]
) { : undefined;
type.value = incomingType as ActivityType; const def = incomingDef?.isActive ? incomingDef : baseDef;
} if (def) pickType(def);
const incomingTitle = const incomingTitle =
props.initialTitle || props.initialTitle ||
(typeof route.query.title === 'string' ? route.query.title : ''); (typeof route.query.title === 'string' ? route.query.title : '');
@@ -379,9 +410,14 @@ function payload(): CreateActivityRequest {
const due = const due =
dueDate.value && dueTime.value ? new Date(`${dueDate.value}T${dueTime.value}:00`) : null; dueDate.value && dueTime.value ? new Date(`${dueDate.value}T${dueTime.value}:00`) : null;
const reminder = parseLocalInput(reminderAt.value); const reminder = parseLocalInput(reminderAt.value);
const keys = new Set(typeFields.value.map((f) => f.key));
return { return {
title: title.value, title: title.value,
type: type.value || 'task', 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, priority: priority.value,
description: description.value || undefined, description: description.value || undefined,
tags: parseTags(tagsRaw.value), tags: parseTags(tagsRaw.value),
@@ -411,6 +447,10 @@ function toggleAssignee(userId: string) {
function validate() { function validate() {
if (!type.value) return 'نوع فعالیت را انتخاب کنید.'; if (!type.value) return 'نوع فعالیت را انتخاب کنید.';
if (title.value.trim().length < 2) 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 (!dueDate.value || !dueTime.value) return 'تاریخ و ساعت را مشخص کنید.';
if (!assigneeIds.value.length) return 'حداقل یک مسئول فعالیت را انتخاب کنید.'; if (!assigneeIds.value.length) return 'حداقل یک مسئول فعالیت را انتخاب کنید.';
if (needsNote.value && !description.value.trim()) return 'توضیح این فعالیت را بنویسید.'; if (needsNote.value && !description.value.trim()) return 'توضیح این فعالیت را بنویسید.';
@@ -553,21 +593,28 @@ watch([id, isNew], () => void load(), { immediate: true });
<div class="field"> <div class="field">
<label>نوع فعالیت <i>*</i></label> <label>نوع فعالیت <i>*</i></label>
<button class="picker type-pick" type="button" @click.stop="typeOpen = !typeOpen"> <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" /> <UiIcon :name="typeMeta?.icon || 'phone'" :size="16" />
</span> </span>
<span :class="{ ghost: !typeMeta }">{{ typeMeta?.label || 'انتخاب کنید' }}</span> <span :class="{ ghost: !typeMeta }">{{ typeMeta?.name || 'انتخاب کنید' }}</span>
<UiIcon name="chevron" :size="16" /> <UiIcon name="chevron" :size="16" />
</button> </button>
<ul v-if="typeOpen" class="menu"> <ul v-if="typeOpen" class="menu">
<li v-for="opt in typeOptions" :key="opt.value"> <li v-for="opt in typeOptions" :key="opt.id">
<button type="button" @click="type = opt.value; typeOpen = false"> <button type="button" :class="{ on: opt.id === typeDefId }" @click="pickType(opt)">
<span class="type-ico" :class="opt.value"> <span class="type-ico" :style="typeColorStyle(opt.color)">
<UiIcon :name="opt.icon" :size="14" /> <UiIcon :name="opt.icon" :size="14" />
</span> </span>
{{ opt.label }} {{ opt.name }}
<small v-if="opt.fields.length" class="type-fields-count">
{{ toFaDigits(opt.fields.length) }} فیلد
</small>
</button> </button>
</li> </li>
<li v-if="!typeOptions.length" class="menu-empty">نوع فعالیتی تعریف نشده است.</li>
</ul> </ul>
</div> </div>
<div class="field"> <div class="field">
@@ -582,6 +629,16 @@ watch([id, isNew], () => void load(), { immediate: true });
</div> </div>
</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"> <div class="card toggles">
<label class="switch"> <label class="switch">
<span>این فعالیت انجام شده</span> <span>این فعالیت انجام شده</span>
@@ -844,7 +901,7 @@ watch([id, isNew], () => void load(), { immediate: true });
<dl> <dl>
<div> <div>
<dt>نوع فعالیت</dt> <dt>نوع فعالیت</dt>
<dd>{{ typeMeta?.label || '—' }}</dd> <dd>{{ typeMeta?.name || '—' }}</dd>
</div> </div>
<div> <div>
<dt>تاریخ و ساعت</dt> <dt>تاریخ و ساعت</dt>
@@ -1160,19 +1217,40 @@ watch([id, isNew], () => void load(), { immediate: true });
flex-shrink: 0; flex-shrink: 0;
} }
.type-ico.meeting { .type-ico.sm {
background: #f5f3ff; width: 26px;
color: #7c3aed; height: 26px;
} }
.type-ico.task { .menu button.on {
background: #fff7ed; background: #f0fdfa;
color: #c2410c;
} }
.type-ico.note { .type-fields-count {
background: #eff6ff; margin-inline-start: auto;
color: #2563eb; 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 { .who {
+159
View File
@@ -1199,6 +1199,152 @@ export interface ActivityStageCountsDto {
total: number; total: number;
} }
/** برچسب نوع پایه (برای گزارش‌ها و فعالیت‌های بدون نوع سفارشی) */
export const ACTIVITY_TYPE_LABELS: Record<ActivityType, string> = {
call: "تماس",
meeting: "جلسه",
task: "وظیفه",
note: "پیام",
};
export type ActivityFieldType =
| "text"
| "textarea"
| "number"
| "date"
| "url"
| "select"
| "multiselect"
| "checkbox"
| "checklist"
| "participants";
export const ACTIVITY_FIELD_TYPES: ActivityFieldType[] = [
"text",
"textarea",
"number",
"date",
"url",
"select",
"multiselect",
"checkbox",
"checklist",
"participants",
];
export const ACTIVITY_FIELD_TYPE_LABELS: Record<ActivityFieldType, string> = {
text: "متن کوتاه",
textarea: "متن بلند",
number: "عدد",
date: "تاریخ",
url: "لینک",
select: "انتخاب از لیست",
multiselect: "چندانتخابی",
checkbox: "بله / خیر",
checklist: "چک‌لیست (لیست کارها)",
participants: "شرکت‌کنندگان",
};
/** فیلدهایی که گزینه لازم دارند */
export const ACTIVITY_FIELD_TYPES_WITH_OPTIONS: ActivityFieldType[] = ["select", "multiselect"];
export interface ActivityFieldDef {
/** کلید پایدار (a-z، 0-9، _) — با تغییر برچسب عوض نمی‌شود */
key: string;
label: string;
type: ActivityFieldType;
options?: string[];
required?: boolean;
placeholder?: string | null;
}
export interface ActivityChecklistItem {
id: string;
text: string;
done: boolean;
}
export interface ActivityParticipantsValue {
/** کاربران سازمان — فعالیت در لیست آن‌ها هم دیده می‌شود */
userIds: string[];
/** اشخاص (مشتریان) */
people: Array<{ id: string; name: string }>;
/** مهمانان آزاد (نام) */
guests: string[];
}
export type ActivityFieldValue =
| string
| number
| boolean
| string[]
| ActivityChecklistItem[]
| ActivityParticipantsValue
| null;
export type ActivityFieldValues = Record<string, ActivityFieldValue>;
export const ACTIVITY_TYPE_ICONS = [
"phone",
"users",
"check",
"chat",
"mail",
"building",
"calendar",
"star",
"flag",
"briefcase",
"document",
"target",
"truck",
"send",
"megaphone",
"pin",
"bell",
"sparkle",
] as const;
export type ActivityTypeIcon = (typeof ACTIVITY_TYPE_ICONS)[number];
export interface ActivityTypeSummaryDto {
id: string;
name: string;
icon: ActivityTypeIcon;
color: ActivityStageColor;
}
export interface ActivityTypeDefDto extends ActivityTypeSummaryDto {
/** نوع پایه — مبنای گزارش‌ها و سازگاری */
baseType: ActivityType;
fields: ActivityFieldDef[];
position: number;
isActive: boolean;
/** چهار نوع اصلی — حذف‌شدنی نیستند */
isSystem: boolean;
}
export interface CreateActivityTypeRequest {
name: string;
baseType?: ActivityType;
icon?: ActivityTypeIcon;
color?: ActivityStageColor;
fields?: ActivityFieldDef[];
}
export interface UpdateActivityTypeRequest {
name?: string;
baseType?: ActivityType;
icon?: ActivityTypeIcon;
color?: ActivityStageColor;
fields?: ActivityFieldDef[];
isActive?: boolean;
}
export interface ReorderActivityTypesRequest {
typeIds: string[];
}
export type CustomFieldEntity = "person" | "company" | "deal"; export type CustomFieldEntity = "person" | "company" | "deal";
export type CustomFieldType = "text" | "number" | "date" | "boolean" | "select"; export type CustomFieldType = "text" | "number" | "date" | "boolean" | "select";
@@ -1267,6 +1413,14 @@ export interface ActivityDto {
createdBy: UserSummaryDto | null; createdBy: UserSummaryDto | null;
/** مرحلهٔ مؤثر (ستون کانبان) */ /** مرحلهٔ مؤثر (ستون کانبان) */
stageId?: string | null; stageId?: string | null;
/** نوع مؤثر (سفارشی یا نوع اصلیِ همان type) */
typeDef?: ActivityTypeSummaryDto | null;
/** مقادیر فیلدهای اختصاصی نوع */
fields?: ActivityFieldValues;
/** کاربران شرکت‌کننده (از فیلدهای شرکت‌کنندگان) */
participantIds?: string[];
/** نام کاربران شرکت‌کننده — فقط در جزئیات فعالیت */
participantUsers?: UserSummaryDto[];
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -1276,6 +1430,9 @@ export interface CreateActivityRequest {
/** مرحلهٔ اولیه؛ وضعیت فعالیت از نوع مرحله گرفته می‌شود */ /** مرحلهٔ اولیه؛ وضعیت فعالیت از نوع مرحله گرفته می‌شود */
stageId?: string | null; stageId?: string | null;
type?: ActivityType; type?: ActivityType;
/** نوع سفارشی؛ type از نوع پایهٔ آن گرفته می‌شود */
typeDefId?: string | null;
fields?: ActivityFieldValues;
priority?: ActivityPriority; priority?: ActivityPriority;
description?: string; description?: string;
dueAt?: string | null; dueAt?: string | null;
@@ -1296,6 +1453,8 @@ export interface CreateActivityRequest {
export interface UpdateActivityRequest { export interface UpdateActivityRequest {
title?: string; title?: string;
type?: ActivityType; type?: ActivityType;
typeDefId?: string | null;
fields?: ActivityFieldValues;
status?: ActivityStatus; status?: ActivityStatus;
priority?: ActivityPriority; priority?: ActivityPriority;
description?: string | null; description?: string | null;