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:
@@ -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;
|
||||
@@ -207,6 +207,7 @@ model Organization {
|
||||
leads Lead[]
|
||||
leadStages LeadStage[]
|
||||
activityStages ActivityStage[]
|
||||
activityTypeDefs ActivityTypeDef[]
|
||||
leadEvents LeadEvent[]
|
||||
leadImportJobs LeadImportJob[]
|
||||
conversationSettings ConversationSettings?
|
||||
@@ -646,6 +647,30 @@ model ActivityStage {
|
||||
@@index([organizationId, position])
|
||||
}
|
||||
|
||||
/// نوع فعالیت قابل تعریف توسط سازمان با فیلدهای اختصاصی
|
||||
model ActivityTypeDef {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
/// نوع پایه — مبنای گزارشها، یادآورها و سازگاری
|
||||
baseType ActivityType @default(task)
|
||||
icon String @default("check")
|
||||
color String @default("green")
|
||||
/// تعریف فیلدها: [{ key, label, type, options?, required?, placeholder? }]
|
||||
fields Json @default("[]")
|
||||
position Int @default(0)
|
||||
isActive Boolean @default(true)
|
||||
/// نوع اصلیِ هر baseType — حذفشدنی نیست
|
||||
isSystem Boolean @default(false)
|
||||
activities Activity[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([organizationId, name])
|
||||
@@index([organizationId, position])
|
||||
}
|
||||
|
||||
model Activity {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
@@ -683,10 +708,18 @@ model Activity {
|
||||
/// مرحله (ستون کانبان)؛ null یا مرحلهٔ ناهمنوع با status → اولین مرحلهٔ همنوع
|
||||
stageId String?
|
||||
stage ActivityStage? @relation(fields: [stageId], references: [id], onDelete: SetNull)
|
||||
/// نوع سفارشی؛ null → نوع اصلیِ همان type
|
||||
typeDefId String?
|
||||
typeDef ActivityTypeDef? @relation(fields: [typeDefId], references: [id], onDelete: SetNull)
|
||||
/// مقادیر فیلدهای اختصاصی نوع (کلید فیلد → مقدار)
|
||||
fields Json?
|
||||
/// کاربران شرکتکننده؛ فعالیت در لیست آنها هم دیده میشود
|
||||
participantIds String[] @default([])
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([stageId])
|
||||
@@index([typeDefId])
|
||||
@@index([organizationId, status, dueAt])
|
||||
@@index([organizationId, assigneeId])
|
||||
@@index([organizationId, createdById])
|
||||
|
||||
@@ -25,15 +25,19 @@ import type { Actor } from '../common/record-scope';
|
||||
import { ActivitiesService } from './activities.service';
|
||||
import { ActivitiesExcelService } from './activities-excel.service';
|
||||
import { ActivityStagesService } from './activity-stages.service';
|
||||
import { ActivityTypesService } from './activity-types.service';
|
||||
import {
|
||||
CreateActivityDto,
|
||||
CreateActivityStageDto,
|
||||
CreateActivityTypeDto,
|
||||
ImportActivityRowsDto,
|
||||
ListActivitiesQueryDto,
|
||||
MoveActivityStageDto,
|
||||
ReorderActivityStagesDto,
|
||||
ReorderActivityTypesDto,
|
||||
UpdateActivityDto,
|
||||
UpdateActivityStageDto,
|
||||
UpdateActivityTypeDto,
|
||||
} from './dto';
|
||||
import { brand } from '@mento/shared';
|
||||
|
||||
@@ -49,6 +53,7 @@ export class ActivitiesController {
|
||||
private readonly activitiesService: ActivitiesService,
|
||||
private readonly excelService: ActivitiesExcelService,
|
||||
private readonly stagesService: ActivityStagesService,
|
||||
private readonly typesService: ActivityTypesService,
|
||||
) {}
|
||||
|
||||
@Get('summary')
|
||||
@@ -94,6 +99,44 @@ export class ActivitiesController {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Get('types')
|
||||
listTypes(@CurrentUser() user: Actor) {
|
||||
return this.typesService.list(user.organizationId);
|
||||
}
|
||||
|
||||
@Post('types')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
createType(@CurrentUser() user: Actor, @Body() dto: CreateActivityTypeDto) {
|
||||
return this.typesService.create(user.organizationId, dto);
|
||||
}
|
||||
|
||||
@Patch('types/reorder')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
reorderTypes(@CurrentUser() user: Actor, @Body() dto: ReorderActivityTypesDto) {
|
||||
return this.typesService.reorder(user.organizationId, dto.typeIds);
|
||||
}
|
||||
|
||||
@Patch('types/:id')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
updateType(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateActivityTypeDto,
|
||||
) {
|
||||
return this.typesService.update(user.organizationId, id, dto);
|
||||
}
|
||||
|
||||
@Delete('types/:id')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
async removeType(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
await this.typesService.remove(user.organizationId, id);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Get('stage-counts')
|
||||
stageCounts(
|
||||
@CurrentUser() user: Actor,
|
||||
@@ -107,6 +150,7 @@ export class ActivitiesController {
|
||||
query.desk,
|
||||
{
|
||||
type: query.type,
|
||||
typeDefId: query.typeDefId,
|
||||
status: query.status,
|
||||
assigneeId: query.assigneeId,
|
||||
},
|
||||
@@ -137,6 +181,7 @@ export class ActivitiesController {
|
||||
query.desk,
|
||||
{
|
||||
type: query.type,
|
||||
typeDefId: query.typeDefId,
|
||||
status: query.status,
|
||||
assigneeId: query.assigneeId,
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ActivitiesController } from './activities.controller';
|
||||
import { ActivitiesService } from './activities.service';
|
||||
import { ActivitiesExcelService } from './activities-excel.service';
|
||||
import { ActivityStagesService } from './activity-stages.service';
|
||||
import { ActivityTypesService } from './activity-types.service';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { AutomationsModule } from '../automations/automations.module';
|
||||
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
|
||||
@@ -10,7 +11,12 @@ import { MessageAutomationsModule } from '../message-automations/message-automat
|
||||
@Module({
|
||||
imports: [NotificationsModule, AutomationsModule, MessageAutomationsModule],
|
||||
controllers: [ActivitiesController],
|
||||
providers: [ActivitiesService, ActivitiesExcelService, ActivityStagesService],
|
||||
exports: [ActivitiesService, ActivityStagesService],
|
||||
providers: [
|
||||
ActivitiesService,
|
||||
ActivitiesExcelService,
|
||||
ActivityStagesService,
|
||||
ActivityTypesService,
|
||||
],
|
||||
exports: [ActivitiesService, ActivityStagesService, ActivityTypesService],
|
||||
})
|
||||
export class ActivitiesModule {}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
ActivityDto,
|
||||
ActivityFieldValues,
|
||||
ActivityFilter,
|
||||
ActivitySummaryDto,
|
||||
ActivityStageCountsDto,
|
||||
@@ -17,10 +18,22 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { AutomationsService } from '../automations/automations.service';
|
||||
import { MessageAutomationsService } from '../message-automations/message-automations.service';
|
||||
import { type Actor, assigneeWhere, dealAccessWhere, ownerWhere } from '../common/record-scope';
|
||||
import {
|
||||
type Actor,
|
||||
activityAccessWhere,
|
||||
dealAccessWhere,
|
||||
ownerWhere,
|
||||
} from '../common/record-scope';
|
||||
import { normalizeTags } from '../common/tags';
|
||||
import { buildSearchWhere, ci } from '../common/search';
|
||||
import { ActivityStagesService, type StageDefaults } from './activity-stages.service';
|
||||
import {
|
||||
ActivityTypesService,
|
||||
type SystemTypes,
|
||||
type TypeRow,
|
||||
} from './activity-types.service';
|
||||
|
||||
type DtoContext = { stages?: StageDefaults; types?: SystemTypes };
|
||||
|
||||
type ActivityActor = Pick<Actor, 'userId' | 'role'>;
|
||||
|
||||
@@ -29,6 +42,7 @@ type StageScope = { stageId: string };
|
||||
|
||||
type ListFacets = {
|
||||
type?: ActivityDto['type'];
|
||||
typeDefId?: string;
|
||||
status?: ActivityDto['status'];
|
||||
assigneeId?: string;
|
||||
};
|
||||
@@ -57,10 +71,14 @@ type ActivityRow = {
|
||||
assignees: Array<{ id: string; fullName: string }>;
|
||||
createdBy: { id: string; fullName: string } | null;
|
||||
stage?: { id: string; kind: ActivityDto['status'] } | null;
|
||||
typeDef?: { id: string; name: string; icon: string; color: string } | null;
|
||||
fields?: Prisma.JsonValue | null;
|
||||
participantIds?: string[];
|
||||
};
|
||||
|
||||
const include = {
|
||||
stage: { select: { id: true, kind: true } },
|
||||
typeDef: { select: { id: true, name: true, icon: true, color: true } },
|
||||
person: { select: { id: true, firstName: true, lastName: true } },
|
||||
company: { select: { id: true, name: true } },
|
||||
deal: { select: { id: true, title: true } },
|
||||
@@ -77,14 +95,23 @@ export class ActivitiesService {
|
||||
private readonly automations: AutomationsService,
|
||||
private readonly messageAutomations: MessageAutomationsService,
|
||||
private readonly stages: ActivityStagesService,
|
||||
private readonly types: ActivityTypesService,
|
||||
) {}
|
||||
|
||||
private async ctx(organizationId: string): Promise<DtoContext> {
|
||||
const [stages, types] = await Promise.all([
|
||||
this.stages.defaults(organizationId),
|
||||
this.types.system(organizationId),
|
||||
]);
|
||||
return { stages, types };
|
||||
}
|
||||
|
||||
async summary(
|
||||
organizationId: string,
|
||||
actor: ActivityActor,
|
||||
): Promise<ActivitySummaryDto> {
|
||||
const { start, end } = dayBounds();
|
||||
const scope = assigneeWhere(actor);
|
||||
const scope = activityAccessWhere(actor);
|
||||
const [todayCount, overdueCount] = await Promise.all([
|
||||
this.prisma.activity.count({
|
||||
where: {
|
||||
@@ -139,10 +166,11 @@ export class ActivitiesService {
|
||||
}),
|
||||
]);
|
||||
|
||||
const ctx = await this.ctx(organizationId);
|
||||
return {
|
||||
today: today.map((row) => this.toDto(row)),
|
||||
overdue: overdue.map((row) => this.toDto(row)),
|
||||
upcoming: upcoming.map((row) => this.toDto(row)),
|
||||
today: today.map((row) => this.toDto(row, ctx)),
|
||||
overdue: overdue.map((row) => this.toDto(row, ctx)),
|
||||
upcoming: upcoming.map((row) => this.toDto(row, ctx)),
|
||||
todayCount,
|
||||
overdueCount,
|
||||
};
|
||||
@@ -168,6 +196,7 @@ export class ActivitiesService {
|
||||
facets?: ListFacets,
|
||||
stageScope?: StageScope,
|
||||
): Promise<PaginatedResponse<ActivityDto>> {
|
||||
const typeRows = await this.types.rows(organizationId);
|
||||
const where = this.buildWhere(
|
||||
organizationId,
|
||||
actor,
|
||||
@@ -178,13 +207,17 @@ export class ActivitiesService {
|
||||
desk,
|
||||
facets,
|
||||
stageScope,
|
||||
typeRows,
|
||||
);
|
||||
if (stageScope && stageScope.stageId !== 'all') {
|
||||
(where.AND as Prisma.ActivityWhereInput[]).push(
|
||||
await this.stages.whereForStage(organizationId, stageScope.stageId),
|
||||
);
|
||||
}
|
||||
const defaults = await this.stages.defaults(organizationId);
|
||||
const ctx: DtoContext = {
|
||||
stages: await this.stages.defaults(organizationId),
|
||||
types: this.types.systemOf(typeRows),
|
||||
};
|
||||
const take = Math.min(Math.max(limit, 1), 200);
|
||||
const [rows, total] = await this.prisma.$transaction([
|
||||
this.prisma.activity.findMany({
|
||||
@@ -198,7 +231,7 @@ export class ActivitiesService {
|
||||
]);
|
||||
|
||||
return {
|
||||
items: rows.map((row) => this.toDto(row, defaults)),
|
||||
items: rows.map((row) => this.toDto(row, ctx)),
|
||||
total,
|
||||
page,
|
||||
limit: take,
|
||||
@@ -227,6 +260,7 @@ export class ActivitiesService {
|
||||
desk,
|
||||
facets,
|
||||
{ stageId: 'all' },
|
||||
await this.types.rows(organizationId),
|
||||
);
|
||||
const groups = await this.prisma.activity.groupBy({
|
||||
by: ['status', 'stageId'],
|
||||
@@ -276,7 +310,7 @@ export class ActivitiesService {
|
||||
if (stage.kind === 'done' && existing?.status !== 'done') {
|
||||
await this.fireDone(organizationId, row);
|
||||
}
|
||||
return this.toDto(row, await this.stages.defaults(organizationId));
|
||||
return this.toDto(row, await this.ctx(organizationId));
|
||||
}
|
||||
|
||||
async get(
|
||||
@@ -285,11 +319,19 @@ export class ActivitiesService {
|
||||
actor: ActivityActor,
|
||||
): Promise<ActivityDto> {
|
||||
const row = await this.prisma.activity.findFirst({
|
||||
where: { id, organizationId, ...assigneeWhere(actor) },
|
||||
where: { id, organizationId, ...activityAccessWhere(actor) },
|
||||
include,
|
||||
});
|
||||
if (!row) throw new NotFoundException('فعالیت پیدا نشد');
|
||||
return this.toDto(row, await this.stages.defaults(organizationId));
|
||||
const dto = this.toDto(row, await this.ctx(organizationId));
|
||||
if (row.participantIds.length) {
|
||||
dto.participantUsers = await this.prisma.user.findMany({
|
||||
where: { id: { in: row.participantIds } },
|
||||
select: { id: true, fullName: true },
|
||||
orderBy: { fullName: 'asc' },
|
||||
});
|
||||
}
|
||||
return dto;
|
||||
}
|
||||
|
||||
async create(
|
||||
@@ -313,6 +355,15 @@ export class ActivitiesService {
|
||||
const initialStage = initialStageId
|
||||
? await this.stages.get(organizationId, initialStageId)
|
||||
: null;
|
||||
const resolved = await this.types.resolve(organizationId, {
|
||||
typeDefId: dto.typeDefId,
|
||||
type: dto.type,
|
||||
});
|
||||
const { fields, participantIds } = await this.types.sanitizeValues(
|
||||
organizationId,
|
||||
resolved.fields,
|
||||
dto.fields,
|
||||
);
|
||||
|
||||
const row = await this.prisma.activity.create({
|
||||
data: {
|
||||
@@ -325,7 +376,10 @@ export class ActivitiesService {
|
||||
}
|
||||
: {}),
|
||||
title: dto.title.trim(),
|
||||
type: dto.type ?? 'task',
|
||||
type: resolved.baseType,
|
||||
typeDefId: resolved.typeDefId,
|
||||
fields: fields as Prisma.InputJsonValue,
|
||||
participantIds,
|
||||
priority: dto.priority ?? 'medium',
|
||||
description: emptyToNull(dto.description),
|
||||
dueAt: dto.dueAt ? new Date(dto.dueAt) : null,
|
||||
@@ -358,6 +412,11 @@ export class ActivitiesService {
|
||||
entityId: row.id,
|
||||
});
|
||||
}
|
||||
await this.notifyParticipants(
|
||||
organizationId,
|
||||
row,
|
||||
participantIds.filter((uid) => !assigneeIds.includes(uid) && uid !== userId),
|
||||
);
|
||||
|
||||
await this.messageAutomations.onEvent('activity_created', {
|
||||
organizationId,
|
||||
@@ -374,7 +433,7 @@ export class ActivitiesService {
|
||||
await this.fireDone(organizationId, row);
|
||||
}
|
||||
|
||||
return this.toDto(row, await this.stages.defaults(organizationId));
|
||||
return this.toDto(row, await this.ctx(organizationId));
|
||||
}
|
||||
|
||||
async update(
|
||||
@@ -386,9 +445,33 @@ export class ActivitiesService {
|
||||
await this.ensureExists(organizationId, id, actor);
|
||||
await this.assertLinks(organizationId, actor, dto);
|
||||
|
||||
const existing = await this.prisma.activity.findFirst({
|
||||
where: { id, organizationId },
|
||||
select: { status: true, type: true, typeDefId: true, fields: true, participantIds: true },
|
||||
});
|
||||
const data: Prisma.ActivityUncheckedUpdateInput = {};
|
||||
if (dto.title !== undefined) data.title = dto.title.trim();
|
||||
if (dto.type !== undefined) data.type = dto.type;
|
||||
let addedParticipants: string[] = [];
|
||||
const typeChanged = 'typeDefId' in dto || dto.type !== undefined;
|
||||
if (typeChanged || dto.fields !== undefined) {
|
||||
const resolved = await this.types.resolve(
|
||||
organizationId,
|
||||
typeChanged
|
||||
? { typeDefId: dto.typeDefId, type: dto.type }
|
||||
: { typeDefId: existing?.typeDefId, type: existing?.type },
|
||||
);
|
||||
const { fields, participantIds } = await this.types.sanitizeValues(
|
||||
organizationId,
|
||||
resolved.fields,
|
||||
dto.fields ?? existing?.fields,
|
||||
);
|
||||
data.type = resolved.baseType;
|
||||
data.typeDefId = resolved.typeDefId;
|
||||
data.fields = fields as Prisma.InputJsonValue;
|
||||
data.participantIds = participantIds;
|
||||
const before = new Set(existing?.participantIds ?? []);
|
||||
addedParticipants = participantIds.filter((uid) => !before.has(uid) && uid !== actor.userId);
|
||||
}
|
||||
if (dto.priority !== undefined) data.priority = dto.priority;
|
||||
if ('description' in dto) data.description = emptyToNull(dto.description);
|
||||
if ('dueAt' in dto) data.dueAt = dto.dueAt ? new Date(dto.dueAt) : null;
|
||||
@@ -434,10 +517,6 @@ export class ActivitiesService {
|
||||
dto.status === 'done' ? new Date() : dto.status === 'open' ? null : undefined;
|
||||
}
|
||||
|
||||
const existing = await this.prisma.activity.findFirst({
|
||||
where: { id, organizationId },
|
||||
select: { status: true },
|
||||
});
|
||||
const row = await this.prisma.activity.update({
|
||||
where: { id },
|
||||
data: {
|
||||
@@ -448,10 +527,21 @@ export class ActivitiesService {
|
||||
},
|
||||
include,
|
||||
});
|
||||
if (addedParticipants.length) {
|
||||
const assigneeSet = new Set([
|
||||
...(row.assignee ? [row.assignee.id] : []),
|
||||
...row.assignees.map((a) => a.id),
|
||||
]);
|
||||
await this.notifyParticipants(
|
||||
organizationId,
|
||||
row,
|
||||
addedParticipants.filter((uid) => !assigneeSet.has(uid)),
|
||||
);
|
||||
}
|
||||
if (dto.status === 'done' && existing?.status !== 'done') {
|
||||
await this.fireDone(organizationId, row);
|
||||
}
|
||||
return this.toDto(row, await this.stages.defaults(organizationId));
|
||||
return this.toDto(row, await this.ctx(organizationId));
|
||||
}
|
||||
|
||||
async complete(
|
||||
@@ -472,7 +562,7 @@ export class ActivitiesService {
|
||||
if (existing?.status !== 'done') {
|
||||
await this.fireDone(organizationId, row);
|
||||
}
|
||||
return this.toDto(row, await this.stages.defaults(organizationId));
|
||||
return this.toDto(row, await this.ctx(organizationId));
|
||||
}
|
||||
|
||||
async remove(
|
||||
@@ -501,6 +591,7 @@ export class ActivitiesService {
|
||||
desk?: 'mine' | 'team',
|
||||
facets?: ListFacets,
|
||||
stageScope?: StageScope,
|
||||
typeRows: TypeRow[] = [],
|
||||
): Prisma.ActivityWhereInput {
|
||||
const { start, end } = dayBounds();
|
||||
const scope: Prisma.ActivityWhereInput =
|
||||
@@ -509,13 +600,16 @@ export class ActivitiesService {
|
||||
OR: [
|
||||
{ assigneeId: actor.userId },
|
||||
{ assignees: { some: { id: actor.userId } } },
|
||||
{ participantIds: { has: actor.userId } },
|
||||
],
|
||||
}
|
||||
: assigneeWhere(actor);
|
||||
: activityAccessWhere(actor);
|
||||
const facetAssignee = facets?.assigneeId?.trim();
|
||||
const facetTypeDef = facets?.typeDefId?.trim();
|
||||
const andParts: Prisma.ActivityWhereInput[] = [
|
||||
scope,
|
||||
...(facets?.type ? [{ type: facets.type }] : []),
|
||||
...(facetTypeDef ? [this.types.whereForType(typeRows, facetTypeDef)] : []),
|
||||
...(facets?.status ? [{ status: facets.status }] : []),
|
||||
...(facetAssignee
|
||||
? [
|
||||
@@ -547,6 +641,10 @@ export class ActivitiesService {
|
||||
},
|
||||
{ company: { name: ci(t) } },
|
||||
{ deal: { title: ci(t) } },
|
||||
{ assignee: { fullName: ci(t) } },
|
||||
{ assignees: { some: { fullName: ci(t) } } },
|
||||
...typesMatching(t).map((type) => ({ type })),
|
||||
...this.types.searchByName(typeRows, t),
|
||||
]);
|
||||
if (search) andParts.push(search);
|
||||
const base: Prisma.ActivityWhereInput = {
|
||||
@@ -633,7 +731,7 @@ export class ActivitiesService {
|
||||
actor: ActivityActor,
|
||||
) {
|
||||
const found = await this.prisma.activity.findFirst({
|
||||
where: { id, organizationId, ...assigneeWhere(actor) },
|
||||
where: { id, organizationId, ...activityAccessWhere(actor) },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!found) throw new NotFoundException('فعالیت پیدا نشد');
|
||||
@@ -684,9 +782,37 @@ export class ActivitiesService {
|
||||
}
|
||||
}
|
||||
|
||||
private toDto(row: ActivityRow, defaults?: StageDefaults): ActivityDto {
|
||||
private async notifyParticipants(
|
||||
organizationId: string,
|
||||
row: { id: string; title: string },
|
||||
userIds: string[],
|
||||
) {
|
||||
for (const uid of new Set(userIds)) {
|
||||
await this.notifications.create({
|
||||
organizationId,
|
||||
userId: uid,
|
||||
type: 'activity_assigned',
|
||||
title: 'شما بهعنوان شرکتکننده اضافه شدید',
|
||||
body: row.title,
|
||||
link: `/activities/${row.id}`,
|
||||
entityType: 'activity',
|
||||
entityId: row.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private toDto(row: ActivityRow, ctx?: DtoContext): ActivityDto {
|
||||
const fields =
|
||||
row.fields && typeof row.fields === 'object' && !Array.isArray(row.fields)
|
||||
? (row.fields as unknown as ActivityFieldValues)
|
||||
: {};
|
||||
return {
|
||||
stageId: this.stages.effectiveStageId(row, defaults),
|
||||
stageId: this.stages.effectiveStageId(row, ctx?.stages),
|
||||
typeDef: row.typeDef
|
||||
? this.types.toSummary(row.typeDef)
|
||||
: (ctx?.types?.[row.type] ?? null),
|
||||
fields,
|
||||
participantIds: row.participantIds ?? [],
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
status: row.status,
|
||||
@@ -741,6 +867,22 @@ export class ActivitiesService {
|
||||
}
|
||||
}
|
||||
|
||||
const TYPE_SEARCH_TERMS: Record<ActivityDto['type'], string[]> = {
|
||||
call: ['تماس', 'تلفن', 'call'],
|
||||
meeting: ['جلسه', 'ملاقات', 'meeting'],
|
||||
task: ['وظیفه', 'تسک', 'task'],
|
||||
note: ['پیام', 'یادداشت', 'note'],
|
||||
};
|
||||
|
||||
/** جستجوی «جلسه» یا «تماس» → فیلتر نوع فعالیت */
|
||||
function typesMatching(term: string): ActivityDto['type'][] {
|
||||
const t = term.trim().toLowerCase();
|
||||
if (t.length < 2) return [];
|
||||
return (Object.keys(TYPE_SEARCH_TERMS) as ActivityDto['type'][]).filter((type) =>
|
||||
TYPE_SEARCH_TERMS[type].some((label) => label.includes(t) || t.includes(label)),
|
||||
);
|
||||
}
|
||||
|
||||
function emptyToNull(value?: string | null): string | null {
|
||||
if (value == null) return null;
|
||||
const trimmed = value.trim();
|
||||
|
||||
@@ -0,0 +1,649 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import type {
|
||||
ActivityChecklistItem,
|
||||
ActivityFieldDef,
|
||||
ActivityFieldType,
|
||||
ActivityFieldValue,
|
||||
ActivityFieldValues,
|
||||
ActivityParticipantsValue,
|
||||
ActivityStageColor,
|
||||
ActivityType,
|
||||
ActivityTypeDefDto,
|
||||
ActivityTypeIcon,
|
||||
ActivityTypeSummaryDto,
|
||||
CreateActivityTypeRequest,
|
||||
UpdateActivityTypeRequest,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
ACTIVITY_FIELD_TYPES,
|
||||
ACTIVITY_FIELD_TYPES_WITH_OPTIONS,
|
||||
ACTIVITY_STAGE_COLORS,
|
||||
ACTIVITY_TYPE_ICONS,
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const BASE_TYPES: ActivityType[] = ['call', 'meeting', 'task', 'note'];
|
||||
const MAX_FIELDS = 30;
|
||||
const MAX_OPTIONS = 50;
|
||||
const MAX_CHECKLIST = 100;
|
||||
const MAX_PARTICIPANTS = 100;
|
||||
|
||||
type Seed = {
|
||||
name: string;
|
||||
baseType: ActivityType;
|
||||
icon: ActivityTypeIcon;
|
||||
color: ActivityStageColor;
|
||||
isSystem?: boolean;
|
||||
fields: ActivityFieldDef[];
|
||||
};
|
||||
|
||||
const DEFAULT_TYPES: Seed[] = [
|
||||
{
|
||||
name: 'تماس',
|
||||
baseType: 'call',
|
||||
icon: 'phone',
|
||||
color: 'green',
|
||||
isSystem: true,
|
||||
fields: [
|
||||
{ key: 'call_direction', label: 'جهت تماس', type: 'select', options: ['خروجی', 'ورودی'] },
|
||||
{
|
||||
key: 'call_result',
|
||||
label: 'نتیجه تماس',
|
||||
type: 'select',
|
||||
options: [
|
||||
'پاسخ داد',
|
||||
'پاسخ نداد',
|
||||
'مشغول بود',
|
||||
'خاموش / در دسترس نبود',
|
||||
'شماره اشتباه',
|
||||
'نیاز به پیگیری مجدد',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'جلسه',
|
||||
baseType: 'meeting',
|
||||
icon: 'users',
|
||||
color: 'purple',
|
||||
isSystem: true,
|
||||
fields: [
|
||||
{
|
||||
key: 'meeting_subject',
|
||||
label: 'موضوع جلسه',
|
||||
type: 'text',
|
||||
placeholder: 'مثلاً: بررسی پیشفاکتور و شرایط پرداخت',
|
||||
},
|
||||
{ key: 'meeting_participants', label: 'شرکتکنندگان', type: 'participants' },
|
||||
{
|
||||
key: 'meeting_location',
|
||||
label: 'محل برگزاری / لینک جلسه',
|
||||
type: 'text',
|
||||
placeholder: 'آدرس یا لینک جلسهٔ آنلاین',
|
||||
},
|
||||
{ key: 'meeting_minutes', label: 'صورتجلسه', type: 'textarea' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'وظیفه',
|
||||
baseType: 'task',
|
||||
icon: 'check',
|
||||
color: 'orange',
|
||||
isSystem: true,
|
||||
fields: [{ key: 'task_checklist', label: 'لیست کارها', type: 'checklist' }],
|
||||
},
|
||||
{
|
||||
name: 'پیام',
|
||||
baseType: 'note',
|
||||
icon: 'chat',
|
||||
color: 'blue',
|
||||
isSystem: true,
|
||||
fields: [
|
||||
{
|
||||
key: 'message_channel',
|
||||
label: 'کانال ارسال',
|
||||
type: 'select',
|
||||
options: ['پیامک', 'واتساپ', 'تلگرام', 'ایتا', 'بله', 'ایمیل', 'دایرکت اینستاگرام'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'بازدید حضوری',
|
||||
baseType: 'meeting',
|
||||
icon: 'pin',
|
||||
color: 'rose',
|
||||
fields: [
|
||||
{ key: 'visit_address', label: 'آدرس محل بازدید', type: 'text' },
|
||||
{ key: 'visit_participants', label: 'همراهان بازدید', type: 'participants' },
|
||||
{ key: 'visit_result', label: 'نتیجهٔ بازدید', type: 'textarea' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'ارائه / دمو محصول',
|
||||
baseType: 'meeting',
|
||||
icon: 'star',
|
||||
color: 'purple',
|
||||
fields: [
|
||||
{ key: 'demo_product', label: 'محصول / خدمت', type: 'text' },
|
||||
{ key: 'demo_participants', label: 'شرکتکنندگان', type: 'participants' },
|
||||
{ key: 'demo_link', label: 'لینک جلسه', type: 'url' },
|
||||
{
|
||||
key: 'demo_result',
|
||||
label: 'نتیجهٔ دمو',
|
||||
type: 'select',
|
||||
options: ['علاقهمند', 'نیاز به بررسی بیشتر', 'درخواست پیشفاکتور', 'عدم علاقه'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'پیگیری',
|
||||
baseType: 'call',
|
||||
icon: 'bell',
|
||||
color: 'orange',
|
||||
fields: [
|
||||
{
|
||||
key: 'followup_reason',
|
||||
label: 'دلیل پیگیری',
|
||||
type: 'select',
|
||||
options: ['پیشفاکتور', 'پرداخت', 'ارسال کالا', 'پشتیبانی', 'تمدید قرارداد', 'سایر'],
|
||||
},
|
||||
{ key: 'followup_result', label: 'نتیجهٔ پیگیری', type: 'textarea' },
|
||||
{ key: 'followup_next', label: 'تاریخ پیگیری بعدی', type: 'date' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'ایمیل',
|
||||
baseType: 'note',
|
||||
icon: 'mail',
|
||||
color: 'slate',
|
||||
fields: [
|
||||
{ key: 'email_subject', label: 'موضوع ایمیل', type: 'text' },
|
||||
{ key: 'email_to', label: 'گیرنده', type: 'text', placeholder: 'example@domain.com' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export type TypeRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
baseType: ActivityType;
|
||||
icon: string;
|
||||
color: string;
|
||||
fields: Prisma.JsonValue;
|
||||
position: number;
|
||||
isActive: boolean;
|
||||
isSystem: boolean;
|
||||
};
|
||||
|
||||
/** نوع اصلی هر baseType — مقصد فعالیتهای بدون typeDefId */
|
||||
export type SystemTypes = Record<ActivityType, ActivityTypeSummaryDto | null>;
|
||||
|
||||
export type ResolvedType = {
|
||||
/** مقداری که در Activity.typeDefId ذخیره میشود (نوع اصلی → null) */
|
||||
typeDefId: string | null;
|
||||
baseType: ActivityType;
|
||||
fields: ActivityFieldDef[];
|
||||
};
|
||||
|
||||
const select = {
|
||||
id: true,
|
||||
name: true,
|
||||
baseType: true,
|
||||
icon: true,
|
||||
color: true,
|
||||
fields: true,
|
||||
position: true,
|
||||
isActive: true,
|
||||
isSystem: true,
|
||||
} satisfies Prisma.ActivityTypeDefSelect;
|
||||
|
||||
@Injectable()
|
||||
export class ActivityTypesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async ensureDefaults(organizationId: string): Promise<void> {
|
||||
const count = await this.prisma.activityTypeDef.count({ where: { organizationId } });
|
||||
if (count > 0) return;
|
||||
await this.prisma.activityTypeDef.createMany({
|
||||
data: DEFAULT_TYPES.map((t, i) => ({
|
||||
organizationId,
|
||||
name: t.name,
|
||||
baseType: t.baseType,
|
||||
icon: t.icon,
|
||||
color: t.color,
|
||||
isSystem: Boolean(t.isSystem),
|
||||
fields: t.fields as unknown as Prisma.InputJsonValue,
|
||||
position: i,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
async rows(organizationId: string): Promise<TypeRow[]> {
|
||||
await this.ensureDefaults(organizationId);
|
||||
return this.prisma.activityTypeDef.findMany({
|
||||
where: { organizationId },
|
||||
orderBy: [{ position: 'asc' }, { createdAt: 'asc' }],
|
||||
select,
|
||||
});
|
||||
}
|
||||
|
||||
async list(organizationId: string): Promise<ActivityTypeDefDto[]> {
|
||||
return (await this.rows(organizationId)).map((r) => this.toDto(r));
|
||||
}
|
||||
|
||||
systemOf(rows: TypeRow[]): SystemTypes {
|
||||
const out: SystemTypes = { call: null, meeting: null, task: null, note: null };
|
||||
for (const r of rows) {
|
||||
if (r.isSystem && !out[r.baseType]) out[r.baseType] = this.toSummary(r);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async system(organizationId: string): Promise<SystemTypes> {
|
||||
return this.systemOf(await this.rows(organizationId));
|
||||
}
|
||||
|
||||
/** فیلتر Prisma برای فعالیتهایی که «مؤثراً» از این نوع هستند */
|
||||
whereForType(rows: TypeRow[], typeDefId: string): Prisma.ActivityWhereInput {
|
||||
const def = rows.find((r) => r.id === typeDefId);
|
||||
if (!def) return { id: '__none__' };
|
||||
if (!def.isSystem) return { typeDefId: def.id };
|
||||
return { OR: [{ typeDefId: def.id }, { typeDefId: null, type: def.baseType }] };
|
||||
}
|
||||
|
||||
/** جستجوی نام نوع → شرطهای OR */
|
||||
searchByName(rows: TypeRow[], term: string): Prisma.ActivityWhereInput[] {
|
||||
const t = term.trim();
|
||||
if (t.length < 2) return [];
|
||||
return rows
|
||||
.filter((r) => r.name.includes(t))
|
||||
.map((r) => this.whereForType(rows, r.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* نوع مؤثر برای ذخیره: typeDefId صریح، یا نوع اصلیِ type.
|
||||
* نوع اصلی با typeDefId=null ذخیره میشود تا با دادههای قدیمی یکسان باشد.
|
||||
*/
|
||||
async resolve(
|
||||
organizationId: string,
|
||||
input: { typeDefId?: string | null; type?: ActivityType | null },
|
||||
): Promise<ResolvedType> {
|
||||
const rows = await this.rows(organizationId);
|
||||
const requested = input.typeDefId?.trim();
|
||||
if (requested) {
|
||||
const def = rows.find((r) => r.id === requested);
|
||||
if (!def) throw new BadRequestException('نوع فعالیت انتخابشده معتبر نیست');
|
||||
return {
|
||||
typeDefId: def.isSystem ? null : def.id,
|
||||
baseType: def.baseType,
|
||||
fields: this.parseFields(def.fields),
|
||||
};
|
||||
}
|
||||
const baseType = input.type && BASE_TYPES.includes(input.type) ? input.type : 'task';
|
||||
const sys = rows.find((r) => r.isSystem && r.baseType === baseType);
|
||||
return { typeDefId: null, baseType, fields: sys ? this.parseFields(sys.fields) : [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* پاکسازی مقادیر بر اساس تعریف فیلدها؛ فقط کلیدهای همین نوع نگه داشته میشوند.
|
||||
* participantIds = کاربران معتبر همهٔ فیلدهای شرکتکنندگان.
|
||||
*/
|
||||
async sanitizeValues(
|
||||
organizationId: string,
|
||||
defs: ActivityFieldDef[],
|
||||
raw: unknown,
|
||||
): Promise<{ fields: ActivityFieldValues; participantIds: string[] }> {
|
||||
const input =
|
||||
raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {};
|
||||
const out: ActivityFieldValues = {};
|
||||
const participantFields: Array<{ key: string; value: ActivityParticipantsValue }> = [];
|
||||
|
||||
for (const def of defs) {
|
||||
const value = this.cleanValue(def, input[def.key]);
|
||||
if (value === null) {
|
||||
if (def.required && def.type !== 'checkbox') {
|
||||
throw new BadRequestException(`فیلد «${def.label}» را تکمیل کنید`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (def.type === 'participants') {
|
||||
participantFields.push({ key: def.key, value: value as ActivityParticipantsValue });
|
||||
}
|
||||
out[def.key] = value;
|
||||
}
|
||||
|
||||
const userIds = [...new Set(participantFields.flatMap((p) => p.value.userIds))];
|
||||
const personIds = [...new Set(participantFields.flatMap((p) => p.value.people.map((x) => x.id)))];
|
||||
const [validUsers, validPeople] = await Promise.all([
|
||||
userIds.length
|
||||
? this.prisma.user.findMany({
|
||||
where: { id: { in: userIds }, memberships: { some: { organizationId } } },
|
||||
select: { id: true },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
personIds.length
|
||||
? this.prisma.person.findMany({
|
||||
where: { id: { in: personIds }, organizationId },
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
const userSet = new Set(validUsers.map((u) => u.id));
|
||||
const personNames = new Map(
|
||||
validPeople.map((p) => [p.id, `${p.firstName} ${p.lastName}`.trim()]),
|
||||
);
|
||||
for (const p of participantFields) {
|
||||
const next: ActivityParticipantsValue = {
|
||||
userIds: p.value.userIds.filter((id) => userSet.has(id)),
|
||||
people: p.value.people
|
||||
.filter((x) => personNames.has(x.id))
|
||||
.map((x) => ({ id: x.id, name: personNames.get(x.id) || x.name })),
|
||||
guests: p.value.guests,
|
||||
};
|
||||
const def = defs.find((d) => d.key === p.key)!;
|
||||
if (!next.userIds.length && !next.people.length && !next.guests.length) {
|
||||
if (def.required) throw new BadRequestException(`فیلد «${def.label}» را تکمیل کنید`);
|
||||
delete out[p.key];
|
||||
} else {
|
||||
out[p.key] = next;
|
||||
}
|
||||
}
|
||||
|
||||
const participantIds = [
|
||||
...new Set(
|
||||
Object.values(out)
|
||||
.filter((v): v is ActivityParticipantsValue => isParticipants(v))
|
||||
.flatMap((v) => v.userIds),
|
||||
),
|
||||
];
|
||||
return { fields: out, participantIds };
|
||||
}
|
||||
|
||||
async create(
|
||||
organizationId: string,
|
||||
dto: CreateActivityTypeRequest,
|
||||
): Promise<ActivityTypeDefDto> {
|
||||
await this.ensureDefaults(organizationId);
|
||||
const max = await this.prisma.activityTypeDef.aggregate({
|
||||
where: { organizationId },
|
||||
_max: { position: true },
|
||||
});
|
||||
try {
|
||||
const row = await this.prisma.activityTypeDef.create({
|
||||
data: {
|
||||
organizationId,
|
||||
name: this.cleanName(dto.name),
|
||||
baseType: this.cleanBase(dto.baseType),
|
||||
icon: this.cleanIcon(dto.icon),
|
||||
color: this.cleanColor(dto.color),
|
||||
fields: this.cleanFieldDefs(dto.fields ?? []) as unknown as Prisma.InputJsonValue,
|
||||
position: (max._max.position ?? -1) + 1,
|
||||
},
|
||||
select,
|
||||
});
|
||||
return this.toDto(row);
|
||||
} catch (e) {
|
||||
throw this.mapError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async update(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
dto: UpdateActivityTypeRequest,
|
||||
): Promise<ActivityTypeDefDto> {
|
||||
const current = await this.get(organizationId, id);
|
||||
if (current.isSystem && dto.baseType && dto.baseType !== current.baseType) {
|
||||
throw new BadRequestException('نوع پایهٔ انواع اصلی قابل تغییر نیست');
|
||||
}
|
||||
if (current.isSystem && dto.isActive === false) {
|
||||
throw new BadRequestException('انواع اصلی را نمیتوان غیرفعال کرد');
|
||||
}
|
||||
const nextBase = dto.baseType ? this.cleanBase(dto.baseType) : current.baseType;
|
||||
try {
|
||||
const row = await this.prisma.$transaction(async (tx) => {
|
||||
if (nextBase !== current.baseType) {
|
||||
await tx.activity.updateMany({
|
||||
where: { organizationId, typeDefId: id },
|
||||
data: { type: nextBase },
|
||||
});
|
||||
}
|
||||
return tx.activityTypeDef.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.name != null ? { name: this.cleanName(dto.name) } : {}),
|
||||
...(dto.icon != null ? { icon: this.cleanIcon(dto.icon) } : {}),
|
||||
...(dto.color != null ? { color: this.cleanColor(dto.color) } : {}),
|
||||
...(dto.fields != null
|
||||
? { fields: this.cleanFieldDefs(dto.fields) as unknown as Prisma.InputJsonValue }
|
||||
: {}),
|
||||
...(dto.isActive != null ? { isActive: dto.isActive } : {}),
|
||||
baseType: nextBase,
|
||||
},
|
||||
select,
|
||||
});
|
||||
});
|
||||
return this.toDto(row);
|
||||
} catch (e) {
|
||||
throw this.mapError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async reorder(organizationId: string, typeIds: string[]): Promise<ActivityTypeDefDto[]> {
|
||||
await this.prisma.$transaction(
|
||||
typeIds.map((id, position) =>
|
||||
this.prisma.activityTypeDef.updateMany({
|
||||
where: { id, organizationId },
|
||||
data: { position },
|
||||
}),
|
||||
),
|
||||
);
|
||||
return this.list(organizationId);
|
||||
}
|
||||
|
||||
async remove(organizationId: string, id: string): Promise<void> {
|
||||
const def = await this.get(organizationId, id);
|
||||
if (def.isSystem) throw new BadRequestException('انواع اصلی قابل حذف نیستند');
|
||||
// onDelete: SetNull → فعالیتها به نوع اصلیِ نوع پایهٔ خود برمیگردند
|
||||
await this.prisma.activityTypeDef.delete({ where: { id } });
|
||||
}
|
||||
|
||||
toSummary(row: Pick<TypeRow, 'id' | 'name' | 'icon' | 'color'>): ActivityTypeSummaryDto {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
icon: this.cleanIcon(row.icon),
|
||||
color: this.cleanColor(row.color),
|
||||
};
|
||||
}
|
||||
|
||||
parseFields(raw: Prisma.JsonValue): ActivityFieldDef[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.filter(
|
||||
(f): f is ActivityFieldDef & Prisma.JsonObject =>
|
||||
Boolean(f) && typeof f === 'object' && !Array.isArray(f) && typeof (f as { key?: unknown }).key === 'string',
|
||||
) as ActivityFieldDef[];
|
||||
}
|
||||
|
||||
private async get(organizationId: string, id: string): Promise<TypeRow> {
|
||||
const row = await this.prisma.activityTypeDef.findFirst({
|
||||
where: { id, organizationId },
|
||||
select,
|
||||
});
|
||||
if (!row) throw new NotFoundException('نوع فعالیت پیدا نشد');
|
||||
return row;
|
||||
}
|
||||
|
||||
private cleanValue(def: ActivityFieldDef, raw: unknown): ActivityFieldValue {
|
||||
switch (def.type) {
|
||||
case 'text':
|
||||
return cleanText(raw, 500);
|
||||
case 'textarea':
|
||||
return cleanText(raw, 5000);
|
||||
case 'url':
|
||||
return cleanText(raw, 1000);
|
||||
case 'number': {
|
||||
if (raw === '' || raw == null) return null;
|
||||
const n = typeof raw === 'number' ? raw : Number(String(raw).replace(/,/g, ''));
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
case 'date': {
|
||||
const s = cleanText(raw, 40);
|
||||
return s && /^\d{4}-\d{2}-\d{2}/.test(s) && !Number.isNaN(new Date(s).getTime())
|
||||
? s
|
||||
: null;
|
||||
}
|
||||
case 'select': {
|
||||
const s = cleanText(raw, 200);
|
||||
return s && (def.options ?? []).includes(s) ? s : null;
|
||||
}
|
||||
case 'multiselect': {
|
||||
if (!Array.isArray(raw)) return null;
|
||||
const opts = new Set(def.options ?? []);
|
||||
const list = [...new Set(raw.filter((v): v is string => typeof v === 'string' && opts.has(v)))];
|
||||
return list.length ? list : null;
|
||||
}
|
||||
case 'checkbox':
|
||||
return raw === true ? true : raw === false ? false : null;
|
||||
case 'checklist': {
|
||||
if (!Array.isArray(raw)) return null;
|
||||
const items: ActivityChecklistItem[] = [];
|
||||
for (const it of raw.slice(0, MAX_CHECKLIST)) {
|
||||
if (!it || typeof it !== 'object') continue;
|
||||
const o = it as Record<string, unknown>;
|
||||
const text = cleanText(o.text, 300);
|
||||
if (!text) continue;
|
||||
const id = typeof o.id === 'string' && o.id.trim() ? o.id.trim().slice(0, 64) : randomUUID();
|
||||
items.push({ id, text, done: o.done === true });
|
||||
}
|
||||
return items.length ? items : null;
|
||||
}
|
||||
case 'participants': {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
const o = raw as Record<string, unknown>;
|
||||
const userIds = Array.isArray(o.userIds)
|
||||
? [...new Set(o.userIds.filter((v): v is string => typeof v === 'string' && Boolean(v.trim())))]
|
||||
: [];
|
||||
const people = Array.isArray(o.people)
|
||||
? o.people
|
||||
.filter(
|
||||
(p): p is { id: string; name?: unknown } =>
|
||||
Boolean(p) && typeof p === 'object' && typeof (p as { id?: unknown }).id === 'string',
|
||||
)
|
||||
.map((p) => ({ id: p.id, name: cleanText(p.name, 200) ?? '' }))
|
||||
: [];
|
||||
const guests = Array.isArray(o.guests)
|
||||
? [
|
||||
...new Set(
|
||||
o.guests
|
||||
.map((g) => cleanText(g, 120))
|
||||
.filter((g): g is string => Boolean(g)),
|
||||
),
|
||||
]
|
||||
: [];
|
||||
if (!userIds.length && !people.length && !guests.length) return null;
|
||||
return {
|
||||
userIds: userIds.slice(0, MAX_PARTICIPANTS),
|
||||
people: people.slice(0, MAX_PARTICIPANTS),
|
||||
guests: guests.slice(0, MAX_PARTICIPANTS),
|
||||
};
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private cleanFieldDefs(raw: ActivityFieldDef[]): ActivityFieldDef[] {
|
||||
if (!Array.isArray(raw)) throw new BadRequestException('تعریف فیلدها نامعتبر است');
|
||||
if (raw.length > MAX_FIELDS) {
|
||||
throw new BadRequestException(`حداکثر ${MAX_FIELDS} فیلد برای هر نوع مجاز است`);
|
||||
}
|
||||
const keys = new Set<string>();
|
||||
return raw.map((f, i) => {
|
||||
const label = typeof f?.label === 'string' ? f.label.trim() : '';
|
||||
if (!label) throw new BadRequestException(`عنوان فیلد ${i + 1} لازم است`);
|
||||
if (label.length > 80) throw new BadRequestException('عنوان فیلد حداکثر ۸۰ کاراکتر است');
|
||||
const type = ACTIVITY_FIELD_TYPES.includes(f.type as ActivityFieldType)
|
||||
? (f.type as ActivityFieldType)
|
||||
: null;
|
||||
if (!type) throw new BadRequestException(`نوع فیلد «${label}» معتبر نیست`);
|
||||
let key = typeof f.key === 'string' ? f.key.trim().toLowerCase() : '';
|
||||
if (!/^[a-z][a-z0-9_]{0,39}$/.test(key)) key = `f_${randomUUID().slice(0, 8)}`;
|
||||
if (keys.has(key)) throw new BadRequestException(`کلید فیلد «${label}» تکراری است`);
|
||||
keys.add(key);
|
||||
const def: ActivityFieldDef = { key, label, type };
|
||||
if (ACTIVITY_FIELD_TYPES_WITH_OPTIONS.includes(type)) {
|
||||
const options = [
|
||||
...new Set(
|
||||
(Array.isArray(f.options) ? f.options : [])
|
||||
.map((o) => (typeof o === 'string' ? o.trim() : ''))
|
||||
.filter(Boolean)
|
||||
.map((o) => o.slice(0, 200)),
|
||||
),
|
||||
].slice(0, MAX_OPTIONS);
|
||||
if (!options.length) {
|
||||
throw new BadRequestException(`برای فیلد «${label}» حداقل یک گزینه تعریف کنید`);
|
||||
}
|
||||
def.options = options;
|
||||
}
|
||||
if (f.required) def.required = true;
|
||||
const placeholder = typeof f.placeholder === 'string' ? f.placeholder.trim().slice(0, 200) : '';
|
||||
if (placeholder) def.placeholder = placeholder;
|
||||
return def;
|
||||
});
|
||||
}
|
||||
|
||||
private cleanName(raw: string): string {
|
||||
const name = raw?.trim() ?? '';
|
||||
if (!name) throw new BadRequestException('نام نوع فعالیت لازم است');
|
||||
if (name.length > 60) throw new BadRequestException('نام نوع فعالیت حداکثر ۶۰ کاراکتر است');
|
||||
return name;
|
||||
}
|
||||
|
||||
private cleanBase(raw?: ActivityType): ActivityType {
|
||||
return raw && BASE_TYPES.includes(raw) ? raw : 'task';
|
||||
}
|
||||
|
||||
private cleanIcon(raw?: string): ActivityTypeIcon {
|
||||
return (ACTIVITY_TYPE_ICONS as readonly string[]).includes(raw ?? '')
|
||||
? (raw as ActivityTypeIcon)
|
||||
: 'check';
|
||||
}
|
||||
|
||||
private cleanColor(raw?: string): ActivityStageColor {
|
||||
return ACTIVITY_STAGE_COLORS.includes(raw as ActivityStageColor)
|
||||
? (raw as ActivityStageColor)
|
||||
: 'green';
|
||||
}
|
||||
|
||||
private mapError(e: unknown) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
return new BadRequestException('نوع فعالیتی با این نام وجود دارد');
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
private toDto(row: TypeRow): ActivityTypeDefDto {
|
||||
return {
|
||||
...this.toSummary(row),
|
||||
baseType: row.baseType,
|
||||
fields: this.parseFields(row.fields),
|
||||
position: row.position,
|
||||
isActive: row.isActive,
|
||||
isSystem: row.isSystem,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function cleanText(raw: unknown, max: number): string | null {
|
||||
if (typeof raw !== 'string' && typeof raw !== 'number') return null;
|
||||
const s = String(raw).trim();
|
||||
return s ? s.slice(0, max) : null;
|
||||
}
|
||||
|
||||
function isParticipants(v: unknown): v is ActivityParticipantsValue {
|
||||
return Boolean(v) && typeof v === 'object' && !Array.isArray(v) && Array.isArray((v as ActivityParticipantsValue).userIds);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
IsDateString,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
@@ -14,6 +15,8 @@ import {
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
import type { ActivityFieldDef, ActivityFieldValues, ActivityTypeIcon } from '@mento/shared';
|
||||
import { ACTIVITY_TYPE_ICONS } from '@mento/shared';
|
||||
|
||||
/** تکههای JSON از ایمپورت مرورگرگرا */
|
||||
export class ImportActivityRowsDto {
|
||||
@@ -66,6 +69,11 @@ export class ListActivitiesQueryDto {
|
||||
@IsString()
|
||||
assigneeId?: string;
|
||||
|
||||
/** نوع فعالیت (تعریفشده در سازمان) */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
typeDefId?: string;
|
||||
|
||||
/** شناسهٔ مرحله یا 'all' — حالت تب/کانبان مراحل */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -151,6 +159,66 @@ export class MoveActivityStageDto {
|
||||
stageId!: string;
|
||||
}
|
||||
|
||||
/** محتوای fields در سرویس بر اساس تعریف نوع اعتبارسنجی میشود */
|
||||
export class CreateActivityTypeDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(60)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(ACTIVITY_TYPES)
|
||||
baseType?: (typeof ACTIVITY_TYPES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(ACTIVITY_TYPE_ICONS)
|
||||
icon?: ActivityTypeIcon;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(STAGE_COLORS)
|
||||
color?: (typeof STAGE_COLORS)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@Allow()
|
||||
fields?: ActivityFieldDef[];
|
||||
}
|
||||
|
||||
export class UpdateActivityTypeDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(60)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(ACTIVITY_TYPES)
|
||||
baseType?: (typeof ACTIVITY_TYPES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(ACTIVITY_TYPE_ICONS)
|
||||
icon?: ActivityTypeIcon;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(STAGE_COLORS)
|
||||
color?: (typeof STAGE_COLORS)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@Allow()
|
||||
fields?: ActivityFieldDef[];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class ReorderActivityTypesDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
typeIds!: string[];
|
||||
}
|
||||
|
||||
export class CreateActivityDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@@ -166,6 +234,15 @@ export class CreateActivityDto {
|
||||
@IsIn(ACTIVITY_TYPES)
|
||||
type?: (typeof ACTIVITY_TYPES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
typeDefId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
fields?: ActivityFieldValues;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(ACTIVITY_PRIORITIES)
|
||||
priority?: (typeof ACTIVITY_PRIORITIES)[number];
|
||||
@@ -242,6 +319,15 @@ export class UpdateActivityDto {
|
||||
@IsIn(ACTIVITY_TYPES)
|
||||
type?: (typeof ACTIVITY_TYPES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
typeDefId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
fields?: ActivityFieldValues;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(ACTIVITY_STATUSES)
|
||||
status?: (typeof ACTIVITY_STATUSES)[number];
|
||||
|
||||
@@ -124,6 +124,21 @@ export function assigneeWhere(
|
||||
};
|
||||
}
|
||||
|
||||
/** دسترسی به فعالیت: مسئولان یا شرکتکنندگان (فیلد شرکتکنندگان) در محدوده دید */
|
||||
export function activityAccessWhere(
|
||||
actor: ScopedActor,
|
||||
): Prisma.ActivityWhereInput {
|
||||
const ids = scopedOwnerIds(actor, 'activities');
|
||||
if (!ids) return {};
|
||||
return {
|
||||
OR: [
|
||||
{ assigneeId: { in: ids } },
|
||||
{ assignees: { some: { id: { in: ids } } } },
|
||||
{ participantIds: { hasSome: ids } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** یادداشت: نویسنده یا پروندههای تحت مالکیت در محدوده دید + محرمانگی شخصی */
|
||||
export function noteAccessWhere(
|
||||
actor: ScopedActor,
|
||||
|
||||
@@ -5,10 +5,10 @@ import axios from 'axios';
|
||||
import type {
|
||||
ActivityDto,
|
||||
ActivityStageDto,
|
||||
ActivityType,
|
||||
PaginatedResponse,
|
||||
} from '@mento/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import { activityTypeView } from '@/lib/activity-types';
|
||||
import { calendarDaysBetween, formatClockFa, formatJalaliDay, startOfLocalDay, toFaDigits } from '@/lib/jalali';
|
||||
import PipelineBoard from '@/components/pipeline/PipelineBoard.vue';
|
||||
import DealCardOverlay from '@/components/pipeline/DealCardOverlay.vue';
|
||||
@@ -59,13 +59,6 @@ const moving = ref(false);
|
||||
const stageBusy = ref(false);
|
||||
const localOrder = ref<string[] | null>(null);
|
||||
|
||||
const typeLabel: Record<ActivityType, string> = {
|
||||
call: 'تماس',
|
||||
meeting: 'جلسه',
|
||||
task: 'وظیفه',
|
||||
note: 'پیام',
|
||||
};
|
||||
|
||||
const KIND_BADGE: Record<ActivityStageDto['kind'], string | undefined> = {
|
||||
open: undefined,
|
||||
done: 'انجامشده',
|
||||
@@ -109,7 +102,7 @@ function toCard(item: ActivityDto, stageId: string): PipelineDeal {
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
description: [typeLabel[item.type], party].filter(Boolean).join(' · '),
|
||||
description: [activityTypeView(item).name, party].filter(Boolean).join(' · '),
|
||||
amount: null,
|
||||
date: dateText(item),
|
||||
initials: initialsFromTitle(party || item.title),
|
||||
|
||||
@@ -0,0 +1,674 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import type {
|
||||
ActivityChecklistItem,
|
||||
ActivityFieldDef,
|
||||
ActivityFieldValue,
|
||||
ActivityFieldValues,
|
||||
ActivityParticipantsValue,
|
||||
OrgUserDto,
|
||||
PaginatedResponse,
|
||||
PersonDto,
|
||||
} from '@mento/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import UserMultiSelect from '@/components/UserMultiSelect.vue';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
fields: ActivityFieldDef[];
|
||||
users: OrgUserDto[];
|
||||
}>();
|
||||
|
||||
const model = defineModel<ActivityFieldValues>({ required: true });
|
||||
|
||||
function get<T extends ActivityFieldValue>(key: string, fallback: T): T {
|
||||
const v = model.value[key];
|
||||
return (v ?? fallback) as T;
|
||||
}
|
||||
|
||||
function set(key: string, value: ActivityFieldValue) {
|
||||
model.value = { ...model.value, [key]: value };
|
||||
}
|
||||
|
||||
function str(key: string): string {
|
||||
const v = model.value[key];
|
||||
return typeof v === 'string' || typeof v === 'number' ? String(v) : '';
|
||||
}
|
||||
|
||||
function multi(key: string): string[] {
|
||||
const v = model.value[key];
|
||||
return Array.isArray(v) && v.every((x) => typeof x === 'string') ? (v as string[]) : [];
|
||||
}
|
||||
|
||||
function toggleMulti(key: string, opt: string) {
|
||||
const list = multi(key);
|
||||
set(key, list.includes(opt) ? list.filter((x) => x !== opt) : [...list, opt]);
|
||||
}
|
||||
|
||||
/* ---------- چکلیست ---------- */
|
||||
const checklistDraft = ref<Record<string, string>>({});
|
||||
|
||||
function checklist(key: string): ActivityChecklistItem[] {
|
||||
const v = model.value[key];
|
||||
return Array.isArray(v) ? (v as ActivityChecklistItem[]).filter((x) => x && typeof x === 'object') : [];
|
||||
}
|
||||
|
||||
function addChecklistItem(key: string) {
|
||||
const text = (checklistDraft.value[key] ?? '').trim();
|
||||
if (!text) return;
|
||||
const id = `c_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
set(key, [...checklist(key), { id, text, done: false }]);
|
||||
checklistDraft.value = { ...checklistDraft.value, [key]: '' };
|
||||
}
|
||||
|
||||
function updateChecklistItem(key: string, id: string, patch: Partial<ActivityChecklistItem>) {
|
||||
set(
|
||||
key,
|
||||
checklist(key).map((it) => (it.id === id ? { ...it, ...patch } : it)),
|
||||
);
|
||||
}
|
||||
|
||||
function removeChecklistItem(key: string, id: string) {
|
||||
set(
|
||||
key,
|
||||
checklist(key).filter((it) => it.id !== id),
|
||||
);
|
||||
}
|
||||
|
||||
function moveChecklistItem(key: string, index: number, dir: -1 | 1) {
|
||||
const list = [...checklist(key)];
|
||||
const to = index + dir;
|
||||
if (to < 0 || to >= list.length) return;
|
||||
const [it] = list.splice(index, 1);
|
||||
list.splice(to, 0, it!);
|
||||
set(key, list);
|
||||
}
|
||||
|
||||
/* ---------- شرکتکنندگان ---------- */
|
||||
function participants(key: string): ActivityParticipantsValue {
|
||||
const v = model.value[key] as ActivityParticipantsValue | null | undefined;
|
||||
return {
|
||||
userIds: v?.userIds ?? [],
|
||||
people: v?.people ?? [],
|
||||
guests: v?.guests ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function patchParticipants(key: string, patch: Partial<ActivityParticipantsValue>) {
|
||||
set(key, { ...participants(key), ...patch });
|
||||
}
|
||||
|
||||
const guestDraft = ref<Record<string, string>>({});
|
||||
function addGuest(key: string) {
|
||||
const name = (guestDraft.value[key] ?? '').trim();
|
||||
if (!name) return;
|
||||
const p = participants(key);
|
||||
if (!p.guests.includes(name)) patchParticipants(key, { guests: [...p.guests, name] });
|
||||
guestDraft.value = { ...guestDraft.value, [key]: '' };
|
||||
}
|
||||
|
||||
const personQuery = ref<Record<string, string>>({});
|
||||
const personResults = ref<Record<string, PersonDto[]>>({});
|
||||
const personOpenKey = ref('');
|
||||
let personTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let personSeq = 0;
|
||||
|
||||
function searchPeople(key: string) {
|
||||
personOpenKey.value = key;
|
||||
if (personTimer) clearTimeout(personTimer);
|
||||
const q = (personQuery.value[key] ?? '').trim();
|
||||
if (!q) {
|
||||
personResults.value = { ...personResults.value, [key]: [] };
|
||||
return;
|
||||
}
|
||||
const seq = ++personSeq;
|
||||
personTimer = setTimeout(async () => {
|
||||
try {
|
||||
const { data } = await api.get<PaginatedResponse<PersonDto>>('/people', {
|
||||
params: { q, limit: 8 },
|
||||
});
|
||||
if (seq === personSeq) personResults.value = { ...personResults.value, [key]: data.items };
|
||||
} catch {
|
||||
if (seq === personSeq) personResults.value = { ...personResults.value, [key]: [] };
|
||||
}
|
||||
}, 280);
|
||||
}
|
||||
|
||||
function addPerson(key: string, p: PersonDto) {
|
||||
const cur = participants(key);
|
||||
if (!cur.people.some((x) => x.id === p.id)) {
|
||||
patchParticipants(key, { people: [...cur.people, { id: p.id, name: p.fullName }] });
|
||||
}
|
||||
personQuery.value = { ...personQuery.value, [key]: '' };
|
||||
personResults.value = { ...personResults.value, [key]: [] };
|
||||
personOpenKey.value = '';
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.fields.map((f) => f.key).join('|'),
|
||||
() => {
|
||||
checklistDraft.value = {};
|
||||
guestDraft.value = {};
|
||||
personQuery.value = {};
|
||||
personResults.value = {};
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fields-grid">
|
||||
<div
|
||||
v-for="f in fields"
|
||||
:key="f.key"
|
||||
class="cf"
|
||||
:class="{ wide: ['textarea', 'checklist', 'participants', 'multiselect'].includes(f.type) }"
|
||||
>
|
||||
<label :for="`cf-${f.key}`">
|
||||
{{ f.label }}
|
||||
<i v-if="f.required">*</i>
|
||||
</label>
|
||||
|
||||
<input
|
||||
v-if="f.type === 'text' || f.type === 'url'"
|
||||
:id="`cf-${f.key}`"
|
||||
:type="f.type === 'url' ? 'url' : 'text'"
|
||||
:dir="f.type === 'url' ? 'ltr' : undefined"
|
||||
:value="str(f.key)"
|
||||
:placeholder="f.placeholder || ''"
|
||||
maxlength="1000"
|
||||
@input="set(f.key, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
|
||||
<textarea
|
||||
v-else-if="f.type === 'textarea'"
|
||||
:id="`cf-${f.key}`"
|
||||
:value="str(f.key)"
|
||||
:placeholder="f.placeholder || ''"
|
||||
rows="3"
|
||||
@input="set(f.key, ($event.target as HTMLTextAreaElement).value)"
|
||||
/>
|
||||
|
||||
<input
|
||||
v-else-if="f.type === 'number'"
|
||||
:id="`cf-${f.key}`"
|
||||
type="number"
|
||||
inputmode="decimal"
|
||||
dir="ltr"
|
||||
:value="str(f.key)"
|
||||
:placeholder="f.placeholder || ''"
|
||||
@input="
|
||||
set(
|
||||
f.key,
|
||||
($event.target as HTMLInputElement).value === ''
|
||||
? null
|
||||
: Number(($event.target as HTMLInputElement).value),
|
||||
)
|
||||
"
|
||||
/>
|
||||
|
||||
<JalaliDateInput
|
||||
v-else-if="f.type === 'date'"
|
||||
:id="`cf-${f.key}`"
|
||||
:model-value="str(f.key)"
|
||||
@update:model-value="(v: string) => set(f.key, v || null)"
|
||||
/>
|
||||
|
||||
<select
|
||||
v-else-if="f.type === 'select'"
|
||||
:id="`cf-${f.key}`"
|
||||
:value="str(f.key)"
|
||||
@change="set(f.key, ($event.target as HTMLSelectElement).value || null)"
|
||||
>
|
||||
<option value="">— انتخاب کنید —</option>
|
||||
<option v-for="opt in f.options ?? []" :key="opt" :value="opt">{{ opt }}</option>
|
||||
</select>
|
||||
|
||||
<div v-else-if="f.type === 'multiselect'" class="chips">
|
||||
<button
|
||||
v-for="opt in f.options ?? []"
|
||||
:key="opt"
|
||||
type="button"
|
||||
class="chip-opt"
|
||||
:class="{ on: multi(f.key).includes(opt) }"
|
||||
@click="toggleMulti(f.key, opt)"
|
||||
>
|
||||
<UiIcon v-if="multi(f.key).includes(opt)" name="check-plain" :size="12" />
|
||||
{{ opt }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label v-else-if="f.type === 'checkbox'" class="switch">
|
||||
<input
|
||||
:id="`cf-${f.key}`"
|
||||
type="checkbox"
|
||||
:checked="get<boolean>(f.key, false) === true"
|
||||
@change="set(f.key, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<i />
|
||||
<span>{{ get<boolean>(f.key, false) === true ? 'بله' : 'خیر' }}</span>
|
||||
</label>
|
||||
|
||||
<div v-else-if="f.type === 'checklist'" class="checklist">
|
||||
<p v-if="checklist(f.key).length" class="progress">
|
||||
{{ toFaDigits(checklist(f.key).filter((x) => x.done).length) }} از
|
||||
{{ toFaDigits(checklist(f.key).length) }} انجام شده
|
||||
</p>
|
||||
<ul>
|
||||
<li v-for="(it, idx) in checklist(f.key)" :key="it.id" :class="{ done: it.done }">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="it.done"
|
||||
aria-label="انجام شد"
|
||||
@change="updateChecklistItem(f.key, it.id, { done: ($event.target as HTMLInputElement).checked })"
|
||||
/>
|
||||
<input
|
||||
class="item-text"
|
||||
:value="it.text"
|
||||
maxlength="300"
|
||||
@input="updateChecklistItem(f.key, it.id, { text: ($event.target as HTMLInputElement).value })"
|
||||
/>
|
||||
<button type="button" class="mini" :disabled="idx === 0" aria-label="بالا" @click="moveChecklistItem(f.key, idx, -1)">▲</button>
|
||||
<button
|
||||
type="button"
|
||||
class="mini"
|
||||
:disabled="idx === checklist(f.key).length - 1"
|
||||
aria-label="پایین"
|
||||
@click="moveChecklistItem(f.key, idx, 1)"
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
<button type="button" class="mini danger" aria-label="حذف" @click="removeChecklistItem(f.key, it.id)">
|
||||
<UiIcon name="x" :size="13" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="add-row">
|
||||
<input
|
||||
v-model="checklistDraft[f.key]"
|
||||
maxlength="300"
|
||||
:placeholder="f.placeholder || 'مورد جدید را بنویسید و Enter بزنید'"
|
||||
@keydown.enter.prevent="addChecklistItem(f.key)"
|
||||
/>
|
||||
<button type="button" class="add-btn" @click="addChecklistItem(f.key)">
|
||||
<UiIcon name="plus" :size="14" />
|
||||
افزودن
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="f.type === 'participants'" class="participants">
|
||||
<div class="p-block">
|
||||
<small>همکاران (فعالیت در لیست آنها هم نمایش داده میشود)</small>
|
||||
<UserMultiSelect
|
||||
:model-value="participants(f.key).userIds"
|
||||
:users="users"
|
||||
add-label="افزودن همکار"
|
||||
empty-label="همکاری انتخاب نشده"
|
||||
@update:model-value="(ids: string[]) => patchParticipants(f.key, { userIds: ids })"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-block">
|
||||
<small>اشخاص / مشتریان</small>
|
||||
<div class="tags">
|
||||
<span v-for="p in participants(f.key).people" :key="p.id" class="tag">
|
||||
<UiIcon name="user" :size="12" />
|
||||
{{ p.name }}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="حذف"
|
||||
@click="patchParticipants(f.key, { people: participants(f.key).people.filter((x) => x.id !== p.id) })"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="person-search">
|
||||
<input
|
||||
v-model="personQuery[f.key]"
|
||||
type="search"
|
||||
placeholder="جستجوی نام یا موبایل شخص…"
|
||||
@input="searchPeople(f.key)"
|
||||
@focus="personOpenKey = f.key"
|
||||
/>
|
||||
<ul v-if="personOpenKey === f.key && (personResults[f.key]?.length ?? 0) > 0" class="results">
|
||||
<li v-for="p in personResults[f.key]" :key="p.id">
|
||||
<button type="button" @click="addPerson(f.key, p)">
|
||||
<b>{{ p.fullName }}</b>
|
||||
<small v-if="p.company?.name">{{ p.company.name }}</small>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-block">
|
||||
<small>مهمانان دیگر (نام آزاد)</small>
|
||||
<div class="tags">
|
||||
<span v-for="g in participants(f.key).guests" :key="g" class="tag guest">
|
||||
{{ g }}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="حذف"
|
||||
@click="patchParticipants(f.key, { guests: participants(f.key).guests.filter((x) => x !== g) })"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="add-row">
|
||||
<input
|
||||
v-model="guestDraft[f.key]"
|
||||
maxlength="120"
|
||||
placeholder="نام مهمان و Enter"
|
||||
@keydown.enter.prevent="addGuest(f.key)"
|
||||
/>
|
||||
<button type="button" class="add-btn" @click="addGuest(f.key)">
|
||||
<UiIcon name="plus" :size="14" />
|
||||
افزودن
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fields-grid {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.cf {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cf > label {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted, #64748b);
|
||||
font-size: var(--fs-xs, 0.78rem);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cf > label i {
|
||||
color: var(--color-danger, #dc2626);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.cf input:not([type='checkbox']),
|
||||
.cf select,
|
||||
.cf textarea {
|
||||
width: 100%;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: var(--radius);
|
||||
min-height: var(--control-h);
|
||||
padding: 0 0.85rem;
|
||||
font: inherit;
|
||||
background: #fff;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.cf textarea {
|
||||
min-height: 5rem;
|
||||
padding: 0.6rem 0.85rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.chips,
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.chip-opt {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
border-radius: 999px;
|
||||
padding: 0.3rem 0.75rem;
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chip-opt.on {
|
||||
border-color: #5eead4;
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.switch i {
|
||||
width: 44px;
|
||||
height: 26px;
|
||||
border-radius: 999px;
|
||||
background: #e2e8f0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.switch i::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-inline-start: 3px;
|
||||
top: 3px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
transition: 0.18s;
|
||||
}
|
||||
|
||||
.switch input:checked + i {
|
||||
background: #14b8a6;
|
||||
}
|
||||
|
||||
.switch input:checked + i::after {
|
||||
inset-inline-start: 21px;
|
||||
}
|
||||
|
||||
.checklist ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.checklist li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.checklist li input[type='checkbox'] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: #14b8a6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.checklist .item-text {
|
||||
flex: 1;
|
||||
min-height: 2.2rem !important;
|
||||
}
|
||||
|
||||
.checklist li.done .item-text {
|
||||
text-decoration: line-through;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.progress {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
color: #0f766e;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mini {
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 0.65rem;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mini:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.mini.danger {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.add-row {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.add-row input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
border: 1px dashed #5eead4;
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
border-radius: var(--radius);
|
||||
padding: 0 0.8rem;
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.participants {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px dashed #e2e8f0;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.p-block {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.p-block > small {
|
||||
color: #94a3b8;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
border-radius: 999px;
|
||||
padding: 0.2rem 0.6rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tag.guest {
|
||||
background: #f1f5f9;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.tag button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
line-height: 1;
|
||||
padding: 0 0.1rem;
|
||||
}
|
||||
|
||||
.person-search {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.results {
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
top: calc(100% + 4px);
|
||||
z-index: 20;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0.3rem;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14);
|
||||
max-height: 14rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.results button {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
text-align: start;
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0.45rem 0.55rem;
|
||||
border-radius: 8px;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.results button:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.results b {
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.results small {
|
||||
color: #94a3b8;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.fields-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.cf.wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,263 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import type {
|
||||
ActivityChecklistItem,
|
||||
ActivityFieldDef,
|
||||
ActivityFieldValues,
|
||||
ActivityParticipantsValue,
|
||||
UserSummaryDto,
|
||||
} from '@mento/shared';
|
||||
import { formatJalaliDay, toFaDigits } from '@/lib/jalali';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
fields: ActivityFieldDef[];
|
||||
values: ActivityFieldValues;
|
||||
participantUsers?: UserSummaryDto[];
|
||||
/** تیک چکلیست قابل تغییر باشد */
|
||||
editable?: boolean;
|
||||
busy?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggleItem: [key: string, itemId: string, done: boolean];
|
||||
}>();
|
||||
|
||||
const userName = computed(
|
||||
() => new Map((props.participantUsers ?? []).map((u) => [u.id, u.fullName])),
|
||||
);
|
||||
|
||||
const filled = computed(() =>
|
||||
props.fields.filter((f) => {
|
||||
const v = props.values[f.key];
|
||||
if (v == null || v === '') return false;
|
||||
if (Array.isArray(v)) return v.length > 0;
|
||||
return true;
|
||||
}),
|
||||
);
|
||||
|
||||
function text(f: ActivityFieldDef): string {
|
||||
const v = props.values[f.key];
|
||||
if (f.type === 'checkbox') return v === true ? 'بله' : 'خیر';
|
||||
if (f.type === 'date' && typeof v === 'string') {
|
||||
const d = new Date(v);
|
||||
return Number.isNaN(d.getTime()) ? v : formatJalaliDay(d);
|
||||
}
|
||||
if (f.type === 'number' && typeof v === 'number') return toFaDigits(v.toLocaleString('en-US'));
|
||||
if (Array.isArray(v)) return (v as string[]).join('، ');
|
||||
return typeof v === 'string' ? v : '';
|
||||
}
|
||||
|
||||
function checklist(key: string): ActivityChecklistItem[] {
|
||||
const v = props.values[key];
|
||||
return Array.isArray(v) ? (v as ActivityChecklistItem[]) : [];
|
||||
}
|
||||
|
||||
function participants(key: string): ActivityParticipantsValue {
|
||||
const v = props.values[key] as ActivityParticipantsValue | undefined;
|
||||
return { userIds: v?.userIds ?? [], people: v?.people ?? [], guests: v?.guests ?? [] };
|
||||
}
|
||||
|
||||
function safeUrl(raw: string): string | null {
|
||||
try {
|
||||
const u = new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`);
|
||||
return u.protocol === 'http:' || u.protocol === 'https:' ? u.href : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="filled.length" class="fv">
|
||||
<div
|
||||
v-for="f in filled"
|
||||
:key="f.key"
|
||||
class="row"
|
||||
:class="{ block: ['textarea', 'checklist', 'participants'].includes(f.type) }"
|
||||
>
|
||||
<span class="label">{{ f.label }}</span>
|
||||
|
||||
<div v-if="f.type === 'checklist'" class="checklist">
|
||||
<p class="progress">
|
||||
{{ toFaDigits(checklist(f.key).filter((x) => x.done).length) }} از
|
||||
{{ toFaDigits(checklist(f.key).length) }} انجام شده
|
||||
<i
|
||||
class="bar"
|
||||
:style="{
|
||||
'--p': `${(checklist(f.key).filter((x) => x.done).length / Math.max(1, checklist(f.key).length)) * 100}%`,
|
||||
}"
|
||||
/>
|
||||
</p>
|
||||
<label v-for="it in checklist(f.key)" :key="it.id" class="item" :class="{ done: it.done }">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="it.done"
|
||||
:disabled="!editable || busy"
|
||||
@change="emit('toggleItem', f.key, it.id, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<span>{{ it.text }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-else-if="f.type === 'participants'" class="people">
|
||||
<span v-for="uid in participants(f.key).userIds" :key="uid" class="tag user">
|
||||
<UiIcon name="user" :size="12" />
|
||||
{{ userName.get(uid) || 'همکار' }}
|
||||
</span>
|
||||
<RouterLink
|
||||
v-for="p in participants(f.key).people"
|
||||
:key="p.id"
|
||||
class="tag person"
|
||||
:to="{ name: 'person-detail', params: { id: p.id } }"
|
||||
>
|
||||
<UiIcon name="users" :size="12" />
|
||||
{{ p.name }}
|
||||
</RouterLink>
|
||||
<span v-for="g in participants(f.key).guests" :key="g" class="tag guest">{{ g }}</span>
|
||||
</div>
|
||||
|
||||
<a
|
||||
v-else-if="f.type === 'url' && safeUrl(text(f))"
|
||||
class="value link"
|
||||
:href="safeUrl(text(f))!"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
dir="ltr"
|
||||
>
|
||||
{{ text(f) }}
|
||||
</a>
|
||||
|
||||
<p v-else class="value" :class="{ multiline: f.type === 'textarea' }">{{ text(f) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="empty">اطلاعاتی برای این نوع فعالیت ثبت نشده است.</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fv {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
padding: 0.72rem 0;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.row.block {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #64748b;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.value {
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
text-align: start;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.value.multiline {
|
||||
font-weight: 500;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.9;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.value.link {
|
||||
color: #0f766e;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.checklist {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.progress {
|
||||
margin: 0 0 0.2rem;
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.78rem;
|
||||
color: #0f766e;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bar {
|
||||
display: block;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, #14b8a6 var(--p), #e2e8f0 var(--p));
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.45rem 0.55rem;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.item input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: #14b8a6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.item.done span {
|
||||
text-decoration: line-through;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.people {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
border-radius: 999px;
|
||||
padding: 0.22rem 0.65rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tag.user {
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.tag.person {
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.tag.guest {
|
||||
background: #f1f5f9;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 0.4rem 0 0;
|
||||
color: #94a3b8;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,821 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import type {
|
||||
ActivityFieldDef,
|
||||
ActivityFieldType,
|
||||
ActivityStageColor,
|
||||
ActivityType,
|
||||
ActivityTypeDefDto,
|
||||
ActivityTypeIcon,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
ACTIVITY_FIELD_TYPE_LABELS,
|
||||
ACTIVITY_FIELD_TYPES,
|
||||
ACTIVITY_FIELD_TYPES_WITH_OPTIONS,
|
||||
ACTIVITY_STAGE_COLOR_LABELS,
|
||||
ACTIVITY_STAGE_COLORS,
|
||||
ACTIVITY_TYPE_ICONS,
|
||||
ACTIVITY_TYPE_LABELS,
|
||||
} from '@mento/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import {
|
||||
ACTIVITY_TYPE_ICON_LABELS,
|
||||
newFieldKey,
|
||||
typeColorStyle,
|
||||
useActivityTypes,
|
||||
} from '@/lib/activity-types';
|
||||
import { UiButton, UiModal } from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
|
||||
const props = defineProps<{ open: boolean }>();
|
||||
const emit = defineEmits<{ close: []; changed: [] }>();
|
||||
|
||||
const BASE_TYPES: ActivityType[] = ['call', 'meeting', 'task', 'note'];
|
||||
|
||||
type FieldDraft = ActivityFieldDef & { optionsText: string };
|
||||
type Draft = {
|
||||
id: string | null;
|
||||
name: string;
|
||||
baseType: ActivityType;
|
||||
icon: ActivityTypeIcon;
|
||||
color: ActivityStageColor;
|
||||
isActive: boolean;
|
||||
isSystem: boolean;
|
||||
fields: FieldDraft[];
|
||||
};
|
||||
|
||||
const { types, load } = useActivityTypes();
|
||||
const selectedId = ref<string | 'new' | null>(null);
|
||||
const draft = ref<Draft | null>(null);
|
||||
const savedSnapshot = ref('');
|
||||
const busy = ref('');
|
||||
const error = ref('');
|
||||
const notice = ref('');
|
||||
|
||||
function toDraft(t: ActivityTypeDefDto | null): Draft {
|
||||
if (!t) {
|
||||
return {
|
||||
id: null,
|
||||
name: '',
|
||||
baseType: 'task',
|
||||
icon: 'check',
|
||||
color: 'blue',
|
||||
isActive: true,
|
||||
isSystem: false,
|
||||
fields: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
baseType: t.baseType,
|
||||
icon: t.icon,
|
||||
color: t.color,
|
||||
isActive: t.isActive,
|
||||
isSystem: t.isSystem,
|
||||
fields: t.fields.map((f) => ({ ...f, optionsText: (f.options ?? []).join('\n') })),
|
||||
};
|
||||
}
|
||||
|
||||
function snapshot(d: Draft | null) {
|
||||
return JSON.stringify(d);
|
||||
}
|
||||
|
||||
const dirty = computed(() => snapshot(draft.value) !== savedSnapshot.value);
|
||||
|
||||
function select(id: string | 'new') {
|
||||
if (dirty.value && draft.value && !window.confirm('تغییرات ذخیره نشده از بین میرود. ادامه میدهید؟')) {
|
||||
return;
|
||||
}
|
||||
selectedId.value = id;
|
||||
draft.value = toDraft(id === 'new' ? null : (types.value.find((t) => t.id === id) ?? null));
|
||||
savedSnapshot.value = snapshot(draft.value);
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
async (open) => {
|
||||
if (!open) return;
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
await load(true);
|
||||
const first = types.value[0];
|
||||
selectedId.value = null;
|
||||
draft.value = null;
|
||||
if (first) select(first.id);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function apiMessage(e: unknown, fallback: string) {
|
||||
if (axios.isAxiosError(e)) {
|
||||
const msg = (e.response?.data as { message?: string | string[] } | undefined)?.message;
|
||||
if (Array.isArray(msg)) return msg[0] ?? fallback;
|
||||
if (typeof msg === 'string' && msg) return msg;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function addField() {
|
||||
if (!draft.value) return;
|
||||
draft.value.fields.push({
|
||||
key: newFieldKey(),
|
||||
label: '',
|
||||
type: 'text',
|
||||
required: false,
|
||||
placeholder: '',
|
||||
optionsText: '',
|
||||
});
|
||||
}
|
||||
|
||||
function moveField(index: number, dir: -1 | 1) {
|
||||
const list = draft.value?.fields;
|
||||
if (!list) return;
|
||||
const to = index + dir;
|
||||
if (to < 0 || to >= list.length) return;
|
||||
const [f] = list.splice(index, 1);
|
||||
list.splice(to, 0, f!);
|
||||
}
|
||||
|
||||
function removeField(index: number) {
|
||||
const f = draft.value?.fields[index];
|
||||
if (!f) return;
|
||||
if (
|
||||
f.label.trim() &&
|
||||
!window.confirm(
|
||||
`فیلد «${f.label}» حذف شود؟ مقادیر ثبتشدهٔ آن در فعالیتهای قبلی دیگر نمایش داده نمیشود.`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
draft.value!.fields.splice(index, 1);
|
||||
}
|
||||
|
||||
function needsOptions(type: ActivityFieldType) {
|
||||
return ACTIVITY_FIELD_TYPES_WITH_OPTIONS.includes(type);
|
||||
}
|
||||
|
||||
function payloadFields(d: Draft): ActivityFieldDef[] {
|
||||
return d.fields.map((f) => {
|
||||
const out: ActivityFieldDef = { key: f.key, label: f.label.trim(), type: f.type };
|
||||
if (needsOptions(f.type)) {
|
||||
out.options = f.optionsText
|
||||
.split(/\n|،|,/)
|
||||
.map((o) => o.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
if (f.required) out.required = true;
|
||||
if (f.placeholder?.trim()) out.placeholder = f.placeholder.trim();
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
function validate(d: Draft): string {
|
||||
if (!d.name.trim()) return 'نام نوع فعالیت را بنویسید.';
|
||||
const empty = d.fields.findIndex((f) => !f.label.trim());
|
||||
if (empty >= 0) return `عنوان فیلد ${toFaDigits(empty + 1)} را بنویسید.`;
|
||||
const noOpts = d.fields.find(
|
||||
(f) => needsOptions(f.type) && !f.optionsText.split(/\n|،|,/).some((o) => o.trim()),
|
||||
);
|
||||
if (noOpts) return `برای فیلد «${noOpts.label}» حداقل یک گزینه بنویسید.`;
|
||||
return '';
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const d = draft.value;
|
||||
if (!d) return;
|
||||
const msg = validate(d);
|
||||
if (msg) {
|
||||
error.value = msg;
|
||||
return;
|
||||
}
|
||||
busy.value = 'save';
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
const body = {
|
||||
name: d.name.trim(),
|
||||
icon: d.icon,
|
||||
color: d.color,
|
||||
fields: payloadFields(d),
|
||||
...(d.isSystem ? {} : { baseType: d.baseType, ...(d.id ? { isActive: d.isActive } : {}) }),
|
||||
};
|
||||
try {
|
||||
if (d.id) {
|
||||
const prev = types.value.find((t) => t.id === d.id);
|
||||
if (
|
||||
prev &&
|
||||
!d.isSystem &&
|
||||
prev.baseType !== d.baseType &&
|
||||
!window.confirm(
|
||||
'با تغییر نوع پایه، فعالیتهای قبلی این نوع هم در گزارشها با نوع پایهٔ جدید شمرده میشوند. ادامه میدهید؟',
|
||||
)
|
||||
) {
|
||||
busy.value = '';
|
||||
return;
|
||||
}
|
||||
await api.patch(`/activities/types/${d.id}`, body);
|
||||
} else {
|
||||
const { data } = await api.post<ActivityTypeDefDto>('/activities/types', body);
|
||||
d.id = data.id;
|
||||
}
|
||||
await load(true);
|
||||
const savedId = d.id!;
|
||||
selectedId.value = savedId;
|
||||
draft.value = toDraft(types.value.find((t) => t.id === savedId) ?? null);
|
||||
savedSnapshot.value = snapshot(draft.value);
|
||||
notice.value = 'ذخیره شد.';
|
||||
emit('changed');
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e, 'ذخیرهٔ نوع فعالیت ناموفق بود.');
|
||||
} finally {
|
||||
busy.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
const d = draft.value;
|
||||
if (!d?.id || d.isSystem) return;
|
||||
if (
|
||||
!window.confirm(
|
||||
`نوع «${d.name}» حذف شود؟ فعالیتهای این نوع به نوع اصلی «${ACTIVITY_TYPE_LABELS[d.baseType]}» برمیگردند. برای نگه داشتن سوابق، میتوانید بهجای حذف آن را غیرفعال کنید.`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
busy.value = 'remove';
|
||||
error.value = '';
|
||||
try {
|
||||
await api.delete(`/activities/types/${d.id}`);
|
||||
await load(true);
|
||||
savedSnapshot.value = snapshot(null);
|
||||
draft.value = null;
|
||||
const first = types.value[0];
|
||||
if (first) select(first.id);
|
||||
emit('changed');
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e, 'حذف نوع فعالیت ناموفق بود.');
|
||||
} finally {
|
||||
busy.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function moveType(index: number, dir: -1 | 1) {
|
||||
const ids = types.value.map((t) => t.id);
|
||||
const to = index + dir;
|
||||
if (to < 0 || to >= ids.length) return;
|
||||
const [moved] = ids.splice(index, 1);
|
||||
ids.splice(to, 0, moved!);
|
||||
busy.value = 'order';
|
||||
error.value = '';
|
||||
try {
|
||||
await api.patch('/activities/types/reorder', { typeIds: ids });
|
||||
await load(true);
|
||||
emit('changed');
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e, 'جابهجایی ناموفق بود.');
|
||||
} finally {
|
||||
busy.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (dirty.value && draft.value && !window.confirm('تغییرات ذخیره نشده از بین میرود. بستن؟')) return;
|
||||
emit('close');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiModal :open="open" title="انواع فعالیت و فیلدهای اختصاصی" @close="close">
|
||||
<div class="mgr">
|
||||
<p class="hint">
|
||||
برای هر نوع فعالیت فیلدهای مخصوص خودش را تعریف کنید؛ مثلاً «موضوع جلسه» و «شرکتکنندگان» برای جلسه
|
||||
یا «لیست کارها» برای وظیفه. «نوع پایه» مشخص میکند فعالیت در گزارشها جزو تماس، جلسه، وظیفه یا پیام
|
||||
شمرده شود.
|
||||
</p>
|
||||
|
||||
<div class="layout">
|
||||
<aside class="list">
|
||||
<div
|
||||
v-for="(t, index) in types"
|
||||
:key="t.id"
|
||||
class="type-row"
|
||||
:class="{ on: selectedId === t.id, off: !t.isActive }"
|
||||
>
|
||||
<button type="button" class="pick" @click="select(t.id)">
|
||||
<span class="ico" :style="typeColorStyle(t.color)">
|
||||
<UiIcon :name="t.icon" :size="14" />
|
||||
</span>
|
||||
<span class="txt">
|
||||
<b>{{ t.name }}</b>
|
||||
<small>
|
||||
{{ ACTIVITY_TYPE_LABELS[t.baseType] }} · {{ toFaDigits(t.fields.length) }} فیلد
|
||||
<template v-if="!t.isActive"> · غیرفعال</template>
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
<div class="order">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="index === 0 || Boolean(busy)"
|
||||
aria-label="انتقال به بالا"
|
||||
@click="moveType(index, -1)"
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="index === types.length - 1 || Boolean(busy)"
|
||||
aria-label="انتقال به پایین"
|
||||
@click="moveType(index, 1)"
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="add-type" :class="{ on: selectedId === 'new' }" @click="select('new')">
|
||||
<UiIcon name="plus" :size="15" />
|
||||
نوع فعالیت جدید
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<section v-if="draft" class="editor">
|
||||
<div class="grid">
|
||||
<label class="f">
|
||||
<span>نام نوع</span>
|
||||
<input v-model="draft.name" maxlength="60" placeholder="مثلاً: بازدید حضوری" />
|
||||
</label>
|
||||
<label class="f">
|
||||
<span>نوع پایه (برای گزارشها)</span>
|
||||
<select v-model="draft.baseType" :disabled="draft.isSystem">
|
||||
<option v-for="b in BASE_TYPES" :key="b" :value="b">{{ ACTIVITY_TYPE_LABELS[b] }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="f">
|
||||
<span>رنگ</span>
|
||||
<select v-model="draft.color">
|
||||
<option v-for="c in ACTIVITY_STAGE_COLORS" :key="c" :value="c">
|
||||
{{ ACTIVITY_STAGE_COLOR_LABELS[c] }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="!draft.isSystem && draft.id" class="f toggle">
|
||||
<input v-model="draft.isActive" type="checkbox" />
|
||||
<span>فعال (در فرم فعالیت قابل انتخاب باشد)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="f">
|
||||
<span>آیکون</span>
|
||||
<div class="icons">
|
||||
<button
|
||||
v-for="ic in ACTIVITY_TYPE_ICONS"
|
||||
:key="ic"
|
||||
type="button"
|
||||
:class="{ on: draft.icon === ic }"
|
||||
:style="draft.icon === ic ? typeColorStyle(draft.color) : undefined"
|
||||
:title="ACTIVITY_TYPE_ICON_LABELS[ic]"
|
||||
:aria-label="ACTIVITY_TYPE_ICON_LABELS[ic]"
|
||||
@click="draft.icon = ic"
|
||||
>
|
||||
<UiIcon :name="ic" :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="draft.isSystem" class="sys-note">
|
||||
این نوع اصلی است: حذف و غیرفعال نمیشود، ولی نام، ظاهر و فیلدهایش قابل تغییر است.
|
||||
</p>
|
||||
|
||||
<div class="fields-head">
|
||||
<strong>فیلدهای اختصاصی</strong>
|
||||
<small>{{ toFaDigits(draft.fields.length) }} فیلد</small>
|
||||
</div>
|
||||
<p v-if="!draft.fields.length" class="empty">
|
||||
هنوز فیلدی تعریف نشده؛ فعالیتهای این نوع فقط فیلدهای عمومی (عنوان، زمان، مسئول، مشتری…) را دارند.
|
||||
</p>
|
||||
|
||||
<div v-for="(f, index) in draft.fields" :key="f.key" class="field-card">
|
||||
<div class="field-top">
|
||||
<span class="num">{{ toFaDigits(index + 1) }}</span>
|
||||
<input v-model="f.label" class="label-input" maxlength="80" placeholder="عنوان فیلد (مثلاً: موضوع جلسه)" />
|
||||
<select v-model="f.type" aria-label="نوع فیلد">
|
||||
<option v-for="ft in ACTIVITY_FIELD_TYPES" :key="ft" :value="ft">
|
||||
{{ ACTIVITY_FIELD_TYPE_LABELS[ft] }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="field-actions">
|
||||
<button type="button" :disabled="index === 0" aria-label="بالا" @click="moveField(index, -1)">▲</button>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="index === draft.fields.length - 1"
|
||||
aria-label="پایین"
|
||||
@click="moveField(index, 1)"
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
<button type="button" class="danger" aria-label="حذف فیلد" @click="removeField(index)">
|
||||
<UiIcon name="trash" :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-extra">
|
||||
<textarea
|
||||
v-if="needsOptions(f.type)"
|
||||
v-model="f.optionsText"
|
||||
rows="3"
|
||||
placeholder="گزینهها — هر گزینه در یک خط"
|
||||
/>
|
||||
<input
|
||||
v-if="!['checkbox', 'participants', 'select', 'multiselect', 'date'].includes(f.type)"
|
||||
v-model="f.placeholder"
|
||||
maxlength="200"
|
||||
placeholder="متن راهنما داخل فیلد (اختیاری)"
|
||||
/>
|
||||
<label v-if="f.type !== 'checkbox'" class="req">
|
||||
<input v-model="f.required" type="checkbox" />
|
||||
اجباری
|
||||
</label>
|
||||
<small v-if="f.type === 'participants'" class="tip">
|
||||
همکارانی که بهعنوان شرکتکننده انتخاب شوند، این فعالیت را در لیست خودشان هم میبینند و اعلان
|
||||
دریافت میکنند.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="add-field" @click="addField">
|
||||
<UiIcon name="plus" :size="15" />
|
||||
افزودن فیلد
|
||||
</button>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-else-if="notice" class="notice">{{ notice }}</p>
|
||||
|
||||
<div class="actions">
|
||||
<UiButton
|
||||
variant="primary"
|
||||
type="button"
|
||||
:disabled="!dirty || Boolean(busy)"
|
||||
:loading="busy === 'save'"
|
||||
@click="save"
|
||||
>
|
||||
{{ draft.id ? 'ذخیرهٔ تغییرات' : 'ساخت نوع فعالیت' }}
|
||||
</UiButton>
|
||||
<UiButton
|
||||
v-if="draft.id && !draft.isSystem"
|
||||
variant="ghost"
|
||||
type="button"
|
||||
:disabled="Boolean(busy)"
|
||||
:loading="busy === 'remove'"
|
||||
@click="remove"
|
||||
>
|
||||
<UiIcon name="trash" :size="15" />
|
||||
حذف نوع
|
||||
</UiButton>
|
||||
</div>
|
||||
</section>
|
||||
<p v-else class="empty">یک نوع فعالیت را انتخاب کنید.</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mgr {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.type-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.type-row.on {
|
||||
border-color: #5eead4;
|
||||
background: #f0fdfa;
|
||||
}
|
||||
|
||||
.type-row.off {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.pick {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0.5rem 0.6rem;
|
||||
font: inherit;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ico {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.txt {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.txt b {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.txt small {
|
||||
color: #94a3b8;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.order {
|
||||
display: grid;
|
||||
padding-inline-end: 0.3rem;
|
||||
}
|
||||
|
||||
.order button,
|
||||
.field-actions button {
|
||||
border: 0;
|
||||
background: none;
|
||||
color: #94a3b8;
|
||||
font-size: 0.62rem;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem 0.3rem;
|
||||
}
|
||||
|
||||
.order button:disabled,
|
||||
.field-actions button:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.add-type,
|
||||
.add-field {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
min-height: var(--control-h);
|
||||
border: 1px dashed #5eead4;
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
border-radius: var(--radius);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
font-size: 0.86rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.add-type.on {
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.editor {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
align-content: start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.f {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.f > span {
|
||||
color: #64748b;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.f.toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
align-self: end;
|
||||
min-height: var(--control-h);
|
||||
}
|
||||
|
||||
.f.toggle span {
|
||||
color: #334155;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
input:not([type='checkbox']),
|
||||
select,
|
||||
textarea {
|
||||
min-height: var(--control-h);
|
||||
padding: 0 0.65rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 0.86rem;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
textarea {
|
||||
padding: 0.5rem 0.65rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
select:disabled {
|
||||
background: #f8fafc;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
accent-color: #14b8a6;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.icons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.icons button {
|
||||
width: 2.2rem;
|
||||
height: 2.2rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icons button.on {
|
||||
border-color: currentColor;
|
||||
}
|
||||
|
||||
.sys-note {
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border-radius: var(--radius);
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.fields-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
padding-top: 0.6rem;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.fields-head small {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 0;
|
||||
color: #94a3b8;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.field-card {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #fcfdfe;
|
||||
}
|
||||
|
||||
.field-top {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4rem minmax(0, 1fr) 10rem auto;
|
||||
gap: 0.45rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.num {
|
||||
color: #94a3b8;
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.field-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.field-actions .danger {
|
||||
color: #dc2626;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.field-extra {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
padding-inline-start: 1.85rem;
|
||||
}
|
||||
|
||||
.req {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.82rem;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.tip {
|
||||
color: #0f766e;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin: 0;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
padding: 0.6rem 0 0.2rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
@media (min-width: 760px) {
|
||||
.layout {
|
||||
grid-template-columns: 14rem minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.field-top {
|
||||
grid-template-columns: 1.4rem minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.field-top select {
|
||||
grid-column: 2 / -1;
|
||||
grid-row: 2;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -23,7 +23,9 @@ import UiSegmented from '@/components/ui/UiSegmented.vue';
|
||||
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
|
||||
import ActivitiesKanban from '@/components/activities/ActivitiesKanban.vue';
|
||||
import ActivityStagesManager from '@/components/activities/ActivityStagesManager.vue';
|
||||
import ActivityTypesManager from '@/components/activities/ActivityTypesManager.vue';
|
||||
import { PIPELINE_ACCENT } from '@/lib/pipeline-board';
|
||||
import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types';
|
||||
import {
|
||||
ACTIVITY_EXCEL_ALIASES,
|
||||
ACTIVITY_EXCEL_FIELDS,
|
||||
@@ -56,10 +58,11 @@ import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
||||
|
||||
const ACT_TABLE_KEY = 'activities';
|
||||
const ACT_COLUMNS = [
|
||||
{ key: 'title', label: 'مشتری / عنوان فعالیت', width: 'minmax(0, 2.2fr)' },
|
||||
{ key: 'title', label: 'عنوان فعالیت', width: 'minmax(0, 2fr)' },
|
||||
{ key: 'who', label: 'کارشناس', width: 'minmax(7rem, 0.9fr)' },
|
||||
{ key: 'customer', label: 'مشتری', width: 'minmax(7rem, 1fr)' },
|
||||
{ key: 'type', label: 'نوع فعالیت', width: '7.6rem' },
|
||||
{ key: 'when', label: 'زمان شروع', width: '7.2rem' },
|
||||
{ key: 'who', label: 'کارشناس', width: '8.4rem' },
|
||||
{ key: 'status', label: 'وضعیت', width: '8.6rem' },
|
||||
] as const;
|
||||
type ActColKey = (typeof ACT_COLUMNS)[number]['key'];
|
||||
@@ -80,7 +83,8 @@ const actGridStyle = computed(() => ({
|
||||
].join(' '),
|
||||
}));
|
||||
|
||||
type TypeFilter = 'all' | ActivityType;
|
||||
/** 'all' یا شناسهٔ نوع فعالیت */
|
||||
type TypeFilter = string;
|
||||
type StatusFilter = 'all' | ActivityStatus;
|
||||
type UiStatus = 'done' | 'progress' | 'planned' | 'overdue' | 'canceled';
|
||||
type DeskScope = 'mine' | 'team';
|
||||
@@ -106,14 +110,6 @@ const DESK_TABS: Array<{ key: DeskScope; label: string }> = [
|
||||
{ key: 'team', label: 'همه تیم' },
|
||||
];
|
||||
|
||||
const TYPE_FILTERS: Array<{ key: TypeFilter; label: string }> = [
|
||||
{ key: 'all', label: 'همه انواع' },
|
||||
{ key: 'call', label: 'تماس' },
|
||||
{ key: 'meeting', label: 'جلسه' },
|
||||
{ key: 'task', label: 'وظیفه' },
|
||||
{ key: 'note', label: 'پیام' },
|
||||
];
|
||||
|
||||
const STATUS_FILTERS: Array<{ key: StatusFilter; label: string }> = [
|
||||
{ key: 'all', label: 'همه وضعیتها' },
|
||||
{ key: 'open', label: 'باز / انجامنشده' },
|
||||
@@ -155,6 +151,13 @@ const filter = computed<TimeFilter>(() => {
|
||||
|
||||
const stages = ref<ActivityStageDto[]>([]);
|
||||
const stagesManagerOpen = ref(false);
|
||||
const typesManagerOpen = ref(false);
|
||||
|
||||
async function onTypesChanged() {
|
||||
await activityTypes.load(true);
|
||||
kanbanReload.value += 1;
|
||||
await refreshAll();
|
||||
}
|
||||
const kanbanReload = ref(0);
|
||||
const stageCounts = ref<ActivityStageCountsDto>({ counts: {}, total: 0 });
|
||||
|
||||
@@ -185,9 +188,25 @@ const ideasOpen = ref(false);
|
||||
const ACTIVITY_TYPES: ActivityType[] = ['call', 'meeting', 'task', 'note'];
|
||||
const ACTIVITY_STATUSES: ActivityStatus[] = ['open', 'done', 'canceled'];
|
||||
|
||||
const activityTypes = useActivityTypes();
|
||||
const TYPE_FILTERS = computed<Array<{ key: TypeFilter; label: string }>>(() => [
|
||||
{ key: 'all', label: 'همه انواع' },
|
||||
...activityTypes.types.value
|
||||
.filter((t) => t.isActive || t.id === typeFilter.value)
|
||||
.map((t) => ({ key: t.id, label: t.name })),
|
||||
]);
|
||||
|
||||
/** پارامتر قدیمی ?type=call → نوع اصلیِ همان نوع پایه */
|
||||
const legacyBaseType = computed<ActivityType | null>(() => {
|
||||
const value = typeof route.query.type === 'string' ? route.query.type : '';
|
||||
return ACTIVITY_TYPES.includes(value as ActivityType) ? (value as ActivityType) : null;
|
||||
});
|
||||
|
||||
const typeFilter = computed<TypeFilter>(() => {
|
||||
const value = typeof route.query.type === 'string' ? route.query.type : 'all';
|
||||
return ACTIVITY_TYPES.includes(value as ActivityType) ? (value as ActivityType) : 'all';
|
||||
const id = typeof route.query.typeDefId === 'string' ? route.query.typeDefId.trim() : '';
|
||||
if (id) return id;
|
||||
if (legacyBaseType.value) return activityTypes.systemOf.value[legacyBaseType.value]?.id ?? 'all';
|
||||
return 'all';
|
||||
});
|
||||
|
||||
const statusFilter = computed<StatusFilter>(() => {
|
||||
@@ -217,20 +236,6 @@ const counts = ref<Record<TimeFilter, number>>({
|
||||
all: 0,
|
||||
});
|
||||
|
||||
const typeLabel: Record<ActivityType, string> = {
|
||||
call: 'تماس',
|
||||
meeting: 'جلسه',
|
||||
task: 'وظیفه',
|
||||
note: 'پیام',
|
||||
};
|
||||
|
||||
const typeIcon: Record<ActivityType, 'phone' | 'users' | 'check' | 'chat'> = {
|
||||
call: 'phone',
|
||||
meeting: 'users',
|
||||
task: 'check',
|
||||
note: 'chat',
|
||||
};
|
||||
|
||||
const statusLabel: Record<UiStatus, string> = {
|
||||
done: 'انجام شد',
|
||||
progress: 'در حال انجام',
|
||||
@@ -263,7 +268,13 @@ const sharedParams = computed<Record<string, string | undefined>>(() => ({
|
||||
filter: filter.value,
|
||||
q: typeof route.query.q === 'string' && route.query.q.trim() ? route.query.q.trim() : undefined,
|
||||
desk: showDeskTabs.value ? desk.value : undefined,
|
||||
type: typeFilter.value === 'all' ? undefined : typeFilter.value,
|
||||
typeDefId: typeof route.query.typeDefId === 'string' && route.query.typeDefId.trim()
|
||||
? route.query.typeDefId.trim()
|
||||
: undefined,
|
||||
type:
|
||||
!(typeof route.query.typeDefId === 'string' && route.query.typeDefId.trim()) && legacyBaseType.value
|
||||
? legacyBaseType.value
|
||||
: undefined,
|
||||
status: statusFilter.value === 'all' ? undefined : statusFilter.value,
|
||||
assigneeId: assigneeFilter.value || undefined,
|
||||
}));
|
||||
@@ -524,7 +535,7 @@ function patchFacetQuery(patch: Record<string, string | undefined>) {
|
||||
}
|
||||
|
||||
function setTypeFilter(next: TypeFilter) {
|
||||
patchFacetQuery({ type: next === 'all' ? undefined : next });
|
||||
patchFacetQuery({ typeDefId: next === 'all' ? undefined : next, type: undefined });
|
||||
}
|
||||
|
||||
function setStatusFilter(next: StatusFilter) {
|
||||
@@ -620,6 +631,7 @@ watch(
|
||||
route.query.page,
|
||||
route.query.desk,
|
||||
route.query.type,
|
||||
route.query.typeDefId,
|
||||
route.query.status,
|
||||
route.query.assigneeId,
|
||||
route.query.stage,
|
||||
@@ -657,6 +669,7 @@ onMounted(() => {
|
||||
syncMobile();
|
||||
mobileQuery.addEventListener('change', syncMobile);
|
||||
void loadUsers();
|
||||
void activityTypes.load(true);
|
||||
void loadStages().then(() => {
|
||||
if (migrateLegacyDoneFilter()) return;
|
||||
void load();
|
||||
@@ -745,6 +758,16 @@ onUnmounted(() => {
|
||||
<UiIcon name="settings" :size="15" />
|
||||
مدیریت ستونها
|
||||
</UiButton>
|
||||
<UiButton
|
||||
v-if="canManageStages"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
type="button"
|
||||
@click="typesManagerOpen = true"
|
||||
>
|
||||
<UiIcon name="sliders" :size="15" />
|
||||
انواع فعالیت و فیلدها
|
||||
</UiButton>
|
||||
<UiSegmented v-model="viewModel" :options="VIEW_OPTIONS">
|
||||
<template #icon="{ option }">
|
||||
<UiIcon :name="option.icon ?? 'list'" :size="15" />
|
||||
@@ -800,7 +823,7 @@ onUnmounted(() => {
|
||||
<input
|
||||
v-model="q"
|
||||
type="search"
|
||||
placeholder="جستجو در عنوان، مشتری، نوع فعالیت..."
|
||||
placeholder="جستجو در عنوان، مشتری، کارشناس، نوع فعالیت..."
|
||||
/>
|
||||
</form>
|
||||
<div class="filter-wrap" @click.stop>
|
||||
@@ -962,27 +985,41 @@ onUnmounted(() => {
|
||||
<p v-if="subtitle(item)" class="sub">{{ subtitle(item) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="colOn('type')" class="type-cell">
|
||||
<span class="type-pill" :class="item.type">
|
||||
<UiIcon :name="typeIcon[item.type]" :size="13" />
|
||||
{{ typeLabel[item.type] }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="colOn('when')" class="when" :class="{ late: uiStatus(item) === 'overdue' }">
|
||||
<strong>{{ clockText(item) }}</strong>
|
||||
<span>{{ dayText(item) }}</span>
|
||||
</div>
|
||||
<div v-if="colOn('who')" class="who">
|
||||
<strong>{{
|
||||
item.assignees?.length
|
||||
? item.assignees.map((a) => a.fullName).join('، ')
|
||||
: item.assignee?.fullName || '—'
|
||||
}}</strong>
|
||||
<span v-if="item.company || item.person">
|
||||
<UiIcon name="building" :size="12" />
|
||||
{{ item.company?.name || item.person?.fullName }}
|
||||
</div>
|
||||
<div v-if="colOn('customer')" class="customer">
|
||||
<RouterLink
|
||||
v-if="item.company"
|
||||
:to="{ name: 'company-detail', params: { id: item.company.id } }"
|
||||
>
|
||||
<UiIcon name="building" :size="13" />
|
||||
{{ item.company.name }}
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-if="item.person"
|
||||
:to="{ name: 'person-detail', params: { id: item.person.id } }"
|
||||
:class="{ secondary: Boolean(item.company) }"
|
||||
>
|
||||
<UiIcon name="user" :size="13" />
|
||||
{{ item.person.fullName }}
|
||||
</RouterLink>
|
||||
<span v-if="!item.company && !item.person" class="none">—</span>
|
||||
</div>
|
||||
<div v-if="colOn('type')" class="type-cell">
|
||||
<span class="type-pill" :style="typeColorStyle(activityTypeView(item).color)">
|
||||
<UiIcon :name="activityTypeView(item).icon" :size="13" />
|
||||
{{ activityTypeView(item).name }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="colOn('when')" class="when" :class="{ late: uiStatus(item) === 'overdue' }">
|
||||
<strong>{{ clockText(item) }}</strong>
|
||||
<span>{{ dayText(item) }}</span>
|
||||
</div>
|
||||
<div v-if="colOn('status')" class="status-cell">
|
||||
<span class="status" :class="uiStatus(item)">
|
||||
<UiIcon :name="statusIcon[uiStatus(item)]" :size="13" />
|
||||
@@ -1068,10 +1105,12 @@ onUnmounted(() => {
|
||||
انجام شد
|
||||
</button>
|
||||
</UiDropdownMenu>
|
||||
<span class="type-ico" :class="item.type">
|
||||
<UiIcon :name="typeIcon[item.type]" :size="18" />
|
||||
<span class="type-ico" :style="typeColorStyle(activityTypeView(item).color)">
|
||||
<UiIcon :name="activityTypeView(item).icon" :size="18" />
|
||||
</span>
|
||||
<span class="type-pill" :style="typeColorStyle(activityTypeView(item).color)">
|
||||
{{ activityTypeView(item).name }}
|
||||
</span>
|
||||
<span class="type-pill" :class="item.type">{{ typeLabel[item.type] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="card-foot">
|
||||
@@ -1121,6 +1160,12 @@ onUnmounted(() => {
|
||||
@close="stagesManagerOpen = false"
|
||||
@changed="onStagesChanged(); kanbanReload += 1"
|
||||
/>
|
||||
<ActivityTypesManager
|
||||
v-if="canManageStages"
|
||||
:open="typesManagerOpen"
|
||||
@close="typesManagerOpen = false"
|
||||
@changed="onTypesChanged"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1566,7 +1611,7 @@ h1 {
|
||||
.thead,
|
||||
.trow {
|
||||
display: grid;
|
||||
grid-template-columns: 2.4rem minmax(0, 2.2fr) 7.6rem 7.2rem 8.4rem 8.6rem 2.4rem;
|
||||
grid-template-columns: 2.4rem minmax(0, 2fr) minmax(7rem, 0.9fr) minmax(7rem, 1fr) 7.6rem 7.2rem 8.6rem 2.4rem;
|
||||
gap: 0.8rem;
|
||||
align-items: center;
|
||||
padding: 0.95rem 1.1rem;
|
||||
@@ -1682,6 +1727,40 @@ time.note {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.customer {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.customer a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
min-width: 0;
|
||||
color: var(--ink);
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.customer a:hover {
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.customer a.secondary {
|
||||
color: #94a3b8;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.customer .none {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.when.late {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
@@ -12,9 +12,16 @@ import UserAvatar from '@/components/UserAvatar.vue';
|
||||
import UserMultiSelect from '@/components/UserMultiSelect.vue';
|
||||
import ActivityCommentSheet from '@/components/activities/ActivityCommentSheet.vue';
|
||||
import ActivityPostponeSheet from '@/components/activities/ActivityPostponeSheet.vue';
|
||||
import type { ActivityDto, ActivityPriority, ActivityType, OrgUserDto } from '@mento/shared';
|
||||
import ActivityFieldsView from '@/components/activities/ActivityFieldsView.vue';
|
||||
import type {
|
||||
ActivityChecklistItem,
|
||||
ActivityDto,
|
||||
ActivityPriority,
|
||||
OrgUserDto,
|
||||
} from '@mento/shared';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { canReassignRecords } from '@/lib/roles';
|
||||
import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -44,19 +51,33 @@ const assigneeNames = computed(() => {
|
||||
return list.length ? list.join('، ') : '—';
|
||||
});
|
||||
|
||||
const typeLabel: Record<ActivityType, string> = {
|
||||
call: 'تماس',
|
||||
meeting: 'جلسه',
|
||||
task: 'وظیفه',
|
||||
note: 'پیام',
|
||||
};
|
||||
const activityTypes = useActivityTypes();
|
||||
const typeView = computed(() => (activity.value ? activityTypeView(activity.value) : null));
|
||||
const typeDef = computed(() => (activity.value ? activityTypes.defOf(activity.value) : null));
|
||||
const fieldsBusy = ref(false);
|
||||
|
||||
const typeIcon: Record<ActivityType, 'phone' | 'users' | 'check' | 'chat'> = {
|
||||
call: 'phone',
|
||||
meeting: 'users',
|
||||
task: 'check',
|
||||
note: 'chat',
|
||||
};
|
||||
/** تیک چکلیست بدون باز کردن فرم ویرایش */
|
||||
async function toggleChecklistItem(key: string, itemId: string, done: boolean) {
|
||||
const a = activity.value;
|
||||
if (!a || fieldsBusy.value) return;
|
||||
const list = (a.fields?.[key] as ActivityChecklistItem[] | undefined) ?? [];
|
||||
const nextFields = {
|
||||
...(a.fields ?? {}),
|
||||
[key]: list.map((it) => (it.id === itemId ? { ...it, done } : it)),
|
||||
};
|
||||
activity.value = { ...a, fields: nextFields };
|
||||
fieldsBusy.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data } = await api.patch<ActivityDto>(`/activities/${a.id}`, { fields: nextFields });
|
||||
activity.value = { ...data, participantUsers: a.participantUsers };
|
||||
} catch {
|
||||
activity.value = a;
|
||||
error.value = 'ثبت تغییر چکلیست ناموفق بود.';
|
||||
} finally {
|
||||
fieldsBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const priorityLabel: Record<ActivityPriority, string> = {
|
||||
very_low: 'خیلی کم',
|
||||
@@ -254,6 +275,7 @@ watch(id, () => {
|
||||
onMounted(() => {
|
||||
void load();
|
||||
void loadUsers();
|
||||
void activityTypes.load();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -295,18 +317,18 @@ onMounted(() => {
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="type-ico" :class="activity.type">
|
||||
<UiIcon :name="typeIcon[activity.type]" :size="22" />
|
||||
<span class="type-ico" :style="typeColorStyle(typeView!.color)">
|
||||
<UiIcon :name="typeView!.icon" :size="22" />
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<dl>
|
||||
<div>
|
||||
<dt>
|
||||
<UiIcon :name="typeIcon[activity.type]" :size="15" />
|
||||
<UiIcon :name="typeView!.icon" :size="15" />
|
||||
نوع فعالیت
|
||||
</dt>
|
||||
<dd>{{ typeLabel[activity.type] }}</dd>
|
||||
<dd>{{ typeView!.name }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>
|
||||
@@ -369,6 +391,23 @@ onMounted(() => {
|
||||
</dl>
|
||||
</article>
|
||||
|
||||
<article v-if="typeDef?.fields.length" class="card type-card">
|
||||
<h3>
|
||||
<span class="type-ico sm" :style="typeColorStyle(typeView!.color)">
|
||||
<UiIcon :name="typeView!.icon" :size="14" />
|
||||
</span>
|
||||
اطلاعات {{ typeView!.name }}
|
||||
</h3>
|
||||
<ActivityFieldsView
|
||||
:fields="typeDef.fields"
|
||||
:values="activity.fields ?? {}"
|
||||
:participant-users="activity.participantUsers"
|
||||
:busy="fieldsBusy"
|
||||
editable
|
||||
@toggle-item="toggleChecklistItem"
|
||||
/>
|
||||
</article>
|
||||
|
||||
<article class="card note">
|
||||
<button class="note-head" type="button" @click="noteOpen = !noteOpen">
|
||||
<strong>
|
||||
@@ -573,24 +612,18 @@ h1 {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.type-ico.call {
|
||||
background: #ecfdf5;
|
||||
color: #0f766e;
|
||||
.type-ico.sm {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.type-ico.meeting {
|
||||
background: #f5f3ff;
|
||||
color: #6d28d9;
|
||||
}
|
||||
|
||||
.type-ico.task {
|
||||
background: #fff7ed;
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.type-ico.note {
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
.type-card h3 {
|
||||
margin: 0 0 0.4rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.95rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
dl {
|
||||
|
||||
@@ -27,10 +27,15 @@ import {
|
||||
UiPageHeader,
|
||||
UiTextarea,
|
||||
} from '@/components/ui';
|
||||
import ActivityFieldsEditor from '@/components/activities/ActivityFieldsEditor.vue';
|
||||
import { typeColorStyle, useActivityTypes } from '@/lib/activity-types';
|
||||
import type {
|
||||
ActivityDto,
|
||||
ActivityFieldValue,
|
||||
ActivityFieldValues,
|
||||
ActivityPriority,
|
||||
ActivityType,
|
||||
ActivityTypeDefDto,
|
||||
CreateActivityRequest,
|
||||
DealDto,
|
||||
OrgUserDto,
|
||||
@@ -71,8 +76,13 @@ const people = ref<PersonDto[]>([]);
|
||||
const deals = ref<DealDto[]>([]);
|
||||
const users = ref<OrgUserDto[]>([]);
|
||||
|
||||
const activityTypes = useActivityTypes();
|
||||
const title = ref('');
|
||||
const type = ref<ActivityType | ''>('');
|
||||
const typeDefId = ref('');
|
||||
const fieldValues = ref<ActivityFieldValues>({});
|
||||
/** نوع غیرفعالی که فعالیت فعلی دارد — در لیست انتخاب نگه داشته میشود */
|
||||
const keepTypeId = ref('');
|
||||
const priority = ref<ActivityPriority>('medium');
|
||||
const description = ref('');
|
||||
const tagsRaw = ref('');
|
||||
@@ -185,16 +195,24 @@ watch(personQuery, (raw) => {
|
||||
}, 300);
|
||||
});
|
||||
|
||||
const typeOptions: Array<{
|
||||
value: ActivityType;
|
||||
label: string;
|
||||
icon: 'phone' | 'users' | 'check' | 'chat';
|
||||
}> = [
|
||||
{ value: 'call', label: 'تماس', icon: 'phone' },
|
||||
{ value: 'meeting', label: 'جلسه', icon: 'users' },
|
||||
{ value: 'task', label: 'وظیفه', icon: 'check' },
|
||||
{ value: 'note', label: 'یادداشت', icon: 'chat' },
|
||||
];
|
||||
const typeOptions = computed<ActivityTypeDefDto[]>(() =>
|
||||
activityTypes.types.value.filter((t) => t.isActive || t.id === keepTypeId.value),
|
||||
);
|
||||
|
||||
function pickType(def: ActivityTypeDefDto) {
|
||||
typeDefId.value = def.id;
|
||||
type.value = def.baseType;
|
||||
typeOpen.value = false;
|
||||
}
|
||||
|
||||
function isEmptyValue(v: ActivityFieldValue | undefined): boolean {
|
||||
if (v == null || v === '') return true;
|
||||
if (Array.isArray(v)) return v.length === 0;
|
||||
if (typeof v === 'object') {
|
||||
return !v.userIds?.length && !v.people?.length && !v.guests?.length;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const priorityOptions: Array<{ value: ActivityPriority; label: string; tone: string }> = [
|
||||
{ value: 'very_high', label: 'خیلی زیاد', tone: 'very-high' },
|
||||
@@ -204,7 +222,8 @@ const priorityOptions: Array<{ value: ActivityPriority; label: string; tone: str
|
||||
{ value: 'very_low', label: 'خیلی کم', tone: 'very-low' },
|
||||
];
|
||||
|
||||
const typeMeta = computed(() => typeOptions.find((o) => o.value === type.value));
|
||||
const typeMeta = computed(() => activityTypes.byId.value.get(typeDefId.value) ?? null);
|
||||
const typeFields = computed(() => typeMeta.value?.fields ?? []);
|
||||
const selectedPerson = computed(() => people.value.find((p) => p.id === personId.value) ?? null);
|
||||
const selectedAssignees = computed(() =>
|
||||
assigneeIds.value
|
||||
@@ -261,6 +280,9 @@ function defaultDue() {
|
||||
function resetForm() {
|
||||
title.value = '';
|
||||
type.value = '';
|
||||
typeDefId.value = '';
|
||||
fieldValues.value = {};
|
||||
keepTypeId.value = '';
|
||||
priority.value = 'medium';
|
||||
description.value = '';
|
||||
tagsRaw.value = '';
|
||||
@@ -300,6 +322,7 @@ async function load() {
|
||||
api.get<PaginatedResponse<PersonDto>>('/people', { params: { limit: 100 } }),
|
||||
api.get<PipelineBoardDto>('/pipelines/board'),
|
||||
api.get<OrgUserDto[]>('/users').catch(() => ({ data: [] as OrgUserDto[] })),
|
||||
activityTypes.load(true),
|
||||
]);
|
||||
people.value = peopleRes.data.items;
|
||||
deals.value = boardRes.data.stages.flatMap((s) => s.deals);
|
||||
@@ -309,6 +332,10 @@ async function load() {
|
||||
const { data } = await api.get<ActivityDto>(`/activities/${id.value}`);
|
||||
title.value = data.title;
|
||||
type.value = data.type;
|
||||
typeDefId.value =
|
||||
data.typeDef?.id ?? activityTypes.systemOf.value[data.type]?.id ?? '';
|
||||
keepTypeId.value = typeDefId.value;
|
||||
fieldValues.value = { ...(data.fields ?? {}) };
|
||||
priority.value = data.priority ?? 'medium';
|
||||
description.value = data.description ?? '';
|
||||
needsNote.value = Boolean(data.description?.trim());
|
||||
@@ -342,13 +369,17 @@ async function load() {
|
||||
await ensurePersonLoaded(incomingPerson);
|
||||
personId.value = incomingPerson;
|
||||
}
|
||||
const incomingDef =
|
||||
typeof route.query.typeDefId === 'string'
|
||||
? activityTypes.byId.value.get(route.query.typeDefId)
|
||||
: undefined;
|
||||
const incomingType = props.initialType || route.query.type;
|
||||
if (
|
||||
typeof incomingType === 'string' &&
|
||||
typeOptions.some((o) => o.value === incomingType)
|
||||
) {
|
||||
type.value = incomingType as ActivityType;
|
||||
}
|
||||
const baseDef =
|
||||
typeof incomingType === 'string'
|
||||
? activityTypes.systemOf.value[incomingType as ActivityType]
|
||||
: undefined;
|
||||
const def = incomingDef?.isActive ? incomingDef : baseDef;
|
||||
if (def) pickType(def);
|
||||
const incomingTitle =
|
||||
props.initialTitle ||
|
||||
(typeof route.query.title === 'string' ? route.query.title : '');
|
||||
@@ -379,9 +410,14 @@ function payload(): CreateActivityRequest {
|
||||
const due =
|
||||
dueDate.value && dueTime.value ? new Date(`${dueDate.value}T${dueTime.value}:00`) : null;
|
||||
const reminder = parseLocalInput(reminderAt.value);
|
||||
const keys = new Set(typeFields.value.map((f) => f.key));
|
||||
return {
|
||||
title: title.value,
|
||||
type: type.value || 'task',
|
||||
typeDefId: typeDefId.value || null,
|
||||
fields: Object.fromEntries(
|
||||
Object.entries(fieldValues.value).filter(([k, v]) => keys.has(k) && !isEmptyValue(v)),
|
||||
),
|
||||
priority: priority.value,
|
||||
description: description.value || undefined,
|
||||
tags: parseTags(tagsRaw.value),
|
||||
@@ -411,6 +447,10 @@ function toggleAssignee(userId: string) {
|
||||
function validate() {
|
||||
if (!type.value) return 'نوع فعالیت را انتخاب کنید.';
|
||||
if (title.value.trim().length < 2) return 'عنوان فعالیت را بنویسید.';
|
||||
const missing = typeFields.value.find(
|
||||
(f) => f.required && f.type !== 'checkbox' && isEmptyValue(fieldValues.value[f.key]),
|
||||
);
|
||||
if (missing) return `فیلد «${missing.label}» را تکمیل کنید.`;
|
||||
if (!dueDate.value || !dueTime.value) return 'تاریخ و ساعت را مشخص کنید.';
|
||||
if (!assigneeIds.value.length) return 'حداقل یک مسئول فعالیت را انتخاب کنید.';
|
||||
if (needsNote.value && !description.value.trim()) return 'توضیح این فعالیت را بنویسید.';
|
||||
@@ -553,21 +593,28 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
<div class="field">
|
||||
<label>نوع فعالیت <i>*</i></label>
|
||||
<button class="picker type-pick" type="button" @click.stop="typeOpen = !typeOpen">
|
||||
<span class="type-ico" :class="type || 'empty'">
|
||||
<span
|
||||
class="type-ico"
|
||||
:style="typeMeta ? typeColorStyle(typeMeta.color) : undefined"
|
||||
>
|
||||
<UiIcon :name="typeMeta?.icon || 'phone'" :size="16" />
|
||||
</span>
|
||||
<span :class="{ ghost: !typeMeta }">{{ typeMeta?.label || 'انتخاب کنید' }}</span>
|
||||
<span :class="{ ghost: !typeMeta }">{{ typeMeta?.name || 'انتخاب کنید' }}</span>
|
||||
<UiIcon name="chevron" :size="16" />
|
||||
</button>
|
||||
<ul v-if="typeOpen" class="menu">
|
||||
<li v-for="opt in typeOptions" :key="opt.value">
|
||||
<button type="button" @click="type = opt.value; typeOpen = false">
|
||||
<span class="type-ico" :class="opt.value">
|
||||
<li v-for="opt in typeOptions" :key="opt.id">
|
||||
<button type="button" :class="{ on: opt.id === typeDefId }" @click="pickType(opt)">
|
||||
<span class="type-ico" :style="typeColorStyle(opt.color)">
|
||||
<UiIcon :name="opt.icon" :size="14" />
|
||||
</span>
|
||||
{{ opt.label }}
|
||||
{{ opt.name }}
|
||||
<small v-if="opt.fields.length" class="type-fields-count">
|
||||
{{ toFaDigits(opt.fields.length) }} فیلد
|
||||
</small>
|
||||
</button>
|
||||
</li>
|
||||
<li v-if="!typeOptions.length" class="menu-empty">نوع فعالیتی تعریف نشده است.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="field">
|
||||
@@ -582,6 +629,16 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="typeFields.length" class="card type-fields">
|
||||
<p class="type-fields-title">
|
||||
<span class="type-ico sm" :style="typeColorStyle(typeMeta!.color)">
|
||||
<UiIcon :name="typeMeta!.icon" :size="13" />
|
||||
</span>
|
||||
اطلاعات {{ typeMeta!.name }}
|
||||
</p>
|
||||
<ActivityFieldsEditor v-model="fieldValues" :fields="typeFields" :users="users" />
|
||||
</div>
|
||||
|
||||
<div class="card toggles">
|
||||
<label class="switch">
|
||||
<span>این فعالیت انجام شده</span>
|
||||
@@ -844,7 +901,7 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
<dl>
|
||||
<div>
|
||||
<dt>نوع فعالیت</dt>
|
||||
<dd>{{ typeMeta?.label || '—' }}</dd>
|
||||
<dd>{{ typeMeta?.name || '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>تاریخ و ساعت</dt>
|
||||
@@ -1160,19 +1217,40 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.type-ico.meeting {
|
||||
background: #f5f3ff;
|
||||
color: #7c3aed;
|
||||
.type-ico.sm {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.type-ico.task {
|
||||
background: #fff7ed;
|
||||
color: #c2410c;
|
||||
.menu button.on {
|
||||
background: #f0fdfa;
|
||||
}
|
||||
|
||||
.type-ico.note {
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
.type-fields-count {
|
||||
margin-inline-start: auto;
|
||||
color: #94a3b8;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.menu-empty {
|
||||
padding: 0.6rem;
|
||||
color: #94a3b8;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.type-fields {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.type-fields-title {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-weight: 700;
|
||||
font-size: 0.92rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.who {
|
||||
|
||||
@@ -1199,6 +1199,152 @@ export interface ActivityStageCountsDto {
|
||||
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 CustomFieldType = "text" | "number" | "date" | "boolean" | "select";
|
||||
|
||||
@@ -1267,6 +1413,14 @@ export interface ActivityDto {
|
||||
createdBy: UserSummaryDto | null;
|
||||
/** مرحلهٔ مؤثر (ستون کانبان) */
|
||||
stageId?: string | null;
|
||||
/** نوع مؤثر (سفارشی یا نوع اصلیِ همان type) */
|
||||
typeDef?: ActivityTypeSummaryDto | null;
|
||||
/** مقادیر فیلدهای اختصاصی نوع */
|
||||
fields?: ActivityFieldValues;
|
||||
/** کاربران شرکتکننده (از فیلدهای شرکتکنندگان) */
|
||||
participantIds?: string[];
|
||||
/** نام کاربران شرکتکننده — فقط در جزئیات فعالیت */
|
||||
participantUsers?: UserSummaryDto[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -1276,6 +1430,9 @@ export interface CreateActivityRequest {
|
||||
/** مرحلهٔ اولیه؛ وضعیت فعالیت از نوع مرحله گرفته میشود */
|
||||
stageId?: string | null;
|
||||
type?: ActivityType;
|
||||
/** نوع سفارشی؛ type از نوع پایهٔ آن گرفته میشود */
|
||||
typeDefId?: string | null;
|
||||
fields?: ActivityFieldValues;
|
||||
priority?: ActivityPriority;
|
||||
description?: string;
|
||||
dueAt?: string | null;
|
||||
@@ -1296,6 +1453,8 @@ export interface CreateActivityRequest {
|
||||
export interface UpdateActivityRequest {
|
||||
title?: string;
|
||||
type?: ActivityType;
|
||||
typeDefId?: string | null;
|
||||
fields?: ActivityFieldValues;
|
||||
status?: ActivityStatus;
|
||||
priority?: ActivityPriority;
|
||||
description?: string | null;
|
||||
|
||||
Reference in New Issue
Block a user