feat(activities): editable stages with tab and kanban views
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||||
@@ -206,6 +206,7 @@ model Organization {
|
|||||||
reservationAppointments ReservationAppointment[]
|
reservationAppointments ReservationAppointment[]
|
||||||
leads Lead[]
|
leads Lead[]
|
||||||
leadStages LeadStage[]
|
leadStages LeadStage[]
|
||||||
|
activityStages ActivityStage[]
|
||||||
leadEvents LeadEvent[]
|
leadEvents LeadEvent[]
|
||||||
leadImportJobs LeadImportJob[]
|
leadImportJobs LeadImportJob[]
|
||||||
conversationSettings ConversationSettings?
|
conversationSettings ConversationSettings?
|
||||||
@@ -628,6 +629,23 @@ model Deal {
|
|||||||
@@index([organizationId, sourceRef])
|
@@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 {
|
model Activity {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
organizationId String
|
organizationId String
|
||||||
@@ -662,9 +680,13 @@ model Activity {
|
|||||||
createdBy User? @relation("ActivityCreator", fields: [createdById], references: [id], onDelete: SetNull)
|
createdBy User? @relation("ActivityCreator", fields: [createdById], references: [id], onDelete: SetNull)
|
||||||
notes Note[]
|
notes Note[]
|
||||||
routineTasks RoutineTask[]
|
routineTasks RoutineTask[]
|
||||||
|
/// مرحله (ستون کانبان)؛ null یا مرحلهٔ ناهمنوع با status → اولین مرحلهٔ همنوع
|
||||||
|
stageId String?
|
||||||
|
stage ActivityStage? @relation(fields: [stageId], references: [id], onDelete: SetNull)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([stageId])
|
||||||
@@index([organizationId, status, dueAt])
|
@@index([organizationId, status, dueAt])
|
||||||
@@index([organizationId, assigneeId])
|
@@index([organizationId, assigneeId])
|
||||||
@@index([organizationId, createdById])
|
@@index([organizationId, createdById])
|
||||||
|
|||||||
@@ -18,15 +18,22 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
|||||||
import { memoryStorage } from 'multer';
|
import { memoryStorage } from 'multer';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
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 { CurrentUser } from '../auth/current-user.decorator';
|
||||||
import type { Actor } from '../common/record-scope';
|
import type { Actor } from '../common/record-scope';
|
||||||
import { ActivitiesService } from './activities.service';
|
import { ActivitiesService } from './activities.service';
|
||||||
import { ActivitiesExcelService } from './activities-excel.service';
|
import { ActivitiesExcelService } from './activities-excel.service';
|
||||||
|
import { ActivityStagesService } from './activity-stages.service';
|
||||||
import {
|
import {
|
||||||
CreateActivityDto,
|
CreateActivityDto,
|
||||||
|
CreateActivityStageDto,
|
||||||
ImportActivityRowsDto,
|
ImportActivityRowsDto,
|
||||||
ListActivitiesQueryDto,
|
ListActivitiesQueryDto,
|
||||||
|
MoveActivityStageDto,
|
||||||
|
ReorderActivityStagesDto,
|
||||||
UpdateActivityDto,
|
UpdateActivityDto,
|
||||||
|
UpdateActivityStageDto,
|
||||||
} from './dto';
|
} from './dto';
|
||||||
import { brand } from '@mento/shared';
|
import { brand } from '@mento/shared';
|
||||||
|
|
||||||
@@ -41,6 +48,7 @@ export class ActivitiesController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly activitiesService: ActivitiesService,
|
private readonly activitiesService: ActivitiesService,
|
||||||
private readonly excelService: ActivitiesExcelService,
|
private readonly excelService: ActivitiesExcelService,
|
||||||
|
private readonly stagesService: ActivityStagesService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('summary')
|
@Get('summary')
|
||||||
@@ -48,6 +56,63 @@ export class ActivitiesController {
|
|||||||
return this.activitiesService.summary(user.organizationId, user);
|
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()
|
@Get()
|
||||||
list(
|
list(
|
||||||
@CurrentUser() user: Actor,
|
@CurrentUser() user: Actor,
|
||||||
@@ -75,6 +140,7 @@ export class ActivitiesController {
|
|||||||
status: query.status,
|
status: query.status,
|
||||||
assigneeId: query.assigneeId,
|
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);
|
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')
|
@Patch(':id/complete')
|
||||||
complete(@CurrentUser() user: Actor, @Param('id') id: string) {
|
complete(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||||
return this.activitiesService.complete(user.organizationId, id, user);
|
return this.activitiesService.complete(user.organizationId, id, user);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
|||||||
import { ActivitiesController } from './activities.controller';
|
import { ActivitiesController } from './activities.controller';
|
||||||
import { ActivitiesService } from './activities.service';
|
import { ActivitiesService } from './activities.service';
|
||||||
import { ActivitiesExcelService } from './activities-excel.service';
|
import { ActivitiesExcelService } from './activities-excel.service';
|
||||||
|
import { ActivityStagesService } from './activity-stages.service';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
import { AutomationsModule } from '../automations/automations.module';
|
import { AutomationsModule } from '../automations/automations.module';
|
||||||
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
|
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
|
||||||
@@ -9,7 +10,7 @@ import { MessageAutomationsModule } from '../message-automations/message-automat
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [NotificationsModule, AutomationsModule, MessageAutomationsModule],
|
imports: [NotificationsModule, AutomationsModule, MessageAutomationsModule],
|
||||||
controllers: [ActivitiesController],
|
controllers: [ActivitiesController],
|
||||||
providers: [ActivitiesService, ActivitiesExcelService],
|
providers: [ActivitiesService, ActivitiesExcelService, ActivityStagesService],
|
||||||
exports: [ActivitiesService],
|
exports: [ActivitiesService, ActivityStagesService],
|
||||||
})
|
})
|
||||||
export class ActivitiesModule {}
|
export class ActivitiesModule {}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
ActivityDto,
|
ActivityDto,
|
||||||
ActivityFilter,
|
ActivityFilter,
|
||||||
ActivitySummaryDto,
|
ActivitySummaryDto,
|
||||||
|
ActivityStageCountsDto,
|
||||||
CreateActivityRequest,
|
CreateActivityRequest,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
UpdateActivityRequest,
|
UpdateActivityRequest,
|
||||||
@@ -19,9 +20,19 @@ import { MessageAutomationsService } from '../message-automations/message-automa
|
|||||||
import { type Actor, assigneeWhere, dealAccessWhere, ownerWhere } from '../common/record-scope';
|
import { type Actor, assigneeWhere, dealAccessWhere, ownerWhere } from '../common/record-scope';
|
||||||
import { normalizeTags } from '../common/tags';
|
import { normalizeTags } from '../common/tags';
|
||||||
import { buildSearchWhere, ci } from '../common/search';
|
import { buildSearchWhere, ci } from '../common/search';
|
||||||
|
import { ActivityStagesService, type StageDefaults } from './activity-stages.service';
|
||||||
|
|
||||||
type ActivityActor = Pick<Actor, 'userId' | 'role'>;
|
type ActivityActor = Pick<Actor, 'userId' | 'role'>;
|
||||||
|
|
||||||
|
/** stageId = شناسهٔ مرحله یا 'all'؛ وقتی ست باشد فیلتر زمانی وضعیت را تحمیل نمیکند (جز عقبافتاده) */
|
||||||
|
type StageScope = { stageId: string };
|
||||||
|
|
||||||
|
type ListFacets = {
|
||||||
|
type?: ActivityDto['type'];
|
||||||
|
status?: ActivityDto['status'];
|
||||||
|
assigneeId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
type ActivityRow = {
|
type ActivityRow = {
|
||||||
id: string;
|
id: string;
|
||||||
type: ActivityDto['type'];
|
type: ActivityDto['type'];
|
||||||
@@ -45,9 +56,11 @@ type ActivityRow = {
|
|||||||
assignee: { id: string; fullName: string } | null;
|
assignee: { id: string; fullName: string } | null;
|
||||||
assignees: Array<{ id: string; fullName: string }>;
|
assignees: Array<{ id: string; fullName: string }>;
|
||||||
createdBy: { id: string; fullName: string } | null;
|
createdBy: { id: string; fullName: string } | null;
|
||||||
|
stage?: { id: string; kind: ActivityDto['status'] } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const include = {
|
const include = {
|
||||||
|
stage: { select: { id: true, kind: true } },
|
||||||
person: { select: { id: true, firstName: true, lastName: true } },
|
person: { select: { id: true, firstName: true, lastName: true } },
|
||||||
company: { select: { id: true, name: true } },
|
company: { select: { id: true, name: true } },
|
||||||
deal: { select: { id: true, title: true } },
|
deal: { select: { id: true, title: true } },
|
||||||
@@ -63,6 +76,7 @@ export class ActivitiesService {
|
|||||||
private readonly notifications: NotificationsService,
|
private readonly notifications: NotificationsService,
|
||||||
private readonly automations: AutomationsService,
|
private readonly automations: AutomationsService,
|
||||||
private readonly messageAutomations: MessageAutomationsService,
|
private readonly messageAutomations: MessageAutomationsService,
|
||||||
|
private readonly stages: ActivityStagesService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async summary(
|
async summary(
|
||||||
@@ -151,11 +165,8 @@ export class ActivitiesService {
|
|||||||
to?: string;
|
to?: string;
|
||||||
},
|
},
|
||||||
desk?: 'mine' | 'team',
|
desk?: 'mine' | 'team',
|
||||||
facets?: {
|
facets?: ListFacets,
|
||||||
type?: ActivityDto['type'];
|
stageScope?: StageScope,
|
||||||
status?: ActivityDto['status'];
|
|
||||||
assigneeId?: string;
|
|
||||||
},
|
|
||||||
): Promise<PaginatedResponse<ActivityDto>> {
|
): Promise<PaginatedResponse<ActivityDto>> {
|
||||||
const where = this.buildWhere(
|
const where = this.buildWhere(
|
||||||
organizationId,
|
organizationId,
|
||||||
@@ -166,7 +177,14 @@ export class ActivitiesService {
|
|||||||
range,
|
range,
|
||||||
desk,
|
desk,
|
||||||
facets,
|
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 take = Math.min(Math.max(limit, 1), 200);
|
||||||
const [rows, total] = await this.prisma.$transaction([
|
const [rows, total] = await this.prisma.$transaction([
|
||||||
this.prisma.activity.findMany({
|
this.prisma.activity.findMany({
|
||||||
@@ -180,13 +198,87 @@ export class ActivitiesService {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: rows.map((row) => this.toDto(row)),
|
items: rows.map((row) => this.toDto(row, defaults)),
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
limit: take,
|
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(
|
async get(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
id: string,
|
id: string,
|
||||||
@@ -197,7 +289,7 @@ export class ActivitiesService {
|
|||||||
include,
|
include,
|
||||||
});
|
});
|
||||||
if (!row) throw new NotFoundException('فعالیت پیدا نشد');
|
if (!row) throw new NotFoundException('فعالیت پیدا نشد');
|
||||||
return this.toDto(row);
|
return this.toDto(row, await this.stages.defaults(organizationId));
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(
|
async create(
|
||||||
@@ -217,10 +309,21 @@ export class ActivitiesService {
|
|||||||
: [userId],
|
: [userId],
|
||||||
);
|
);
|
||||||
const assigneeId = assigneeIds[0] ?? 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({
|
const row = await this.prisma.activity.create({
|
||||||
data: {
|
data: {
|
||||||
organizationId,
|
organizationId,
|
||||||
|
...(initialStage
|
||||||
|
? {
|
||||||
|
stageId: initialStage.id,
|
||||||
|
status: initialStage.kind,
|
||||||
|
completedAt: initialStage.kind === 'done' ? new Date() : null,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
title: dto.title.trim(),
|
title: dto.title.trim(),
|
||||||
type: dto.type ?? 'task',
|
type: dto.type ?? 'task',
|
||||||
priority: dto.priority ?? 'medium',
|
priority: dto.priority ?? 'medium',
|
||||||
@@ -267,8 +370,11 @@ export class ActivitiesService {
|
|||||||
assigneeId: row.assignee?.id ?? null,
|
assigneeId: row.assignee?.id ?? null,
|
||||||
ownerId: 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(
|
async update(
|
||||||
@@ -345,7 +451,7 @@ export class ActivitiesService {
|
|||||||
if (dto.status === 'done' && existing?.status !== 'done') {
|
if (dto.status === 'done' && existing?.status !== 'done') {
|
||||||
await this.fireDone(organizationId, row);
|
await this.fireDone(organizationId, row);
|
||||||
}
|
}
|
||||||
return this.toDto(row);
|
return this.toDto(row, await this.stages.defaults(organizationId));
|
||||||
}
|
}
|
||||||
|
|
||||||
async complete(
|
async complete(
|
||||||
@@ -366,7 +472,7 @@ export class ActivitiesService {
|
|||||||
if (existing?.status !== 'done') {
|
if (existing?.status !== 'done') {
|
||||||
await this.fireDone(organizationId, row);
|
await this.fireDone(organizationId, row);
|
||||||
}
|
}
|
||||||
return this.toDto(row);
|
return this.toDto(row, await this.stages.defaults(organizationId));
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(
|
async remove(
|
||||||
@@ -393,11 +499,8 @@ export class ActivitiesService {
|
|||||||
to?: string;
|
to?: string;
|
||||||
},
|
},
|
||||||
desk?: 'mine' | 'team',
|
desk?: 'mine' | 'team',
|
||||||
facets?: {
|
facets?: ListFacets,
|
||||||
type?: ActivityDto['type'];
|
stageScope?: StageScope,
|
||||||
status?: ActivityDto['status'];
|
|
||||||
assigneeId?: string;
|
|
||||||
},
|
|
||||||
): Prisma.ActivityWhereInput {
|
): Prisma.ActivityWhereInput {
|
||||||
const { start, end } = dayBounds();
|
const { start, end } = dayBounds();
|
||||||
const scope: Prisma.ActivityWhereInput =
|
const scope: Prisma.ActivityWhereInput =
|
||||||
@@ -466,14 +569,24 @@ export class ActivitiesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Explicit status facet overrides the status implied by time tabs.
|
// 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 };
|
const impliedDone = facets?.status ? { status: facets.status } : { status: 'done' as const };
|
||||||
|
|
||||||
switch (filter) {
|
switch (filter) {
|
||||||
case 'today':
|
case 'today':
|
||||||
return { ...base, ...impliedOpen, dueAt: { gte: start, lt: end } };
|
return { ...base, ...impliedOpen, dueAt: { gte: start, lt: end } };
|
||||||
case 'overdue':
|
case 'overdue':
|
||||||
return { ...base, ...impliedOpen, dueAt: { lt: start } };
|
// عقبافتاده ذاتاً یعنی باز و موعدگذشته
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
...(facets?.status ? { status: facets.status } : { status: 'open' as const }),
|
||||||
|
dueAt: { lt: start },
|
||||||
|
};
|
||||||
case 'upcoming':
|
case 'upcoming':
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
@@ -571,8 +684,9 @@ export class ActivitiesService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private toDto(row: ActivityRow): ActivityDto {
|
private toDto(row: ActivityRow, defaults?: StageDefaults): ActivityDto {
|
||||||
return {
|
return {
|
||||||
|
stageId: this.stages.effectiveStageId(row, defaults),
|
||||||
id: row.id,
|
id: row.id,
|
||||||
type: row.type,
|
type: row.type,
|
||||||
status: row.status,
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,6 +66,11 @@ export class ListActivitiesQueryDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
assigneeId?: string;
|
assigneeId?: string;
|
||||||
|
|
||||||
|
/** شناسهٔ مرحله یا 'all' — حالت تب/کانبان مراحل */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
stageId?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
personId?: string;
|
personId?: string;
|
||||||
@@ -102,12 +107,61 @@ export class ListActivitiesQueryDto {
|
|||||||
limit?: number = 50;
|
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 {
|
export class CreateActivityDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(2)
|
@MinLength(2)
|
||||||
@MaxLength(200)
|
@MaxLength(200)
|
||||||
title!: string;
|
title!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateIf((_, v) => v !== '' && v != null)
|
||||||
|
@IsString()
|
||||||
|
stageId?: string | null;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(ACTIVITY_TYPES)
|
@IsIn(ACTIVITY_TYPES)
|
||||||
type?: (typeof ACTIVITY_TYPES)[number];
|
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];
|
columnDragStart: [stageId: string, event: DragEvent];
|
||||||
columnDragEnd: [];
|
columnDragEnd: [];
|
||||||
addStage: [name: string];
|
addStage: [name: string];
|
||||||
|
loadMore: [stageId: string];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const adding = ref(false);
|
const adding = ref(false);
|
||||||
@@ -82,6 +83,7 @@ function submitAdd() {
|
|||||||
@move="(id, dir) => emit('moveStage', id, dir)"
|
@move="(id, dir) => emit('moveStage', id, dir)"
|
||||||
@column-drag-start="(id, e) => emit('columnDragStart', id, e)"
|
@column-drag-start="(id, e) => emit('columnDragStart', id, e)"
|
||||||
@column-drag-end="emit('columnDragEnd')"
|
@column-drag-end="emit('columnDragEnd')"
|
||||||
|
@load-more="emit('loadMore', $event)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section v-if="canManage" class="add-col">
|
<section v-if="canManage" class="add-col">
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const emit = defineEmits<{
|
|||||||
move: [stageId: string, dir: -1 | 1];
|
move: [stageId: string, dir: -1 | 1];
|
||||||
columnDragStart: [stageId: string, event: DragEvent];
|
columnDragStart: [stageId: string, event: DragEvent];
|
||||||
columnDragEnd: [];
|
columnDragEnd: [];
|
||||||
|
loadMore: [stageId: string];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const renaming = ref(false);
|
const renaming = ref(false);
|
||||||
@@ -153,8 +154,9 @@ onBeforeUnmount(() => {
|
|||||||
>
|
>
|
||||||
{{ stage.title }}
|
{{ stage.title }}
|
||||||
</h2>
|
</h2>
|
||||||
|
<small v-if="stage.badge && !renaming" class="stage-badge">{{ stage.badge }}</small>
|
||||||
</div>
|
</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">
|
<div v-if="canManage" ref="menuRoot" class="more-wrap">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -225,6 +227,15 @@ onBeforeUnmount(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!stage.deals.length" class="empty">{{ copy.empty }}</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
<AddDealButton :accent="stage.color" :label="copy.addItem" @add="emit('addDeal', stage.id)" />
|
<AddDealButton :accent="stage.color" :label="copy.addItem" @add="emit('addDeal', stage.id)" />
|
||||||
@@ -425,6 +436,38 @@ onBeforeUnmount(() => {
|
|||||||
font-size: 0.85rem;
|
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) {
|
@container pipeline (max-width: 720px) {
|
||||||
.column {
|
.column {
|
||||||
flex: 0 0 min(20rem, 86cqi);
|
flex: 0 0 min(20rem, 86cqi);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { matchesSearch } from '@/lib/search';
|
import { matchesSearch } from '@/lib/search';
|
||||||
|
|
||||||
export type PipelineAccent = 'green' | 'orange' | 'purple';
|
export type PipelineAccent = 'green' | 'orange' | 'purple' | 'blue' | 'rose' | 'slate';
|
||||||
|
|
||||||
export type PipelineDeal = {
|
export type PipelineDeal = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -49,6 +49,16 @@ export const PROJECT_BOARD_COPY: PipelineUiCopy = {
|
|||||||
hideAmount: true,
|
hideAmount: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const ACTIVITY_BOARD_COPY: PipelineUiCopy = {
|
||||||
|
itemNoun: 'فعالیت',
|
||||||
|
addItem: 'افزودن فعالیت',
|
||||||
|
empty: 'فعالیتی نیست',
|
||||||
|
addItemAria: 'افزودن فعالیت به این ستون',
|
||||||
|
itemMenuAria: 'گزینههای فعالیت',
|
||||||
|
listColTitle: 'فعالیت',
|
||||||
|
hideAmount: true,
|
||||||
|
};
|
||||||
|
|
||||||
export type PipelineStage = {
|
export type PipelineStage = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -56,6 +66,12 @@ export type PipelineStage = {
|
|||||||
deals: PipelineDeal[];
|
deals: PipelineDeal[];
|
||||||
isWon?: boolean;
|
isWon?: boolean;
|
||||||
isLost?: boolean;
|
isLost?: boolean;
|
||||||
|
/** تعداد کل از سرور وقتی کارتها صفحهبندیشده بارگذاری میشوند */
|
||||||
|
total?: number;
|
||||||
|
hasMore?: boolean;
|
||||||
|
loadingMore?: boolean;
|
||||||
|
/** برچسب کوچک کنار عنوان ستون (مثلاً نوع مرحله) */
|
||||||
|
badge?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PIPELINE_ACCENT: Record<
|
export const PIPELINE_ACCENT: Record<
|
||||||
@@ -80,6 +96,24 @@ export const PIPELINE_ACCENT: Record<
|
|||||||
text: '#6d28d9',
|
text: '#6d28d9',
|
||||||
border: '#ddd6fe',
|
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'];
|
const ACCENT_CYCLE: PipelineAccent[] = ['green', 'orange', 'purple'];
|
||||||
|
|||||||
@@ -19,7 +19,11 @@ import {
|
|||||||
UiPagination,
|
UiPagination,
|
||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
import UiIcon from '@/components/UiIcon.vue';
|
import UiIcon from '@/components/UiIcon.vue';
|
||||||
|
import UiSegmented from '@/components/ui/UiSegmented.vue';
|
||||||
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
|
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
|
||||||
|
import ActivitiesKanban from '@/components/activities/ActivitiesKanban.vue';
|
||||||
|
import ActivityStagesManager from '@/components/activities/ActivityStagesManager.vue';
|
||||||
|
import { PIPELINE_ACCENT } from '@/lib/pipeline-board';
|
||||||
import {
|
import {
|
||||||
ACTIVITY_EXCEL_ALIASES,
|
ACTIVITY_EXCEL_ALIASES,
|
||||||
ACTIVITY_EXCEL_FIELDS,
|
ACTIVITY_EXCEL_FIELDS,
|
||||||
@@ -35,6 +39,8 @@ import ActivityFormView from '@/views/ActivityFormView.vue';
|
|||||||
import type {
|
import type {
|
||||||
ActivityDto,
|
ActivityDto,
|
||||||
ActivityFilter,
|
ActivityFilter,
|
||||||
|
ActivityStageCountsDto,
|
||||||
|
ActivityStageDto,
|
||||||
ActivityStatus,
|
ActivityStatus,
|
||||||
ActivityType,
|
ActivityType,
|
||||||
ImportContactsResult,
|
ImportContactsResult,
|
||||||
@@ -43,7 +49,7 @@ import type {
|
|||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { brand } from '@mento/shared';
|
import { brand } from '@mento/shared';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
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 { PAGE_SIZE_OPTIONS, usePageSize } from '@/lib/pageSize';
|
||||||
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
|
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
|
||||||
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
||||||
@@ -78,14 +84,22 @@ type TypeFilter = 'all' | ActivityType;
|
|||||||
type StatusFilter = 'all' | ActivityStatus;
|
type StatusFilter = 'all' | ActivityStatus;
|
||||||
type UiStatus = 'done' | 'progress' | 'planned' | 'overdue' | 'canceled';
|
type UiStatus = 'done' | 'progress' | 'planned' | 'overdue' | 'canceled';
|
||||||
type DeskScope = 'mine' | 'team';
|
type DeskScope = 'mine' | 'team';
|
||||||
|
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: 'today', label: 'امروز' },
|
||||||
{ key: 'overdue', label: 'عقبافتاده' },
|
{ key: 'overdue', label: 'عقبافتاده' },
|
||||||
{ key: 'upcoming', 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 }> = [
|
const DESK_TABS: Array<{ key: DeskScope; label: string }> = [
|
||||||
{ key: 'mine', label: 'میز من' },
|
{ key: 'mine', label: 'میز من' },
|
||||||
@@ -113,13 +127,41 @@ const route = useRoute();
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const showDeskTabs = computed(() => canViewAllRecords(auth.user?.role));
|
const showDeskTabs = computed(() => canViewAllRecords(auth.user?.role));
|
||||||
|
const canManageStages = computed(() => canManageSettings(auth.user?.role));
|
||||||
|
|
||||||
const filter = computed<ActivityFilter>(() => {
|
function storedViewMode(): ViewMode {
|
||||||
const value = typeof route.query.filter === 'string' ? route.query.filter : 'today';
|
try {
|
||||||
if (['today', 'overdue', 'upcoming', 'done', 'all'].includes(value)) {
|
return localStorage.getItem(VIEW_STORAGE_KEY) === 'board' ? 'board' : 'tabs';
|
||||||
return value as ActivityFilter;
|
} 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>(() => {
|
const desk = computed<DeskScope>(() => {
|
||||||
@@ -161,17 +203,17 @@ const assigneeFilter = computed(() =>
|
|||||||
const activityModal = ref<{
|
const activityModal = ref<{
|
||||||
type?: ActivityType;
|
type?: ActivityType;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
stageId?: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const menuId = ref<string | null>(null);
|
const menuId = ref<string | null>(null);
|
||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const pageBusy = ref(false);
|
const pageBusy = ref(false);
|
||||||
const busyMessage = ref('لطفاً صبر کنید…');
|
const busyMessage = ref('لطفاً صبر کنید…');
|
||||||
const importMsg = ref('');
|
const importMsg = ref('');
|
||||||
const counts = ref<Record<ActivityFilter, number>>({
|
const counts = ref<Record<TimeFilter, number>>({
|
||||||
today: 0,
|
today: 0,
|
||||||
overdue: 0,
|
overdue: 0,
|
||||||
upcoming: 0,
|
upcoming: 0,
|
||||||
done: 0,
|
|
||||||
all: 0,
|
all: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -209,7 +251,26 @@ const items = computed(() => data.value?.items ?? []);
|
|||||||
|
|
||||||
const total = computed(() => data.value?.total ?? 0);
|
const total = computed(() => data.value?.total ?? 0);
|
||||||
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)));
|
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(() => {
|
const activeFacetCount = computed(() => {
|
||||||
let n = 0;
|
let n = 0;
|
||||||
if (typeFilter.value !== 'all') n += 1;
|
if (typeFilter.value !== 'all') n += 1;
|
||||||
@@ -220,7 +281,14 @@ const activeFacetCount = computed(() => {
|
|||||||
const filterBtnLabel = computed(() =>
|
const filterBtnLabel = computed(() =>
|
||||||
activeFacetCount.value ? `فیلتر (${toFaDigits(activeFacetCount.value)})` : 'فیلتر',
|
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);
|
const listEmpty = computed(() => !loading.value && !items.value.length);
|
||||||
|
|
||||||
function isOverdue(item: ActivityDto) {
|
function isOverdue(item: ActivityDto) {
|
||||||
@@ -269,21 +337,43 @@ function subtitle(item: ActivityDto) {
|
|||||||
return item.deal?.title || item.description || '';
|
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() {
|
async function loadCounts() {
|
||||||
const keys: ActivityFilter[] = ['today', 'overdue', 'upcoming', 'done', 'all'];
|
const base = sharedParams.value;
|
||||||
const deskParam = showDeskTabs.value ? desk.value : undefined;
|
const stageParam = viewMode.value === 'tabs' ? stage.value : 'all';
|
||||||
const results = await Promise.all(
|
try {
|
||||||
keys.map((key) =>
|
const [timeResults, stageRes] = await Promise.all([
|
||||||
api.get<PaginatedResponse<ActivityDto>>('/activities', {
|
Promise.all(
|
||||||
params: { filter: key, limit: 1, desk: deskParam },
|
TIME_KEYS.map((key) =>
|
||||||
}),
|
api.get<PaginatedResponse<ActivityDto>>('/activities', {
|
||||||
),
|
params: { ...base, filter: key, stageId: stageParam, limit: 1 },
|
||||||
);
|
}),
|
||||||
const next = { ...counts.value };
|
),
|
||||||
keys.forEach((key, i) => {
|
),
|
||||||
next[key] = results[i]?.data.total ?? 0;
|
api.get<ActivityStageCountsDto>('/activities/stage-counts', { params: base }),
|
||||||
});
|
]);
|
||||||
counts.value = next;
|
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() {
|
async function loadUsers() {
|
||||||
@@ -300,20 +390,17 @@ async function loadUsers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
|
if (viewMode.value !== 'tabs') return;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
menuId.value = null;
|
menuId.value = null;
|
||||||
try {
|
try {
|
||||||
const { data: res } = await api.get<PaginatedResponse<ActivityDto>>('/activities', {
|
const { data: res } = await api.get<PaginatedResponse<ActivityDto>>('/activities', {
|
||||||
params: {
|
params: {
|
||||||
filter: filter.value,
|
...sharedParams.value,
|
||||||
q: q.value || undefined,
|
stageId: stage.value,
|
||||||
page: page.value,
|
page: page.value,
|
||||||
limit: pageSize.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;
|
data.value = res;
|
||||||
@@ -339,18 +426,65 @@ function setDesk(next: DeskScope) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function setFilter(next: ActivityFilter) {
|
function setFilter(next: TimeFilter) {
|
||||||
filterOpen.value = false;
|
filterOpen.value = false;
|
||||||
void router.replace({
|
void router.replace({
|
||||||
name: 'activities',
|
name: 'activities',
|
||||||
query: {
|
query: {
|
||||||
...route.query,
|
...route.query,
|
||||||
filter: next === 'today' ? undefined : next,
|
filter: next === defaultTime.value ? undefined : next,
|
||||||
page: undefined,
|
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) {
|
function setPage(next: number) {
|
||||||
const safe = Math.min(Math.max(1, next), pageCount.value);
|
const safe = Math.min(Math.max(1, next), pageCount.value);
|
||||||
void router.replace({
|
void router.replace({
|
||||||
@@ -394,17 +528,7 @@ function setTypeFilter(next: TypeFilter) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setStatusFilter(next: StatusFilter) {
|
function setStatusFilter(next: StatusFilter) {
|
||||||
// Avoid empty results when a time-tab forces a different status.
|
patchFacetQuery({ status: next === 'all' ? undefined : next });
|
||||||
const nextFilter =
|
|
||||||
next === 'all'
|
|
||||||
? route.query.filter
|
|
||||||
: next === 'done'
|
|
||||||
? 'done'
|
|
||||||
: 'all';
|
|
||||||
patchFacetQuery({
|
|
||||||
status: next === 'all' ? undefined : next,
|
|
||||||
filter: nextFilter === 'today' || !nextFilter ? undefined : String(nextFilter),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setAssigneeFilter(next: string) {
|
function setAssigneeFilter(next: string) {
|
||||||
@@ -426,7 +550,7 @@ async function complete(id: string) {
|
|||||||
menuId.value = null;
|
menuId.value = null;
|
||||||
try {
|
try {
|
||||||
await api.patch(`/activities/${id}/complete`);
|
await api.patch(`/activities/${id}/complete`);
|
||||||
await Promise.all([load(), loadCounts()]);
|
await refreshAll();
|
||||||
} catch {
|
} catch {
|
||||||
error.value = 'ثبت انجامشدن ناموفق بود.';
|
error.value = 'ثبت انجامشدن ناموفق بود.';
|
||||||
}
|
}
|
||||||
@@ -476,7 +600,8 @@ async function onImportDone(data: ImportContactsResult) {
|
|||||||
.map((e) => `ردیف ${e.row}: ${e.message}`)
|
.map((e) => `ردیف ${e.row}: ${e.message}`)
|
||||||
.join(' | ');
|
.join(' | ');
|
||||||
}
|
}
|
||||||
await Promise.all([load(), loadCounts()]);
|
kanbanReload.value += 1;
|
||||||
|
await refreshAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onImportFail(message: string) {
|
function onImportFail(message: string) {
|
||||||
@@ -497,6 +622,8 @@ watch(
|
|||||||
route.query.type,
|
route.query.type,
|
||||||
route.query.status,
|
route.query.status,
|
||||||
route.query.assigneeId,
|
route.query.assigneeId,
|
||||||
|
route.query.stage,
|
||||||
|
route.query.view,
|
||||||
],
|
],
|
||||||
() => {
|
() => {
|
||||||
q.value = typeof route.query.q === 'string' ? route.query.q : '';
|
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;
|
let mobileQuery: MediaQueryList | null = null;
|
||||||
function syncMobile() {
|
function syncMobile() {
|
||||||
isMobile.value = mobileQuery?.matches ?? false;
|
isMobile.value = mobileQuery?.matches ?? false;
|
||||||
@@ -515,8 +657,11 @@ onMounted(() => {
|
|||||||
syncMobile();
|
syncMobile();
|
||||||
mobileQuery.addEventListener('change', syncMobile);
|
mobileQuery.addEventListener('change', syncMobile);
|
||||||
void loadUsers();
|
void loadUsers();
|
||||||
void load();
|
void loadStages().then(() => {
|
||||||
void loadCounts();
|
if (migrateLegacyDoneFilter()) return;
|
||||||
|
void load();
|
||||||
|
void loadCounts();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -575,32 +720,77 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</UiPageHeader>
|
</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
|
<button
|
||||||
v-for="tab in DESK_TABS"
|
|
||||||
:key="tab.key"
|
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
:class="{ on: desk === tab.key }"
|
:class="{ on: stage === 'all' }"
|
||||||
:aria-selected="desk === tab.key"
|
:aria-selected="stage === 'all'"
|
||||||
@click="setDesk(tab.key)"
|
@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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tabs" role="tablist" aria-label="فیلتر فعالیت">
|
<div class="time-chips" role="tablist" aria-label="فیلتر زمانی">
|
||||||
<button
|
<button
|
||||||
v-for="tab in TABS"
|
v-for="tab in TIME_TABS"
|
||||||
:key="tab.key"
|
:key="tab.key"
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
:class="{ on: filter === tab.key }"
|
:class="{ on: filter === tab.key, late: tab.key === 'overdue' }"
|
||||||
:aria-selected="filter === tab.key"
|
:aria-selected="filter === tab.key"
|
||||||
@click="setFilter(tab.key)"
|
@click="setFilter(tab.key)"
|
||||||
>
|
>
|
||||||
<span>{{ tab.label }}</span>
|
{{ tab.label }}
|
||||||
<em>{{ toFaDigits(counts[tab.key]) }}</em>
|
<em>{{ toFaDigits(counts[tab.key].toLocaleString('en-US')) }}</em>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -681,7 +871,20 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<p v-if="error" class="error">{{ error }}</p>
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
<p v-if="importMsg" class="ok">{{ importMsg }}</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 v-else-if="listEmpty" class="empty-card">
|
||||||
<div class="art" aria-hidden="true">
|
<div class="art" aria-hidden="true">
|
||||||
@@ -780,11 +983,15 @@ onUnmounted(() => {
|
|||||||
{{ item.company?.name || item.person?.fullName }}
|
{{ item.company?.name || item.person?.fullName }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="colOn('status')">
|
<div v-if="colOn('status')" class="status-cell">
|
||||||
<span class="status" :class="uiStatus(item)">
|
<span class="status" :class="uiStatus(item)">
|
||||||
<UiIcon :name="statusIcon[uiStatus(item)]" :size="13" />
|
<UiIcon :name="statusIcon[uiStatus(item)]" :size="13" />
|
||||||
{{ statusLabel[uiStatus(item)] }}
|
{{ statusLabel[uiStatus(item)] }}
|
||||||
</span>
|
</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>
|
||||||
<div v-if="!isMobile" class="row-more" @click.stop>
|
<div v-if="!isMobile" class="row-more" @click.stop>
|
||||||
<UiDropdownMenu
|
<UiDropdownMenu
|
||||||
@@ -901,10 +1108,19 @@ onUnmounted(() => {
|
|||||||
embedded
|
embedded
|
||||||
:initial-type="activityModal.type"
|
:initial-type="activityModal.type"
|
||||||
:initial-title="activityModal.title"
|
:initial-title="activityModal.title"
|
||||||
@saved="activityModal = null; void load(); void loadCounts()"
|
:initial-stage-id="activityModal.stageId"
|
||||||
|
@saved="onActivitySaved"
|
||||||
@cancel="activityModal = null"
|
@cancel="activityModal = null"
|
||||||
/>
|
/>
|
||||||
</UiModal>
|
</UiModal>
|
||||||
|
|
||||||
|
<ActivityStagesManager
|
||||||
|
v-if="canManageStages"
|
||||||
|
:open="stagesManagerOpen"
|
||||||
|
:stages="stages"
|
||||||
|
@close="stagesManagerOpen = false"
|
||||||
|
@changed="onStagesChanged(); kanbanReload += 1"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -1049,6 +1265,85 @@ h1 {
|
|||||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
|
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 {
|
.tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.2rem;
|
gap: 0.2rem;
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ const props = withDefaults(
|
|||||||
initialPersonId?: string;
|
initialPersonId?: string;
|
||||||
initialType?: ActivityType;
|
initialType?: ActivityType;
|
||||||
initialTitle?: string;
|
initialTitle?: string;
|
||||||
|
initialStageId?: string;
|
||||||
}>(),
|
}>(),
|
||||||
{ embedded: false },
|
{ embedded: false },
|
||||||
);
|
);
|
||||||
@@ -438,7 +439,10 @@ async function submit(next: 'detail' | 'new' = 'detail') {
|
|||||||
try {
|
try {
|
||||||
let savedId = id.value;
|
let savedId = id.value;
|
||||||
if (isNew.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;
|
savedId = data.id;
|
||||||
if (isDone.value) {
|
if (isDone.value) {
|
||||||
await api.patch(`/activities/${savedId}/complete`);
|
await api.patch(`/activities/${savedId}/complete`);
|
||||||
|
|||||||
@@ -1138,6 +1138,67 @@ export type ActivityStatus = "open" | "done" | "canceled";
|
|||||||
export type ActivityPriority = "very_low" | "low" | "medium" | "high" | "very_high";
|
export type ActivityPriority = "very_low" | "low" | "medium" | "high" | "very_high";
|
||||||
export type ActivityFilter = "today" | "overdue" | "upcoming" | "done" | "all";
|
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 CustomFieldEntity = "person" | "company" | "deal";
|
||||||
export type CustomFieldType = "text" | "number" | "date" | "boolean" | "select";
|
export type CustomFieldType = "text" | "number" | "date" | "boolean" | "select";
|
||||||
|
|
||||||
@@ -1204,12 +1265,16 @@ export interface ActivityDto {
|
|||||||
/** همه مسئولان انجام فعالیت */
|
/** همه مسئولان انجام فعالیت */
|
||||||
assignees: UserSummaryDto[];
|
assignees: UserSummaryDto[];
|
||||||
createdBy: UserSummaryDto | null;
|
createdBy: UserSummaryDto | null;
|
||||||
|
/** مرحلهٔ مؤثر (ستون کانبان) */
|
||||||
|
stageId?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateActivityRequest {
|
export interface CreateActivityRequest {
|
||||||
title: string;
|
title: string;
|
||||||
|
/** مرحلهٔ اولیه؛ وضعیت فعالیت از نوع مرحله گرفته میشود */
|
||||||
|
stageId?: string | null;
|
||||||
type?: ActivityType;
|
type?: ActivityType;
|
||||||
priority?: ActivityPriority;
|
priority?: ActivityPriority;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user