feat(activities): editable stages with tab and kanban views

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-07 11:18:53 +03:30
co-authored by Cursor
parent b4ffef9b6f
commit aa2f39256a
15 changed files with 1860 additions and 88 deletions
@@ -0,0 +1,31 @@
-- CreateTable
CREATE TABLE "ActivityStage" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"position" INTEGER NOT NULL DEFAULT 0,
"color" TEXT NOT NULL DEFAULT 'green',
"kind" "ActivityStatus" NOT NULL DEFAULT 'open',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ActivityStage_pkey" PRIMARY KEY ("id")
);
-- AlterTable
ALTER TABLE "Activity" ADD COLUMN "stageId" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "ActivityStage_organizationId_name_key" ON "ActivityStage"("organizationId", "name");
-- CreateIndex
CREATE INDEX "ActivityStage_organizationId_position_idx" ON "ActivityStage"("organizationId", "position");
-- CreateIndex
CREATE INDEX "Activity_stageId_idx" ON "Activity"("stageId");
-- AddForeignKey
ALTER TABLE "ActivityStage" ADD CONSTRAINT "ActivityStage_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "ActivityStage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+22
View File
@@ -206,6 +206,7 @@ model Organization {
reservationAppointments ReservationAppointment[]
leads Lead[]
leadStages LeadStage[]
activityStages ActivityStage[]
leadEvents LeadEvent[]
leadImportJobs LeadImportJob[]
conversationSettings ConversationSettings?
@@ -628,6 +629,23 @@ model Deal {
@@index([organizationId, sourceRef])
}
/// مراحل/ستون‌های فعالیت (تب و کانبان) — kind وضعیت فعالیت داخل این مرحله را تعیین می‌کند
model ActivityStage {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
name String
position Int @default(0)
color String @default("green")
kind ActivityStatus @default(open)
activities Activity[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([organizationId, name])
@@index([organizationId, position])
}
model Activity {
id String @id @default(cuid())
organizationId String
@@ -662,9 +680,13 @@ model Activity {
createdBy User? @relation("ActivityCreator", fields: [createdById], references: [id], onDelete: SetNull)
notes Note[]
routineTasks RoutineTask[]
/// مرحله (ستون کانبان)؛ null یا مرحلهٔ ناهم‌نوع با status → اولین مرحلهٔ هم‌نوع
stageId String?
stage ActivityStage? @relation(fields: [stageId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([stageId])
@@index([organizationId, status, dueAt])
@@index([organizationId, assigneeId])
@@index([organizationId, createdById])
@@ -18,15 +18,22 @@ import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import type { Response } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../auth/roles.guard';
import { Roles } from '../auth/roles.decorator';
import { CurrentUser } from '../auth/current-user.decorator';
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 {
CreateActivityDto,
CreateActivityStageDto,
ImportActivityRowsDto,
ListActivitiesQueryDto,
MoveActivityStageDto,
ReorderActivityStagesDto,
UpdateActivityDto,
UpdateActivityStageDto,
} from './dto';
import { brand } from '@mento/shared';
@@ -41,6 +48,7 @@ export class ActivitiesController {
constructor(
private readonly activitiesService: ActivitiesService,
private readonly excelService: ActivitiesExcelService,
private readonly stagesService: ActivityStagesService,
) {}
@Get('summary')
@@ -48,6 +56,63 @@ export class ActivitiesController {
return this.activitiesService.summary(user.organizationId, user);
}
@Get('stages')
listStages(@CurrentUser() user: Actor) {
return this.stagesService.list(user.organizationId);
}
@Post('stages')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
createStage(@CurrentUser() user: Actor, @Body() dto: CreateActivityStageDto) {
return this.stagesService.create(user.organizationId, dto);
}
@Patch('stages/reorder')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
reorderStages(@CurrentUser() user: Actor, @Body() dto: ReorderActivityStagesDto) {
return this.stagesService.reorder(user.organizationId, dto.stageIds);
}
@Patch('stages/:id')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
updateStage(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() dto: UpdateActivityStageDto,
) {
return this.stagesService.update(user.organizationId, id, dto);
}
@Delete('stages/:id')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
async removeStage(@CurrentUser() user: Actor, @Param('id') id: string) {
await this.stagesService.remove(user.organizationId, id);
return { ok: true };
}
@Get('stage-counts')
stageCounts(
@CurrentUser() user: Actor,
@Query() query: ListActivitiesQueryDto,
) {
return this.activitiesService.stageCounts(
user.organizationId,
user,
query.filter ?? 'all',
query.q,
query.desk,
{
type: query.type,
status: query.status,
assigneeId: query.assigneeId,
},
);
}
@Get()
list(
@CurrentUser() user: Actor,
@@ -75,6 +140,7 @@ export class ActivitiesController {
status: query.status,
assigneeId: query.assigneeId,
},
query.stageId ? { stageId: query.stageId } : undefined,
);
}
@@ -174,6 +240,15 @@ export class ActivitiesController {
return this.activitiesService.update(user.organizationId, id, user, dto);
}
@Patch(':id/stage')
moveStage(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() dto: MoveActivityStageDto,
) {
return this.activitiesService.moveToStage(user.organizationId, id, user, dto.stageId);
}
@Patch(':id/complete')
complete(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.activitiesService.complete(user.organizationId, id, user);
+3 -2
View File
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { ActivitiesController } from './activities.controller';
import { ActivitiesService } from './activities.service';
import { ActivitiesExcelService } from './activities-excel.service';
import { ActivityStagesService } from './activity-stages.service';
import { NotificationsModule } from '../notifications/notifications.module';
import { AutomationsModule } from '../automations/automations.module';
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
@@ -9,7 +10,7 @@ import { MessageAutomationsModule } from '../message-automations/message-automat
@Module({
imports: [NotificationsModule, AutomationsModule, MessageAutomationsModule],
controllers: [ActivitiesController],
providers: [ActivitiesService, ActivitiesExcelService],
exports: [ActivitiesService],
providers: [ActivitiesService, ActivitiesExcelService, ActivityStagesService],
exports: [ActivitiesService, ActivityStagesService],
})
export class ActivitiesModule {}
+132 -18
View File
@@ -7,6 +7,7 @@ import type {
ActivityDto,
ActivityFilter,
ActivitySummaryDto,
ActivityStageCountsDto,
CreateActivityRequest,
PaginatedResponse,
UpdateActivityRequest,
@@ -19,9 +20,19 @@ import { MessageAutomationsService } from '../message-automations/message-automa
import { type Actor, assigneeWhere, 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';
type ActivityActor = Pick<Actor, 'userId' | 'role'>;
/** stageId = شناسهٔ مرحله یا 'all'؛ وقتی ست باشد فیلتر زمانی وضعیت را تحمیل نمی‌کند (جز عقب‌افتاده) */
type StageScope = { stageId: string };
type ListFacets = {
type?: ActivityDto['type'];
status?: ActivityDto['status'];
assigneeId?: string;
};
type ActivityRow = {
id: string;
type: ActivityDto['type'];
@@ -45,9 +56,11 @@ type ActivityRow = {
assignee: { id: string; fullName: string } | null;
assignees: Array<{ id: string; fullName: string }>;
createdBy: { id: string; fullName: string } | null;
stage?: { id: string; kind: ActivityDto['status'] } | null;
};
const include = {
stage: { select: { id: true, kind: true } },
person: { select: { id: true, firstName: true, lastName: true } },
company: { select: { id: true, name: true } },
deal: { select: { id: true, title: true } },
@@ -63,6 +76,7 @@ export class ActivitiesService {
private readonly notifications: NotificationsService,
private readonly automations: AutomationsService,
private readonly messageAutomations: MessageAutomationsService,
private readonly stages: ActivityStagesService,
) {}
async summary(
@@ -151,11 +165,8 @@ export class ActivitiesService {
to?: string;
},
desk?: 'mine' | 'team',
facets?: {
type?: ActivityDto['type'];
status?: ActivityDto['status'];
assigneeId?: string;
},
facets?: ListFacets,
stageScope?: StageScope,
): Promise<PaginatedResponse<ActivityDto>> {
const where = this.buildWhere(
organizationId,
@@ -166,7 +177,14 @@ export class ActivitiesService {
range,
desk,
facets,
stageScope,
);
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 take = Math.min(Math.max(limit, 1), 200);
const [rows, total] = await this.prisma.$transaction([
this.prisma.activity.findMany({
@@ -180,13 +198,87 @@ export class ActivitiesService {
]);
return {
items: rows.map((row) => this.toDto(row)),
items: rows.map((row) => this.toDto(row, defaults)),
total,
page,
limit: take,
};
}
/** تعداد فعالیت هر مرحله با همان فیلترهای لیست (زمان، جستجو، میز، نوع، کارشناس) */
async stageCounts(
organizationId: string,
actor: ActivityActor,
filter: ActivityFilter,
q?: string,
desk?: 'mine' | 'team',
facets?: ListFacets,
): Promise<ActivityStageCountsDto> {
const rows = await this.stages.rows(organizationId);
const defaults = this.stages.defaultsOf(rows);
const kindOf = new Map(rows.map((r) => [r.id, r.kind]));
const where = this.buildWhere(
organizationId,
actor,
filter,
q,
undefined,
undefined,
desk,
facets,
{ stageId: 'all' },
);
const groups = await this.prisma.activity.groupBy({
by: ['status', 'stageId'],
where,
_count: { _all: true },
});
const counts: Record<string, number> = Object.fromEntries(rows.map((r) => [r.id, 0]));
let total = 0;
for (const g of groups) {
const n = g._count._all;
total += n;
const target =
g.stageId && kindOf.get(g.stageId) === g.status ? g.stageId : defaults[g.status];
if (target) counts[target] = (counts[target] ?? 0) + n;
}
return { counts, total };
}
async moveToStage(
organizationId: string,
id: string,
actor: ActivityActor,
stageId: string,
): Promise<ActivityDto> {
await this.ensureExists(organizationId, id, actor);
const stage = await this.stages.get(organizationId, stageId);
const existing = await this.prisma.activity.findFirst({
where: { id, organizationId },
select: { status: true, completedAt: true },
});
const row = await this.prisma.activity.update({
where: { id },
data: {
stageId: stage.id,
status: stage.kind,
completedAt:
stage.kind === 'done'
? existing?.status === 'done'
? existing.completedAt ?? new Date()
: new Date()
: stage.kind === 'open'
? null
: undefined,
},
include,
});
if (stage.kind === 'done' && existing?.status !== 'done') {
await this.fireDone(organizationId, row);
}
return this.toDto(row, await this.stages.defaults(organizationId));
}
async get(
organizationId: string,
id: string,
@@ -197,7 +289,7 @@ export class ActivitiesService {
include,
});
if (!row) throw new NotFoundException('فعالیت پیدا نشد');
return this.toDto(row);
return this.toDto(row, await this.stages.defaults(organizationId));
}
async create(
@@ -217,10 +309,21 @@ export class ActivitiesService {
: [userId],
);
const assigneeId = assigneeIds[0] ?? userId;
const initialStageId = emptyToNull(dto.stageId ?? null);
const initialStage = initialStageId
? await this.stages.get(organizationId, initialStageId)
: null;
const row = await this.prisma.activity.create({
data: {
organizationId,
...(initialStage
? {
stageId: initialStage.id,
status: initialStage.kind,
completedAt: initialStage.kind === 'done' ? new Date() : null,
}
: {}),
title: dto.title.trim(),
type: dto.type ?? 'task',
priority: dto.priority ?? 'medium',
@@ -267,8 +370,11 @@ export class ActivitiesService {
assigneeId: row.assignee?.id ?? null,
ownerId: row.assignee?.id ?? null,
});
if (initialStage?.kind === 'done') {
await this.fireDone(organizationId, row);
}
return this.toDto(row);
return this.toDto(row, await this.stages.defaults(organizationId));
}
async update(
@@ -345,7 +451,7 @@ export class ActivitiesService {
if (dto.status === 'done' && existing?.status !== 'done') {
await this.fireDone(organizationId, row);
}
return this.toDto(row);
return this.toDto(row, await this.stages.defaults(organizationId));
}
async complete(
@@ -366,7 +472,7 @@ export class ActivitiesService {
if (existing?.status !== 'done') {
await this.fireDone(organizationId, row);
}
return this.toDto(row);
return this.toDto(row, await this.stages.defaults(organizationId));
}
async remove(
@@ -393,11 +499,8 @@ export class ActivitiesService {
to?: string;
},
desk?: 'mine' | 'team',
facets?: {
type?: ActivityDto['type'];
status?: ActivityDto['status'];
assigneeId?: string;
},
facets?: ListFacets,
stageScope?: StageScope,
): Prisma.ActivityWhereInput {
const { start, end } = dayBounds();
const scope: Prisma.ActivityWhereInput =
@@ -466,14 +569,24 @@ export class ActivitiesService {
}
// Explicit status facet overrides the status implied by time tabs.
const impliedOpen = facets?.status ? { status: facets.status } : { status: 'open' as const };
// With stage tabs/columns the stage decides status; time filters only narrow dueAt.
const impliedOpen = facets?.status
? { status: facets.status }
: stageScope
? {}
: { status: 'open' as const };
const impliedDone = facets?.status ? { status: facets.status } : { status: 'done' as const };
switch (filter) {
case 'today':
return { ...base, ...impliedOpen, dueAt: { gte: start, lt: end } };
case 'overdue':
return { ...base, ...impliedOpen, dueAt: { lt: start } };
// عقب‌افتاده ذاتاً یعنی باز و موعدگذشته
return {
...base,
...(facets?.status ? { status: facets.status } : { status: 'open' as const }),
dueAt: { lt: start },
};
case 'upcoming':
return {
...base,
@@ -571,8 +684,9 @@ export class ActivitiesService {
}
}
private toDto(row: ActivityRow): ActivityDto {
private toDto(row: ActivityRow, defaults?: StageDefaults): ActivityDto {
return {
stageId: this.stages.effectiveStageId(row, defaults),
id: row.id,
type: row.type,
status: row.status,
@@ -0,0 +1,232 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import type {
ActivityStageColor,
ActivityStageDto,
ActivityStatus,
} from '@mento/shared';
import { ACTIVITY_STAGE_COLORS } from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
const DEFAULT_STAGES: Array<{ name: string; color: ActivityStageColor; kind: ActivityStatus }> = [
{ name: 'در صف', color: 'orange', kind: 'open' },
{ name: 'در حال انجام', color: 'blue', kind: 'open' },
{ name: 'انجام‌شده', color: 'green', kind: 'done' },
{ name: 'لغو شده', color: 'rose', kind: 'canceled' },
];
const KINDS: ActivityStatus[] = ['open', 'done', 'canceled'];
type StageRow = {
id: string;
name: string;
position: number;
color: string;
kind: ActivityStatus;
};
export type StageDefaults = Record<ActivityStatus, string | null>;
@Injectable()
export class ActivityStagesService {
constructor(private readonly prisma: PrismaService) {}
async ensureDefaults(organizationId: string): Promise<void> {
const count = await this.prisma.activityStage.count({ where: { organizationId } });
if (count > 0) return;
await this.prisma.activityStage.createMany({
data: DEFAULT_STAGES.map((s, i) => ({
organizationId,
name: s.name,
color: s.color,
kind: s.kind,
position: i,
})),
skipDuplicates: true,
});
}
async rows(organizationId: string): Promise<StageRow[]> {
await this.ensureDefaults(organizationId);
return this.prisma.activityStage.findMany({
where: { organizationId },
orderBy: [{ position: 'asc' }, { createdAt: 'asc' }],
select: { id: true, name: true, position: true, color: true, kind: true },
});
}
async list(organizationId: string): Promise<ActivityStageDto[]> {
return (await this.rows(organizationId)).map((r) => this.toDto(r));
}
/** اولین مرحلهٔ هر نوع = مقصد فعالیت‌های بدون مرحله یا با مرحلهٔ ناهم‌نوع */
defaultsOf(rows: StageRow[]): StageDefaults {
const out: StageDefaults = { open: null, done: null, canceled: null };
for (const r of rows) {
if (!out[r.kind]) out[r.kind] = r.id;
}
return out;
}
async defaults(organizationId: string): Promise<StageDefaults> {
return this.defaultsOf(await this.rows(organizationId));
}
/** شرط Prisma برای فعالیت‌هایی که «مؤثراً» در این مرحله هستند */
async whereForStage(
organizationId: string,
stageId: string,
): Promise<Prisma.ActivityWhereInput> {
const rows = await this.rows(organizationId);
const stage = rows.find((r) => r.id === stageId);
if (!stage) throw new BadRequestException('مرحلهٔ انتخاب‌شده معتبر نیست');
const isDefault = this.defaultsOf(rows)[stage.kind] === stage.id;
if (!isDefault) return { status: stage.kind, stageId: stage.id };
return {
status: stage.kind,
OR: [
{ stageId: stage.id },
{ stageId: null },
{ stage: { kind: { not: stage.kind } } },
],
};
}
effectiveStageId(
row: { status: ActivityStatus; stage?: { id: string; kind: ActivityStatus } | null },
defaults?: StageDefaults,
): string | null {
if (row.stage && row.stage.kind === row.status) return row.stage.id;
return defaults?.[row.status] ?? null;
}
async get(organizationId: string, id: string): Promise<StageRow> {
const row = await this.prisma.activityStage.findFirst({
where: { id, organizationId },
select: { id: true, name: true, position: true, color: true, kind: true },
});
if (!row) throw new NotFoundException('مرحله پیدا نشد');
return row;
}
async create(
organizationId: string,
dto: { name: string; color?: string; kind?: ActivityStatus },
): Promise<ActivityStageDto> {
await this.ensureDefaults(organizationId);
const max = await this.prisma.activityStage.aggregate({
where: { organizationId },
_max: { position: true },
});
try {
const row = await this.prisma.activityStage.create({
data: {
organizationId,
name: this.cleanName(dto.name),
color: this.cleanColor(dto.color),
kind: dto.kind && KINDS.includes(dto.kind) ? dto.kind : 'open',
position: (max._max.position ?? -1) + 1,
},
});
return this.toDto(row);
} catch (e) {
throw this.mapError(e);
}
}
async update(
organizationId: string,
id: string,
dto: { name?: string; color?: string; kind?: ActivityStatus },
): Promise<ActivityStageDto> {
const stage = await this.get(organizationId, id);
const nextKind = dto.kind && KINDS.includes(dto.kind) ? dto.kind : stage.kind;
if (nextKind !== stage.kind) {
await this.assertNotLastOfKind(organizationId, stage);
}
try {
const row = await this.prisma.$transaction(async (tx) => {
if (nextKind !== stage.kind) {
// فعالیت‌ها وضعیتشان را نگه می‌دارند و به اولین مرحلهٔ هم‌نوع خودشان برمی‌گردند
await tx.activity.updateMany({
where: { organizationId, stageId: id },
data: { stageId: null },
});
}
return tx.activityStage.update({
where: { id },
data: {
...(dto.name != null ? { name: this.cleanName(dto.name) } : {}),
...(dto.color != null ? { color: this.cleanColor(dto.color) } : {}),
kind: nextKind,
},
});
});
return this.toDto(row);
} catch (e) {
throw this.mapError(e);
}
}
async reorder(organizationId: string, stageIds: string[]): Promise<ActivityStageDto[]> {
await this.prisma.$transaction(
stageIds.map((id, position) =>
this.prisma.activityStage.updateMany({
where: { id, organizationId },
data: { position },
}),
),
);
return this.list(organizationId);
}
async remove(organizationId: string, id: string): Promise<void> {
const stage = await this.get(organizationId, id);
await this.assertNotLastOfKind(organizationId, stage);
// onDelete: SetNull → فعالیت‌ها به اولین مرحلهٔ هم‌نوع وضعیتشان منتقل می‌شوند
await this.prisma.activityStage.delete({ where: { id } });
}
private async assertNotLastOfKind(organizationId: string, stage: StageRow) {
const sameKind = await this.prisma.activityStage.count({
where: { organizationId, kind: stage.kind },
});
if (sameKind <= 1) {
const label =
stage.kind === 'done' ? 'انجام‌شده' : stage.kind === 'canceled' ? 'لغو شده' : 'باز';
throw new BadRequestException(
`حداقل یک ستون از نوع «${label}» لازم است؛ اول یک ستون دیگر از همین نوع بسازید`,
);
}
}
private cleanName(raw: string): string {
const name = raw?.trim() ?? '';
if (!name) throw new BadRequestException('نام ستون لازم است');
if (name.length > 80) throw new BadRequestException('نام ستون حداکثر ۸۰ کاراکتر است');
return name;
}
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: StageRow): ActivityStageDto {
return {
id: row.id,
name: row.name,
position: row.position,
color: this.cleanColor(row.color),
kind: row.kind,
};
}
}
+54
View File
@@ -66,6 +66,11 @@ export class ListActivitiesQueryDto {
@IsString()
assigneeId?: string;
/** شناسهٔ مرحله یا 'all' — حالت تب/کانبان مراحل */
@IsOptional()
@IsString()
stageId?: string;
@IsOptional()
@IsString()
personId?: string;
@@ -102,12 +107,61 @@ export class ListActivitiesQueryDto {
limit?: number = 50;
}
const STAGE_COLORS = ['green', 'orange', 'purple', 'blue', 'rose', 'slate'] as const;
export class CreateActivityStageDto {
@IsString()
@MinLength(1)
@MaxLength(80)
name!: string;
@IsOptional()
@IsIn(STAGE_COLORS)
color?: (typeof STAGE_COLORS)[number];
@IsOptional()
@IsIn(ACTIVITY_STATUSES)
kind?: (typeof ACTIVITY_STATUSES)[number];
}
export class UpdateActivityStageDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(80)
name?: string;
@IsOptional()
@IsIn(STAGE_COLORS)
color?: (typeof STAGE_COLORS)[number];
@IsOptional()
@IsIn(ACTIVITY_STATUSES)
kind?: (typeof ACTIVITY_STATUSES)[number];
}
export class ReorderActivityStagesDto {
@IsArray()
@IsString({ each: true })
stageIds!: string[];
}
export class MoveActivityStageDto {
@IsString()
stageId!: string;
}
export class CreateActivityDto {
@IsString()
@MinLength(2)
@MaxLength(200)
title!: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
stageId?: string | null;
@IsOptional()
@IsIn(ACTIVITY_TYPES)
type?: (typeof ACTIVITY_TYPES)[number];
@@ -0,0 +1,430 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import axios from 'axios';
import type {
ActivityDto,
ActivityStageDto,
ActivityType,
PaginatedResponse,
} from '@mento/shared';
import { api } from '@/lib/api';
import { calendarDaysBetween, formatClockFa, formatJalaliDay, startOfLocalDay, toFaDigits } from '@/lib/jalali';
import PipelineBoard from '@/components/pipeline/PipelineBoard.vue';
import DealCardOverlay from '@/components/pipeline/DealCardOverlay.vue';
import {
ACTIVITY_BOARD_COPY,
initialsFromTitle,
moveStageToIndex,
type PipelineAccent,
type PipelineDeal,
type PipelineStage,
} from '@/lib/pipeline-board';
const PAGE = 30;
const props = defineProps<{
stages: ActivityStageDto[];
/** فیلترهای مشترک با حالت تب (filter زمانی، q، desk، type، assigneeId) */
params: Record<string, string | undefined>;
canManage: boolean;
reloadKey: number;
}>();
const emit = defineEmits<{
changed: [];
stagesChanged: [];
add: [stageId: string];
error: [message: string];
}>();
const router = useRouter();
type Column = {
items: ActivityDto[];
total: number;
page: number;
loading: boolean;
};
const columns = ref<Record<string, Column>>({});
const initialLoading = ref(false);
const draggingId = ref<string | null>(null);
const dropStageId = ref<string | null>(null);
const draggingColumnId = ref<string | null>(null);
const overlayDeal = ref<PipelineDeal | null>(null);
const overlayAccent = ref<PipelineAccent>('green');
const overlayPos = ref({ x: 0, y: 0 });
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: 'انجام‌شده',
canceled: 'لغو',
};
const orderedStages = computed(() => {
if (!localOrder.value) return props.stages;
const byId = new Map(props.stages.map((s) => [s.id, s]));
return localOrder.value.map((id) => byId.get(id)).filter(Boolean) as ActivityStageDto[];
});
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 dateText(item: ActivityDto) {
if (!item.dueAt) return 'بدون موعد';
const due = new Date(item.dueAt);
const days = calendarDaysBetween(due, new Date());
const day =
days === 0 ? 'امروز' : days === 1 ? 'دیروز' : days === -1 ? 'فردا' : formatJalaliDay(due);
const late =
item.status === 'open' &&
startOfLocalDay(due).getTime() < startOfLocalDay().getTime();
return `${late ? 'عقب‌افتاده · ' : ''}${day} · ${formatClockFa(due)}`;
}
function toCard(item: ActivityDto, stageId: string): PipelineDeal {
const party = item.company?.name || item.person?.fullName || item.deal?.title || '';
const assigneeNames = item.assignees?.length
? item.assignees.map((a) => a.fullName)
: item.assignee
? [item.assignee.fullName]
: [];
return {
id: item.id,
title: item.title,
description: [typeLabel[item.type], party].filter(Boolean).join(' · '),
amount: null,
date: dateText(item),
initials: initialsFromTitle(party || item.title),
stageId,
assigneeNames,
ownerId: item.assignee?.id ?? null,
assigneeIds: (item.assignees ?? []).map((a) => a.id),
};
}
const boardStages = computed<PipelineStage[]>(() =>
orderedStages.value.map((s) => {
const col = columns.value[s.id];
const items = col?.items ?? [];
const total = col?.total ?? 0;
return {
id: s.id,
title: s.name,
color: s.color as PipelineAccent,
deals: items.map((item) => toCard(item, s.id)),
total,
hasMore: items.length < total,
loadingMore: col?.loading ?? false,
badge: KIND_BADGE[s.kind],
};
}),
);
async function fetchPage(stageId: string, page: number) {
const { data } = await api.get<PaginatedResponse<ActivityDto>>('/activities', {
params: { ...props.params, stageId, page, limit: PAGE },
});
return data;
}
let loadSeq = 0;
async function loadAll() {
const seq = ++loadSeq;
initialLoading.value = true;
try {
const results = await Promise.all(
props.stages.map(async (s) => [s.id, await fetchPage(s.id, 1)] as const),
);
if (seq !== loadSeq) return;
const next: Record<string, Column> = {};
for (const [id, res] of results) {
next[id] = { items: res.items, total: res.total, page: 1, loading: false };
}
columns.value = next;
} catch (e) {
if (seq === loadSeq) emit('error', apiMessage(e, 'بارگذاری ستون‌های فعالیت ناموفق بود.'));
} finally {
if (seq === loadSeq) initialLoading.value = false;
}
}
async function loadMore(stageId: string) {
const col = columns.value[stageId];
if (!col || col.loading) return;
col.loading = true;
try {
const res = await fetchPage(stageId, col.page + 1);
const seen = new Set(col.items.map((i) => i.id));
col.items = [...col.items, ...res.items.filter((i) => !seen.has(i.id))];
col.total = res.total;
col.page += 1;
} catch (e) {
emit('error', apiMessage(e, 'بارگذاری بیشتر ناموفق بود.'));
} finally {
col.loading = false;
}
}
watch(
() => [
props.stages.map((s) => s.id).sort().join(','),
JSON.stringify(props.params),
props.reloadKey,
],
() => void loadAll(),
{ immediate: true },
);
watch(
() => props.stages,
() => {
localOrder.value = null;
},
);
function findStageOf(id: string) {
return Object.entries(columns.value).find(([, col]) => col.items.some((i) => i.id === id))?.[0];
}
function onDragStart(deal: PipelineDeal, event: DragEvent) {
draggingId.value = deal.id;
overlayDeal.value = deal;
const sid = findStageOf(deal.id);
overlayAccent.value =
(props.stages.find((s) => s.id === sid)?.color as PipelineAccent | undefined) ?? 'green';
overlayPos.value = { x: (event.clientX || 0) + 12, y: (event.clientY || 0) + 12 };
event.dataTransfer?.setData('text/plain', deal.id);
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
const img = new Image();
img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
event.dataTransfer.setDragImage(img, 0, 0);
}
}
function onDragEnd() {
draggingId.value = null;
dropStageId.value = null;
overlayDeal.value = null;
}
function onDragOver(stageId: string, event: DragEvent) {
event.preventDefault();
dropStageId.value = stageId;
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
if (event.clientX || event.clientY) {
overlayPos.value = { x: event.clientX + 12, y: event.clientY + 12 };
}
}
async function moveCard(activityId: string, toStageId: string, index?: number) {
const fromStageId = findStageOf(activityId);
if (!fromStageId || fromStageId === toStageId) return;
const from = columns.value[fromStageId]!;
const to = columns.value[toStageId];
if (!to) return;
const item = from.items.find((i) => i.id === activityId)!;
const toStage = props.stages.find((s) => s.id === toStageId)!;
const snapshot = {
fromItems: from.items,
fromTotal: from.total,
toItems: to.items,
toTotal: to.total,
};
const optimistic: ActivityDto = { ...item, status: toStage.kind, stageId: toStageId };
from.items = from.items.filter((i) => i.id !== activityId);
from.total = Math.max(0, from.total - 1);
const nextTo = [...to.items];
const at = index == null || index < 0 || index > nextTo.length ? 0 : index;
nextTo.splice(at, 0, optimistic);
to.items = nextTo;
to.total += 1;
moving.value = true;
try {
const { data } = await api.patch<ActivityDto>(`/activities/${activityId}/stage`, {
stageId: toStageId,
});
to.items = to.items.map((i) => (i.id === activityId ? data : i));
emit('changed');
} catch (e) {
from.items = snapshot.fromItems;
from.total = snapshot.fromTotal;
to.items = snapshot.toItems;
to.total = snapshot.toTotal;
emit('error', apiMessage(e, 'جابه‌جایی فعالیت ناموفق بود.'));
} finally {
moving.value = false;
}
}
async function onDrop(stageId: string, event: DragEvent, index?: number) {
event.preventDefault();
const raw = event.dataTransfer?.getData('text/plain') || draggingColumnId.value || draggingId.value || '';
dropStageId.value = null;
draggingId.value = null;
overlayDeal.value = null;
if (raw.startsWith('stage:') || draggingColumnId.value) {
const fromId = raw.replace(/^stage:/, '') || draggingColumnId.value;
draggingColumnId.value = null;
if (fromId) await reorderColumn(fromId, stageId);
return;
}
if (!raw || moving.value) return;
await moveCard(raw, stageId, index);
}
async function persistOrder(ids: string[]) {
const prev = localOrder.value;
localOrder.value = ids;
stageBusy.value = true;
try {
await api.patch('/activities/stages/reorder', { stageIds: ids });
emit('stagesChanged');
} catch (e) {
localOrder.value = prev;
emit('error', apiMessage(e, 'جابه‌جایی ستون ناموفق بود.'));
} finally {
stageBusy.value = false;
}
}
async function reorderColumn(fromId: string, toId: string) {
if (!props.canManage || stageBusy.value || fromId === toId) return;
const current = boardStages.value;
const to = current.findIndex((s) => s.id === toId);
if (to < 0) return;
await persistOrder(moveStageToIndex(current, fromId, to).map((s) => s.id));
}
async function onMoveStage(stageId: string, dir: -1 | 1) {
if (stageBusy.value) return;
const current = boardStages.value;
const from = current.findIndex((s) => s.id === stageId);
const to = from + dir;
if (from < 0 || to < 0 || to >= current.length) return;
await persistOrder(moveStageToIndex(current, stageId, to).map((s) => s.id));
}
async function onRenameStage(stageId: string, name: string) {
if (stageBusy.value) return;
stageBusy.value = true;
try {
await api.patch(`/activities/stages/${stageId}`, { name });
emit('stagesChanged');
} catch (e) {
emit('error', apiMessage(e, 'تغییر نام ستون ناموفق بود.'));
} finally {
stageBusy.value = false;
}
}
async function onDeleteStage(stageId: string) {
if (stageBusy.value) return;
const stage = props.stages.find((s) => s.id === stageId);
const total = columns.value[stageId]?.total ?? 0;
const ok = window.confirm(
`ستون «${stage?.name ?? ''}» حذف شود؟${
total ? `\n${toFaDigits(total)} فعالیت این ستون به اولین ستون هم‌وضعیت منتقل می‌شوند.` : ''
}`,
);
if (!ok) return;
stageBusy.value = true;
try {
await api.delete(`/activities/stages/${stageId}`);
emit('stagesChanged');
emit('changed');
} catch (e) {
emit('error', apiMessage(e, 'حذف ستون ناموفق بود.'));
} finally {
stageBusy.value = false;
}
}
async function onAddStage(name: string) {
if (stageBusy.value) return;
stageBusy.value = true;
try {
await api.post('/activities/stages', { name, kind: 'open' });
emit('stagesChanged');
} catch (e) {
emit('error', apiMessage(e, 'افزودن ستون ناموفق بود.'));
} finally {
stageBusy.value = false;
}
}
function openActivity(id: string) {
void router.push({ name: 'activity-detail', params: { id } });
}
</script>
<template>
<div class="kanban-wrap">
<p v-if="initialLoading && !Object.keys(columns).length" class="muted">در حال بارگذاری ستون‌ها…</p>
<PipelineBoard
v-else
:stages="boardStages"
:dragging-id="draggingId"
:drop-stage-id="dropStageId"
:can-manage="canManage"
:copy="ACTIVITY_BOARD_COPY"
@drag-start="onDragStart"
@drag-end="onDragEnd"
@drag-over="onDragOver"
@drop="onDrop"
@add-deal="emit('add', $event)"
@open-deal="openActivity"
@menu-deal="openActivity"
@rename-stage="onRenameStage"
@delete-stage="onDeleteStage"
@move-stage="onMoveStage"
@column-drag-start="(id: string) => (draggingColumnId = id)"
@column-drag-end="draggingColumnId = null; dropStageId = null"
@add-stage="onAddStage"
@load-more="loadMore"
/>
<Teleport to="body">
<DealCardOverlay
v-if="overlayDeal && draggingId"
:deal="overlayDeal"
:accent="overlayAccent"
:copy="ACTIVITY_BOARD_COPY"
:x="overlayPos.x"
:y="overlayPos.y"
/>
</Teleport>
</div>
</template>
<style scoped>
.kanban-wrap {
container-type: inline-size;
container-name: pipeline;
min-width: 0;
}
.muted {
margin: 0;
color: #64748b;
}
</style>
@@ -0,0 +1,370 @@
<script setup lang="ts">
import { reactive, ref, watch } from 'vue';
import axios from 'axios';
import type {
ActivityStageColor,
ActivityStageDto,
ActivityStatus,
CreateActivityStageRequest,
} from '@mento/shared';
import {
ACTIVITY_STAGE_COLOR_LABELS,
ACTIVITY_STAGE_COLORS,
ACTIVITY_STAGE_KIND_LABELS,
} from '@mento/shared';
import { api } from '@/lib/api';
import { PIPELINE_ACCENT } from '@/lib/pipeline-board';
import { UiButton, UiModal } from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
const props = defineProps<{
open: boolean;
stages: ActivityStageDto[];
}>();
const emit = defineEmits<{
close: [];
changed: [];
}>();
const KINDS: ActivityStatus[] = ['open', 'done', 'canceled'];
type Draft = { name: string; color: ActivityStageColor; kind: ActivityStatus };
const drafts = ref<Record<string, Draft>>({});
const busyId = ref('');
const error = ref('');
const createForm = reactive<Required<CreateActivityStageRequest>>({
name: '',
color: 'blue',
kind: 'open',
});
watch(
() => [props.open, props.stages] as const,
() => {
drafts.value = Object.fromEntries(
props.stages.map((s) => [s.id, { name: s.name, color: s.color, kind: s.kind }]),
);
},
{ 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 isDirty(stage: ActivityStageDto) {
const d = drafts.value[stage.id];
return Boolean(
d && (d.name.trim() !== stage.name || d.color !== stage.color || d.kind !== stage.kind),
);
}
async function save(stage: ActivityStageDto) {
const d = drafts.value[stage.id];
if (!d || !isDirty(stage)) return;
if (!d.name.trim()) {
error.value = 'نام ستون نمی‌تواند خالی باشد.';
return;
}
if (
d.kind !== stage.kind &&
!window.confirm(
'با تغییر نوع ستون، فعالیت‌های فعلی آن وضعیتشان را نگه می‌دارند و به اولین ستون هم‌وضعیت خودشان منتقل می‌شوند. ادامه می‌دهید؟',
)
) {
return;
}
busyId.value = stage.id;
error.value = '';
try {
await api.patch(`/activities/stages/${stage.id}`, {
name: d.name.trim(),
color: d.color,
kind: d.kind,
});
emit('changed');
} catch (e) {
error.value = apiMessage(e, 'ذخیرهٔ ستون ناموفق بود.');
} finally {
busyId.value = '';
}
}
async function move(index: number, dir: -1 | 1) {
const to = index + dir;
if (to < 0 || to >= props.stages.length) return;
const ids = props.stages.map((s) => s.id);
const [moved] = ids.splice(index, 1);
ids.splice(to, 0, moved!);
busyId.value = 'order';
error.value = '';
try {
await api.patch('/activities/stages/reorder', { stageIds: ids });
emit('changed');
} catch (e) {
error.value = apiMessage(e, 'جابه‌جایی ستون ناموفق بود.');
} finally {
busyId.value = '';
}
}
async function remove(stage: ActivityStageDto) {
if (
!window.confirm(
`ستون «${stage.name}» حذف شود؟ فعالیت‌های آن به اولین ستون هم‌وضعیت منتقل می‌شوند.`,
)
) {
return;
}
busyId.value = stage.id;
error.value = '';
try {
await api.delete(`/activities/stages/${stage.id}`);
emit('changed');
} catch (e) {
error.value = apiMessage(e, 'حذف ستون ناموفق بود.');
} finally {
busyId.value = '';
}
}
async function create() {
if (!createForm.name.trim()) return;
busyId.value = 'create';
error.value = '';
try {
await api.post('/activities/stages', { ...createForm, name: createForm.name.trim() });
createForm.name = '';
emit('changed');
} catch (e) {
error.value = apiMessage(e, 'افزودن ستون ناموفق بود.');
} finally {
busyId.value = '';
}
}
</script>
<template>
<UiModal :open="open" title="مدیریت ستون‌ها و تب‌های فعالیت" @close="emit('close')">
<div class="manager">
<p class="hint">
هر ستون یک «نوع» دارد: فعالیتی که به ستون <b>انجام‌شده</b> کشیده شود خودکار انجام‌شده ثبت
می‌شود و در ستون <b>لغو شده</b> لغو می‌شود. از هر نوع حداقل یک ستون لازم است.
</p>
<p v-if="error" class="error">{{ error }}</p>
<div class="rows">
<div
v-for="(stage, index) in stages"
:key="stage.id"
class="row"
:style="{ '--dot': PIPELINE_ACCENT[drafts[stage.id]?.color ?? stage.color].solid }"
>
<span class="dot" aria-hidden="true" />
<input
v-if="drafts[stage.id]"
v-model="drafts[stage.id]!.name"
class="name"
maxlength="80"
aria-label="نام ستون"
@keydown.enter.prevent="save(stage)"
/>
<select v-if="drafts[stage.id]" v-model="drafts[stage.id]!.kind" aria-label="نوع ستون">
<option v-for="k in KINDS" :key="k" :value="k">{{ ACTIVITY_STAGE_KIND_LABELS[k] }}</option>
</select>
<select v-if="drafts[stage.id]" v-model="drafts[stage.id]!.color" aria-label="رنگ ستون">
<option v-for="c in ACTIVITY_STAGE_COLORS" :key="c" :value="c">
{{ ACTIVITY_STAGE_COLOR_LABELS[c] }}
</option>
</select>
<div class="row-actions">
<button
type="button"
class="icon"
:disabled="index === 0 || Boolean(busyId)"
title="انتقال به قبل"
aria-label="انتقال به قبل"
@click="move(index, -1)"
>
▲
</button>
<button
type="button"
class="icon"
:disabled="index === stages.length - 1 || Boolean(busyId)"
title="انتقال به بعد"
aria-label="انتقال به بعد"
@click="move(index, 1)"
>
▼
</button>
<UiButton
size="sm"
type="button"
:disabled="!isDirty(stage) || Boolean(busyId)"
:loading="busyId === stage.id"
@click="save(stage)"
>
ذخیره
</UiButton>
<button
type="button"
class="icon danger"
:disabled="Boolean(busyId)"
title="حذف ستون"
aria-label="حذف ستون"
@click="remove(stage)"
>
<UiIcon name="trash" :size="15" />
</button>
</div>
</div>
</div>
<form class="create" @submit.prevent="create">
<strong>ستون جدید</strong>
<div class="create-fields">
<input v-model="createForm.name" maxlength="80" placeholder="مثلاً: منتظر پاسخ مشتری" />
<select v-model="createForm.kind" aria-label="نوع ستون جدید">
<option v-for="k in KINDS" :key="k" :value="k">{{ ACTIVITY_STAGE_KIND_LABELS[k] }}</option>
</select>
<select v-model="createForm.color" aria-label="رنگ ستون جدید">
<option v-for="c in ACTIVITY_STAGE_COLORS" :key="c" :value="c">
{{ ACTIVITY_STAGE_COLOR_LABELS[c] }}
</option>
</select>
<UiButton
variant="primary"
type="submit"
:disabled="!createForm.name.trim() || Boolean(busyId)"
:loading="busyId === 'create'"
>
افزودن
</UiButton>
</div>
</form>
</div>
</UiModal>
</template>
<style scoped>
.manager {
display: grid;
gap: 0.9rem;
}
.hint {
margin: 0;
color: #64748b;
font-size: 0.84rem;
line-height: 1.8;
}
.error {
margin: 0;
color: #b91c1c;
}
.rows {
display: grid;
gap: 0.5rem;
}
.row {
display: grid;
grid-template-columns: 0.7rem minmax(0, 1fr) 9rem 6.5rem auto;
gap: 0.5rem;
align-items: center;
padding: 0.5rem 0.6rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
}
.dot {
width: 0.65rem;
height: 0.65rem;
border-radius: 999px;
background: var(--dot);
}
input,
select {
min-height: var(--control-h);
padding: 0 0.6rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
font: inherit;
font-size: 0.86rem;
min-width: 0;
}
.row-actions {
display: flex;
align-items: center;
gap: 0.3rem;
}
.icon {
width: 2rem;
height: 2rem;
display: grid;
place-items: center;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
color: #475569;
font-size: 0.7rem;
cursor: pointer;
}
.icon:disabled {
opacity: 0.4;
cursor: default;
}
.icon.danger {
color: #dc2626;
}
.create {
display: grid;
gap: 0.5rem;
padding-top: 0.8rem;
border-top: 1px solid var(--line);
}
.create-fields {
display: grid;
grid-template-columns: minmax(0, 1fr) 9rem 6.5rem auto;
gap: 0.5rem;
}
@media (max-width: 640px) {
.row {
grid-template-columns: 0.7rem minmax(0, 1fr) auto;
}
.row select {
grid-column: 2 / -1;
}
.row-actions {
grid-column: 1 / -1;
justify-content: flex-end;
}
.create-fields {
grid-template-columns: 1fr;
}
}
</style>
@@ -30,6 +30,7 @@ const emit = defineEmits<{
columnDragStart: [stageId: string, event: DragEvent];
columnDragEnd: [];
addStage: [name: string];
loadMore: [stageId: string];
}>();
const adding = ref(false);
@@ -82,6 +83,7 @@ function submitAdd() {
@move="(id, dir) => emit('moveStage', id, dir)"
@column-drag-start="(id, e) => emit('columnDragStart', id, e)"
@column-drag-end="emit('columnDragEnd')"
@load-more="emit('loadMore', $event)"
/>
<section v-if="canManage" class="add-col">
@@ -32,6 +32,7 @@ const emit = defineEmits<{
move: [stageId: string, dir: -1 | 1];
columnDragStart: [stageId: string, event: DragEvent];
columnDragEnd: [];
loadMore: [stageId: string];
}>();
const renaming = ref(false);
@@ -153,8 +154,9 @@ onBeforeUnmount(() => {
>
{{ stage.title }}
</h2>
<small v-if="stage.badge && !renaming" class="stage-badge">{{ stage.badge }}</small>
</div>
<span class="count">{{ toFaItemCount(stage.deals.length, copy.itemNoun) }}</span>
<span class="count">{{ toFaItemCount(stage.total ?? stage.deals.length, copy.itemNoun) }}</span>
<div v-if="canManage" ref="menuRoot" class="more-wrap">
<button
type="button"
@@ -225,6 +227,15 @@ onBeforeUnmount(() => {
/>
</div>
<p v-if="!stage.deals.length" class="empty">{{ copy.empty }}</p>
<button
v-if="stage.hasMore"
type="button"
class="load-more"
:disabled="stage.loadingMore"
@click="emit('loadMore', stage.id)"
>
{{ stage.loadingMore ? 'در حال بارگذاری…' : 'نمایش بیشتر' }}
</button>
</div>
<AddDealButton :accent="stage.color" :label="copy.addItem" @add="emit('addDeal', stage.id)" />
@@ -425,6 +436,38 @@ onBeforeUnmount(() => {
font-size: 0.85rem;
}
.stage-badge {
flex: 0 0 auto;
padding: 0.05rem 0.4rem;
border-radius: 999px;
background: var(--stage-soft);
color: var(--stage-text);
font-size: 0.66rem;
font-weight: 700;
white-space: nowrap;
}
.load-more {
min-height: var(--control-h);
border: 1px dashed var(--stage-border);
border-radius: var(--radius);
background: #fff;
color: var(--stage-text);
font: inherit;
font-size: 0.82rem;
font-weight: 700;
cursor: pointer;
}
.load-more:hover:not(:disabled) {
background: var(--stage-soft);
}
.load-more:disabled {
opacity: 0.6;
cursor: default;
}
@container pipeline (max-width: 720px) {
.column {
flex: 0 0 min(20rem, 86cqi);
+35 -1
View File
@@ -1,6 +1,6 @@
import { matchesSearch } from '@/lib/search';
export type PipelineAccent = 'green' | 'orange' | 'purple';
export type PipelineAccent = 'green' | 'orange' | 'purple' | 'blue' | 'rose' | 'slate';
export type PipelineDeal = {
id: string;
@@ -49,6 +49,16 @@ export const PROJECT_BOARD_COPY: PipelineUiCopy = {
hideAmount: true,
};
export const ACTIVITY_BOARD_COPY: PipelineUiCopy = {
itemNoun: 'فعالیت',
addItem: 'افزودن فعالیت',
empty: 'فعالیتی نیست',
addItemAria: 'افزودن فعالیت به این ستون',
itemMenuAria: 'گزینه‌های فعالیت',
listColTitle: 'فعالیت',
hideAmount: true,
};
export type PipelineStage = {
id: string;
title: string;
@@ -56,6 +66,12 @@ export type PipelineStage = {
deals: PipelineDeal[];
isWon?: boolean;
isLost?: boolean;
/** تعداد کل از سرور وقتی کارت‌ها صفحه‌بندی‌شده بارگذاری می‌شوند */
total?: number;
hasMore?: boolean;
loadingMore?: boolean;
/** برچسب کوچک کنار عنوان ستون (مثلاً نوع مرحله) */
badge?: string;
};
export const PIPELINE_ACCENT: Record<
@@ -80,6 +96,24 @@ export const PIPELINE_ACCENT: Record<
text: '#6d28d9',
border: '#ddd6fe',
},
blue: {
soft: '#eff6ff',
solid: '#3b82f6',
text: '#1d4ed8',
border: '#bfdbfe',
},
rose: {
soft: '#fff1f2',
solid: '#f43f5e',
text: '#be123c',
border: '#fecdd3',
},
slate: {
soft: '#f8fafc',
solid: '#64748b',
text: '#334155',
border: '#e2e8f0',
},
};
const ACCENT_CYCLE: PipelineAccent[] = ['green', 'orange', 'purple'];
+360 -65
View File
@@ -19,7 +19,11 @@ import {
UiPagination,
} from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
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 { PIPELINE_ACCENT } from '@/lib/pipeline-board';
import {
ACTIVITY_EXCEL_ALIASES,
ACTIVITY_EXCEL_FIELDS,
@@ -35,6 +39,8 @@ import ActivityFormView from '@/views/ActivityFormView.vue';
import type {
ActivityDto,
ActivityFilter,
ActivityStageCountsDto,
ActivityStageDto,
ActivityStatus,
ActivityType,
ImportContactsResult,
@@ -43,7 +49,7 @@ import type {
} from '@mento/shared';
import { brand } from '@mento/shared';
import { useAuthStore } from '@/stores/auth';
import { canViewAllRecords } from '@/lib/roles';
import { canManageSettings, canViewAllRecords } from '@/lib/roles';
import { PAGE_SIZE_OPTIONS, usePageSize } from '@/lib/pageSize';
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
@@ -78,14 +84,22 @@ type TypeFilter = 'all' | ActivityType;
type StatusFilter = 'all' | ActivityStatus;
type UiStatus = 'done' | 'progress' | 'planned' | 'overdue' | 'canceled';
type DeskScope = 'mine' | 'team';
type ViewMode = 'tabs' | 'board';
type TimeFilter = Exclude<ActivityFilter, 'done'>;
const TABS: Array<{ key: ActivityFilter; label: string }> = [
const TIME_TABS: Array<{ key: TimeFilter; label: string }> = [
{ key: 'today', label: 'امروز' },
{ key: 'overdue', label: 'عقب‌افتاده' },
{ key: 'upcoming', label: 'بعداً' },
{ key: 'done', label: 'انجام‌شده' },
{ key: 'all', label: 'همه' },
{ key: 'all', label: 'همه زمان‌ها' },
];
const TIME_KEYS: TimeFilter[] = TIME_TABS.map((t) => t.key);
const VIEW_OPTIONS = [
{ value: 'tabs' as const, label: 'نمای تب', icon: 'list' as const },
{ value: 'board' as const, label: 'نمای ستونی', icon: 'grid' as const },
];
const VIEW_STORAGE_KEY = 'activities-view-mode';
const DESK_TABS: Array<{ key: DeskScope; label: string }> = [
{ key: 'mine', label: 'میز من' },
@@ -113,13 +127,41 @@ const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const showDeskTabs = computed(() => canViewAllRecords(auth.user?.role));
const canManageStages = computed(() => canManageSettings(auth.user?.role));
const filter = computed<ActivityFilter>(() => {
const value = typeof route.query.filter === 'string' ? route.query.filter : 'today';
if (['today', 'overdue', 'upcoming', 'done', 'all'].includes(value)) {
return value as ActivityFilter;
function storedViewMode(): ViewMode {
try {
return localStorage.getItem(VIEW_STORAGE_KEY) === 'board' ? 'board' : 'tabs';
} catch {
return 'tabs';
}
return 'today';
}
const viewMode = computed<ViewMode>(() => {
if (route.query.view === 'board') return 'board';
if (route.query.view === 'tabs') return 'tabs';
return storedViewMode();
});
/** پیش‌فرض زمانی: در نمای تب «امروز»، در نمای ستونی «همه زمان‌ها» */
const defaultTime = computed<TimeFilter>(() => (viewMode.value === 'board' ? 'all' : 'today'));
const filter = computed<TimeFilter>(() => {
const value = typeof route.query.filter === 'string' ? route.query.filter : '';
if (TIME_KEYS.includes(value as TimeFilter)) return value as TimeFilter;
if (value === 'done') return 'all';
return defaultTime.value;
});
const stages = ref<ActivityStageDto[]>([]);
const stagesManagerOpen = ref(false);
const kanbanReload = ref(0);
const stageCounts = ref<ActivityStageCountsDto>({ counts: {}, total: 0 });
const stage = computed<string>(() => {
const value = typeof route.query.stage === 'string' ? route.query.stage : 'all';
if (value === 'all' || stages.value.some((s) => s.id === value)) return value;
return 'all';
});
const desk = computed<DeskScope>(() => {
@@ -161,17 +203,17 @@ const assigneeFilter = computed(() =>
const activityModal = ref<{
type?: ActivityType;
title?: string;
stageId?: string;
} | null>(null);
const menuId = ref<string | null>(null);
const busy = ref(false);
const pageBusy = ref(false);
const busyMessage = ref('لطفاً صبر کنید…');
const importMsg = ref('');
const counts = ref<Record<ActivityFilter, number>>({
const counts = ref<Record<TimeFilter, number>>({
today: 0,
overdue: 0,
upcoming: 0,
done: 0,
all: 0,
});
@@ -209,7 +251,26 @@ const items = computed(() => data.value?.items ?? []);
const total = computed(() => data.value?.total ?? 0);
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)));
const filterLabel = computed(() => TABS.find((t) => t.key === filter.value)?.label ?? 'امروز');
const stageById = computed(() => new Map(stages.value.map((s) => [s.id, s])));
const filterLabel = computed(() => {
const time = TIME_TABS.find((t) => t.key === filter.value)?.label ?? 'امروز';
const st = stage.value === 'all' ? 'همه ستون‌ها' : stageById.value.get(stage.value)?.name ?? '';
return `${st} · ${time}`;
});
/** فیلترهای مشترک لیست، شمارش‌ها و کانبان */
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,
status: statusFilter.value === 'all' ? undefined : statusFilter.value,
assigneeId: assigneeFilter.value || undefined,
}));
function stageDotStyle(s: ActivityStageDto) {
return { background: PIPELINE_ACCENT[s.color].solid };
}
const activeFacetCount = computed(() => {
let n = 0;
if (typeFilter.value !== 'all') n += 1;
@@ -220,7 +281,14 @@ const activeFacetCount = computed(() => {
const filterBtnLabel = computed(() =>
activeFacetCount.value ? `فیلتر (${toFaDigits(activeFacetCount.value)})` : 'فیلتر',
);
const trulyEmpty = computed(() => !loading.value && counts.value.all === 0 && !q.value.trim());
const trulyEmpty = computed(
() =>
!loading.value &&
counts.value.all === 0 &&
stage.value === 'all' &&
!q.value.trim() &&
!activeFacetCount.value,
);
const listEmpty = computed(() => !loading.value && !items.value.length);
function isOverdue(item: ActivityDto) {
@@ -269,21 +337,43 @@ function subtitle(item: ActivityDto) {
return item.deal?.title || item.description || '';
}
async function loadStages() {
try {
const { data: rows } = await api.get<ActivityStageDto[]>('/activities/stages');
stages.value = rows;
} catch {
error.value = 'بارگذاری ستون‌های فعالیت ناموفق بود.';
}
}
/** شمارش تب‌های زمانی (با ستون انتخابی در نمای تب) و شمارش ستون‌ها (با زمان انتخابی) */
async function loadCounts() {
const keys: ActivityFilter[] = ['today', 'overdue', 'upcoming', 'done', 'all'];
const deskParam = showDeskTabs.value ? desk.value : undefined;
const results = await Promise.all(
keys.map((key) =>
api.get<PaginatedResponse<ActivityDto>>('/activities', {
params: { filter: key, limit: 1, desk: deskParam },
}),
),
);
const next = { ...counts.value };
keys.forEach((key, i) => {
next[key] = results[i]?.data.total ?? 0;
});
counts.value = next;
const base = sharedParams.value;
const stageParam = viewMode.value === 'tabs' ? stage.value : 'all';
try {
const [timeResults, stageRes] = await Promise.all([
Promise.all(
TIME_KEYS.map((key) =>
api.get<PaginatedResponse<ActivityDto>>('/activities', {
params: { ...base, filter: key, stageId: stageParam, limit: 1 },
}),
),
),
api.get<ActivityStageCountsDto>('/activities/stage-counts', { params: base }),
]);
const next = { ...counts.value };
TIME_KEYS.forEach((key, i) => {
next[key] = timeResults[i]?.data.total ?? 0;
});
counts.value = next;
stageCounts.value = stageRes.data;
} catch {
/* شمارش‌ها اختیاری‌اند؛ خطای اصلی را load نشان می‌دهد */
}
}
async function refreshAll() {
await Promise.all([viewMode.value === 'tabs' ? load() : Promise.resolve(), loadCounts()]);
}
async function loadUsers() {
@@ -300,20 +390,17 @@ async function loadUsers() {
}
async function load() {
if (viewMode.value !== 'tabs') return;
loading.value = true;
error.value = '';
menuId.value = null;
try {
const { data: res } = await api.get<PaginatedResponse<ActivityDto>>('/activities', {
params: {
filter: filter.value,
q: q.value || undefined,
...sharedParams.value,
stageId: stage.value,
page: page.value,
limit: pageSize.value,
desk: showDeskTabs.value ? desk.value : undefined,
type: typeFilter.value === 'all' ? undefined : typeFilter.value,
status: statusFilter.value === 'all' ? undefined : statusFilter.value,
assigneeId: assigneeFilter.value || undefined,
},
});
data.value = res;
@@ -339,18 +426,65 @@ function setDesk(next: DeskScope) {
});
}
function setFilter(next: ActivityFilter) {
function setFilter(next: TimeFilter) {
filterOpen.value = false;
void router.replace({
name: 'activities',
query: {
...route.query,
filter: next === 'today' ? undefined : next,
filter: next === defaultTime.value ? undefined : next,
page: undefined,
},
});
}
function setStage(next: string) {
void router.replace({
name: 'activities',
query: {
...route.query,
stage: next === 'all' ? undefined : next,
page: undefined,
},
});
}
function setViewMode(next: ViewMode) {
try {
localStorage.setItem(VIEW_STORAGE_KEY, next);
} catch {
/* ignore */
}
void router.replace({
name: 'activities',
query: {
...route.query,
view: next,
filter: undefined,
page: undefined,
},
});
}
const viewModel = computed<ViewMode>({
get: () => viewMode.value,
set: (v) => setViewMode(v),
});
function onStagesChanged() {
void loadStages().then(() => loadCounts());
}
function openAddInStage(stageId: string) {
activityModal.value = { stageId };
}
function onActivitySaved() {
activityModal.value = null;
kanbanReload.value += 1;
void refreshAll();
}
function setPage(next: number) {
const safe = Math.min(Math.max(1, next), pageCount.value);
void router.replace({
@@ -394,17 +528,7 @@ function setTypeFilter(next: TypeFilter) {
}
function setStatusFilter(next: StatusFilter) {
// Avoid empty results when a time-tab forces a different status.
const nextFilter =
next === 'all'
? route.query.filter
: next === 'done'
? 'done'
: 'all';
patchFacetQuery({
status: next === 'all' ? undefined : next,
filter: nextFilter === 'today' || !nextFilter ? undefined : String(nextFilter),
});
patchFacetQuery({ status: next === 'all' ? undefined : next });
}
function setAssigneeFilter(next: string) {
@@ -426,7 +550,7 @@ async function complete(id: string) {
menuId.value = null;
try {
await api.patch(`/activities/${id}/complete`);
await Promise.all([load(), loadCounts()]);
await refreshAll();
} catch {
error.value = 'ثبت انجام‌شدن ناموفق بود.';
}
@@ -476,7 +600,8 @@ async function onImportDone(data: ImportContactsResult) {
.map((e) => `ردیف ${e.row}: ${e.message}`)
.join(' | ');
}
await Promise.all([load(), loadCounts()]);
kanbanReload.value += 1;
await refreshAll();
}
function onImportFail(message: string) {
@@ -497,6 +622,8 @@ watch(
route.query.type,
route.query.status,
route.query.assigneeId,
route.query.stage,
route.query.view,
],
() => {
q.value = typeof route.query.q === 'string' ? route.query.q : '';
@@ -505,6 +632,21 @@ watch(
},
);
/** لینک‌های قدیمی ?filter=done → ستون انجام‌شده */
function migrateLegacyDoneFilter() {
if (route.query.filter !== 'done') return false;
const doneStage = stages.value.find((s) => s.kind === 'done');
void router.replace({
name: 'activities',
query: {
...route.query,
filter: 'all',
stage: doneStage?.id,
},
});
return true;
}
let mobileQuery: MediaQueryList | null = null;
function syncMobile() {
isMobile.value = mobileQuery?.matches ?? false;
@@ -515,8 +657,11 @@ onMounted(() => {
syncMobile();
mobileQuery.addEventListener('change', syncMobile);
void loadUsers();
void load();
void loadCounts();
void loadStages().then(() => {
if (migrateLegacyDoneFilter()) return;
void load();
void loadCounts();
});
});
onUnmounted(() => {
@@ -575,32 +720,77 @@ onUnmounted(() => {
</template>
</UiPageHeader>
<div v-if="showDeskTabs" class="desk-tabs" role="tablist" aria-label="محدوده میز کار">
<div class="view-row">
<div v-if="showDeskTabs" class="desk-tabs" role="tablist" aria-label="محدوده میز کار">
<button
v-for="tab in DESK_TABS"
:key="tab.key"
type="button"
role="tab"
:class="{ on: desk === tab.key }"
:aria-selected="desk === tab.key"
@click="setDesk(tab.key)"
>
{{ tab.label }}
</button>
</div>
<div class="view-row-end">
<UiButton
v-if="canManageStages"
variant="ghost"
size="sm"
type="button"
@click="stagesManagerOpen = true"
>
<UiIcon name="settings" :size="15" />
مدیریت ستون‌ها
</UiButton>
<UiSegmented v-model="viewModel" :options="VIEW_OPTIONS">
<template #icon="{ option }">
<UiIcon :name="option.icon ?? 'list'" :size="15" />
</template>
</UiSegmented>
</div>
</div>
<div v-if="viewMode === 'tabs'" class="tabs" role="tablist" aria-label="ستون‌های فعالیت">
<button
v-for="tab in DESK_TABS"
:key="tab.key"
type="button"
role="tab"
:class="{ on: desk === tab.key }"
:aria-selected="desk === tab.key"
@click="setDesk(tab.key)"
:class="{ on: stage === 'all' }"
:aria-selected="stage === 'all'"
@click="setStage('all')"
>
{{ tab.label }}
<span>همه</span>
<em>{{ toFaDigits(stageCounts.total.toLocaleString('en-US')) }}</em>
</button>
<button
v-for="s in stages"
:key="s.id"
type="button"
role="tab"
:class="{ on: stage === s.id }"
:aria-selected="stage === s.id"
@click="setStage(s.id)"
>
<i class="stage-dot" :style="stageDotStyle(s)" aria-hidden="true" />
<span>{{ s.name }}</span>
<em>{{ toFaDigits((stageCounts.counts[s.id] ?? 0).toLocaleString('en-US')) }}</em>
</button>
</div>
<div class="tabs" role="tablist" aria-label="فیلتر فعالیت">
<div class="time-chips" role="tablist" aria-label="فیلتر زمانی">
<button
v-for="tab in TABS"
v-for="tab in TIME_TABS"
:key="tab.key"
type="button"
role="tab"
:class="{ on: filter === tab.key }"
:class="{ on: filter === tab.key, late: tab.key === 'overdue' }"
:aria-selected="filter === tab.key"
@click="setFilter(tab.key)"
>
<span>{{ tab.label }}</span>
<em>{{ toFaDigits(counts[tab.key]) }}</em>
{{ tab.label }}
<em>{{ toFaDigits(counts[tab.key].toLocaleString('en-US')) }}</em>
</button>
</div>
@@ -681,7 +871,20 @@ onUnmounted(() => {
<p v-if="error" class="error">{{ error }}</p>
<p v-if="importMsg" class="ok">{{ importMsg }}</p>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<ActivitiesKanban
v-if="viewMode === 'board'"
:stages="stages"
:params="sharedParams"
:can-manage="canManageStages"
:reload-key="kanbanReload"
@changed="loadCounts"
@stages-changed="onStagesChanged"
@add="openAddInStage"
@error="(msg: string) => (error = msg)"
/>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<div v-else-if="listEmpty" class="empty-card">
<div class="art" aria-hidden="true">
@@ -780,11 +983,15 @@ onUnmounted(() => {
{{ item.company?.name || item.person?.fullName }}
</span>
</div>
<div v-if="colOn('status')">
<div v-if="colOn('status')" class="status-cell">
<span class="status" :class="uiStatus(item)">
<UiIcon :name="statusIcon[uiStatus(item)]" :size="13" />
{{ statusLabel[uiStatus(item)] }}
</span>
<small v-if="item.stageId && stageById.get(item.stageId)" class="stage-name">
<i class="stage-dot" :style="stageDotStyle(stageById.get(item.stageId)!)" aria-hidden="true" />
{{ stageById.get(item.stageId)!.name }}
</small>
</div>
<div v-if="!isMobile" class="row-more" @click.stop>
<UiDropdownMenu
@@ -901,10 +1108,19 @@ onUnmounted(() => {
embedded
:initial-type="activityModal.type"
:initial-title="activityModal.title"
@saved="activityModal = null; void load(); void loadCounts()"
:initial-stage-id="activityModal.stageId"
@saved="onActivitySaved"
@cancel="activityModal = null"
/>
</UiModal>
<ActivityStagesManager
v-if="canManageStages"
:open="stagesManagerOpen"
:stages="stages"
@close="stagesManagerOpen = false"
@changed="onStagesChanged(); kanbanReload += 1"
/>
</section>
</template>
@@ -1049,6 +1265,85 @@ h1 {
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
}
.view-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
flex-wrap: wrap;
}
.view-row-end {
display: flex;
align-items: center;
gap: 0.5rem;
margin-inline-start: auto;
}
.stage-dot {
display: inline-block;
width: 0.5rem;
height: 0.5rem;
border-radius: 999px;
flex: 0 0 auto;
}
.time-chips {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
}
.time-chips button {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: 1px solid var(--line);
background: #fff;
color: #475569;
font: inherit;
font-size: 0.82rem;
font-weight: 700;
padding: 0.32rem 0.75rem;
border-radius: 999px;
cursor: pointer;
}
.time-chips button em {
font-style: normal;
font-size: 0.7rem;
color: #64748b;
}
.time-chips button.late em {
color: #dc2626;
}
.time-chips button.on {
border-color: #5eead4;
background: #f0fdfa;
color: #0f766e;
}
.status-cell {
display: grid;
gap: 0.25rem;
justify-items: start;
min-width: 0;
}
.stage-name {
display: inline-flex;
align-items: center;
gap: 0.3rem;
color: #64748b;
font-size: 0.72rem;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tabs {
display: flex;
gap: 0.2rem;
+5 -1
View File
@@ -46,6 +46,7 @@ const props = withDefaults(
initialPersonId?: string;
initialType?: ActivityType;
initialTitle?: string;
initialStageId?: string;
}>(),
{ embedded: false },
);
@@ -438,7 +439,10 @@ async function submit(next: 'detail' | 'new' = 'detail') {
try {
let savedId = id.value;
if (isNew.value) {
const { data } = await api.post<ActivityDto>('/activities', payload());
const { data } = await api.post<ActivityDto>('/activities', {
...payload(),
...(props.initialStageId ? { stageId: props.initialStageId } : {}),
});
savedId = data.id;
if (isDone.value) {
await api.patch(`/activities/${savedId}/complete`);
+65
View File
@@ -1138,6 +1138,67 @@ export type ActivityStatus = "open" | "done" | "canceled";
export type ActivityPriority = "very_low" | "low" | "medium" | "high" | "very_high";
export type ActivityFilter = "today" | "overdue" | "upcoming" | "done" | "all";
export type ActivityStageColor = "green" | "orange" | "purple" | "blue" | "rose" | "slate";
export const ACTIVITY_STAGE_COLORS: ActivityStageColor[] = [
"green",
"orange",
"purple",
"blue",
"rose",
"slate",
];
export const ACTIVITY_STAGE_COLOR_LABELS: Record<ActivityStageColor, string> = {
green: "سبز",
orange: "نارنجی",
purple: "بنفش",
blue: "آبی",
rose: "قرمز",
slate: "خاکستری",
};
/** نوع مرحله = وضعیتی که فعالیت با رفتن به آن ستون می‌گیرد */
export const ACTIVITY_STAGE_KIND_LABELS: Record<ActivityStatus, string> = {
open: "باز (در جریان)",
done: "انجام‌شده",
canceled: "لغو شده",
};
export interface ActivityStageDto {
id: string;
name: string;
position: number;
color: ActivityStageColor;
kind: ActivityStatus;
}
export interface CreateActivityStageRequest {
name: string;
color?: ActivityStageColor;
kind?: ActivityStatus;
}
export interface UpdateActivityStageRequest {
name?: string;
color?: ActivityStageColor;
kind?: ActivityStatus;
}
export interface ReorderActivityStagesRequest {
stageIds: string[];
}
export interface MoveActivityStageRequest {
stageId: string;
}
/** شمارش فعالیت‌ها به تفکیک مرحله با فیلترهای فعلی */
export interface ActivityStageCountsDto {
counts: Record<string, number>;
total: number;
}
export type CustomFieldEntity = "person" | "company" | "deal";
export type CustomFieldType = "text" | "number" | "date" | "boolean" | "select";
@@ -1204,12 +1265,16 @@ export interface ActivityDto {
/** همه مسئولان انجام فعالیت */
assignees: UserSummaryDto[];
createdBy: UserSummaryDto | null;
/** مرحلهٔ مؤثر (ستون کانبان) */
stageId?: string | null;
createdAt: string;
updatedAt: string;
}
export interface CreateActivityRequest {
title: string;
/** مرحلهٔ اولیه؛ وضعیت فعالیت از نوع مرحله گرفته می‌شود */
stageId?: string | null;
type?: ActivityType;
priority?: ActivityPriority;
description?: string;