diff --git a/apps/api/prisma/migrations/20261007090000_activity_stages/migration.sql b/apps/api/prisma/migrations/20261007090000_activity_stages/migration.sql new file mode 100644 index 0000000..3848acc --- /dev/null +++ b/apps/api/prisma/migrations/20261007090000_activity_stages/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 8d24e0b..4d0000d 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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]) diff --git a/apps/api/src/activities/activities.controller.ts b/apps/api/src/activities/activities.controller.ts index 1011ad8..831a687 100644 --- a/apps/api/src/activities/activities.controller.ts +++ b/apps/api/src/activities/activities.controller.ts @@ -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); diff --git a/apps/api/src/activities/activities.module.ts b/apps/api/src/activities/activities.module.ts index 8ad52b8..f67e796 100644 --- a/apps/api/src/activities/activities.module.ts +++ b/apps/api/src/activities/activities.module.ts @@ -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 {} diff --git a/apps/api/src/activities/activities.service.ts b/apps/api/src/activities/activities.service.ts index 38771e2..2ef0eb2 100644 --- a/apps/api/src/activities/activities.service.ts +++ b/apps/api/src/activities/activities.service.ts @@ -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; +/** 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> { 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 { + 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 = 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 { + 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, diff --git a/apps/api/src/activities/activity-stages.service.ts b/apps/api/src/activities/activity-stages.service.ts new file mode 100644 index 0000000..5a62a58 --- /dev/null +++ b/apps/api/src/activities/activity-stages.service.ts @@ -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; + +@Injectable() +export class ActivityStagesService { + constructor(private readonly prisma: PrismaService) {} + + async ensureDefaults(organizationId: string): Promise { + 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 { + 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 { + 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 { + return this.defaultsOf(await this.rows(organizationId)); + } + + /** شرط Prisma برای فعالیت‌هایی که «مؤثراً» در این مرحله هستند */ + async whereForStage( + organizationId: string, + stageId: string, + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + }; + } +} diff --git a/apps/api/src/activities/dto.ts b/apps/api/src/activities/dto.ts index 1478078..7ec0e8a 100644 --- a/apps/api/src/activities/dto.ts +++ b/apps/api/src/activities/dto.ts @@ -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]; diff --git a/apps/web/src/components/activities/ActivitiesKanban.vue b/apps/web/src/components/activities/ActivitiesKanban.vue new file mode 100644 index 0000000..bb3ab37 --- /dev/null +++ b/apps/web/src/components/activities/ActivitiesKanban.vue @@ -0,0 +1,430 @@ + + + + + diff --git a/apps/web/src/components/activities/ActivityStagesManager.vue b/apps/web/src/components/activities/ActivityStagesManager.vue new file mode 100644 index 0000000..7116ce7 --- /dev/null +++ b/apps/web/src/components/activities/ActivityStagesManager.vue @@ -0,0 +1,370 @@ + + + + + diff --git a/apps/web/src/components/pipeline/PipelineBoard.vue b/apps/web/src/components/pipeline/PipelineBoard.vue index 77a40a9..7895ca0 100644 --- a/apps/web/src/components/pipeline/PipelineBoard.vue +++ b/apps/web/src/components/pipeline/PipelineBoard.vue @@ -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)" />
diff --git a/apps/web/src/components/pipeline/PipelineColumn.vue b/apps/web/src/components/pipeline/PipelineColumn.vue index 9934248..63d2cad 100644 --- a/apps/web/src/components/pipeline/PipelineColumn.vue +++ b/apps/web/src/components/pipeline/PipelineColumn.vue @@ -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 }} + {{ stage.badge }} - {{ toFaItemCount(stage.deals.length, copy.itemNoun) }} + {{ toFaItemCount(stage.total ?? stage.deals.length, copy.itemNoun) }}

{{ copy.empty }}

+ @@ -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); diff --git a/apps/web/src/lib/pipeline-board.ts b/apps/web/src/lib/pipeline-board.ts index d1d8232..c6d4059 100644 --- a/apps/web/src/lib/pipeline-board.ts +++ b/apps/web/src/lib/pipeline-board.ts @@ -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']; diff --git a/apps/web/src/views/ActivitiesView.vue b/apps/web/src/views/ActivitiesView.vue index 04a53bb..b06645a 100644 --- a/apps/web/src/views/ActivitiesView.vue +++ b/apps/web/src/views/ActivitiesView.vue @@ -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; -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(() => { - 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(() => { + if (route.query.view === 'board') return 'board'; + if (route.query.view === 'tabs') return 'tabs'; + return storedViewMode(); +}); + +/** پیش‌فرض زمانی: در نمای تب «امروز»، در نمای ستونی «همه زمان‌ها» */ +const defaultTime = computed(() => (viewMode.value === 'board' ? 'all' : 'today')); + +const filter = computed(() => { + 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([]); +const stagesManagerOpen = ref(false); +const kanbanReload = ref(0); +const stageCounts = ref({ counts: {}, total: 0 }); + +const stage = computed(() => { + 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(() => { @@ -161,17 +203,17 @@ const assigneeFilter = computed(() => const activityModal = ref<{ type?: ActivityType; title?: string; + stageId?: string; } | null>(null); const menuId = ref(null); const busy = ref(false); const pageBusy = ref(false); const busyMessage = ref('لطفاً صبر کنید…'); const importMsg = ref(''); -const counts = ref>({ +const counts = ref>({ 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>(() => ({ + 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('/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>('/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>('/activities', { + params: { ...base, filter: key, stageId: stageParam, limit: 1 }, + }), + ), + ), + api.get('/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>('/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({ + 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(() => { -
+
+
+ +
+
+ + + مدیریت ستون‌ها + + + + +
+
+ +
+