task time

This commit is contained in:
2026-07-15 10:37:19 +03:30
parent 4263d7118e
commit 30d86c9775
12 changed files with 384 additions and 5 deletions
+7
View File
@@ -955,4 +955,11 @@ export const enum checkListItemMessage {
export const enum attachmentMessage {
ATTACHMENT_NOT_FOUND = "ضمیمه پیدا نشد!"
}
export const enum timeMessage {
TIME_NOT_FOUND = "زمان ثبت شده پیدا نشد!",
INVALID_TIME_RANGE = "زمان پایان باید بعد از زمان شروع باشد!",
TIME_ALREADY_STARTED = "برای این تسک یک زمان در حال اجرا وجود دارد!",
NO_RUNNING_TIME = "زمان در حال اجرایی برای این تسک پیدا نشد!"
}
@@ -0,0 +1,69 @@
import { Controller, Post, Patch, Delete, Param, Body, Get } from "@nestjs/common";
import { ApiTags, ApiOperation, ApiResponse, ApiParam } from "@nestjs/swagger";
import { TimeService } from "../providers/time.service";
import { CreateTimeDto } from "../dto/time/create-time.dto";
import { UpdateTimeDto } from "../dto/time/update-time.dto";
import { TMTime } from "../entities/time.entity";
import { AuthGuards } from "../../../common/decorators/auth-guard.decorator";
import { AdminRoute } from "../../../common/decorators/admin.decorator";
import { UserDec } from "../../../common/decorators/user.decorator";
@AuthGuards()
@AdminRoute()
@ApiTags("Task Manager - Times")
@Controller("task-manager/times")
export class TimeController {
constructor(private readonly timeService: TimeService) { }
@Get("task/:taskId")
@ApiOperation({ summary: "Get all time entries for a task" })
@ApiParam({ name: "taskId", description: "Task ID" })
getTaskTimes(@UserDec("id") adminId: string, @Param("taskId") taskId: string): Promise<TMTime[]> {
return this.timeService.getTaskTimes(adminId, taskId);
}
@Post()
@ApiOperation({ summary: "Create a new time entry for a task" })
@ApiResponse({ status: 201, description: "Time entry created", type: TMTime })
@ApiResponse({ status: 404, description: "Task not found" })
create(@UserDec("id") adminId: string, @Body() body: CreateTimeDto): Promise<TMTime> {
return this.timeService.create(adminId, body);
}
@Post("start/:taskId")
@ApiOperation({ summary: "Start tracking time for a task" })
@ApiParam({ name: "taskId", description: "Task ID" })
@ApiResponse({ status: 201, description: "Time tracking started", type: TMTime })
@ApiResponse({ status: 400, description: "A running time entry already exists" })
@ApiResponse({ status: 404, description: "Task not found" })
start(@UserDec("id") adminId: string, @Param("taskId") taskId: string): Promise<TMTime> {
return this.timeService.start(adminId, taskId);
}
@Post("end/:taskId")
@ApiOperation({ summary: "Stop the current running time entry for a task" })
@ApiParam({ name: "taskId", description: "Task ID" })
@ApiResponse({ status: 200, description: "Time tracking stopped", type: TMTime })
@ApiResponse({ status: 400, description: "No running time entry found" })
@ApiResponse({ status: 404, description: "Task not found" })
end(@UserDec("id") adminId: string, @Param("taskId") taskId: string): Promise<TMTime> {
return this.timeService.end(adminId, taskId);
}
@Patch(":id")
@ApiOperation({ summary: "Update a time entry" })
@ApiParam({ name: "id", description: "Time entry ID" })
@ApiResponse({ status: 200, description: "Time entry updated", type: TMTime })
@ApiResponse({ status: 404, description: "Time entry or Task not found" })
update(@Param("id") id: string, @Body() body: UpdateTimeDto): Promise<TMTime> {
return this.timeService.update(id, body);
}
@Delete(":id")
@ApiOperation({ summary: "Delete a time entry" })
@ApiParam({ name: "id", description: "Time entry ID" })
@ApiResponse({ status: 200, description: "Time entry deleted" })
@ApiResponse({ status: 404, description: "Time entry not found" })
remove(@Param("id") id: string): Promise<void> {
return this.timeService.remove(id);
}
}
@@ -0,0 +1,19 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsDateString, IsNotEmpty, IsUUID } from "class-validator";
export class CreateTimeDto {
@ApiProperty({ description: "Start time of the entry", example: "2026-07-15T08:00:00.000Z" })
@IsDateString()
@IsNotEmpty()
startedAt: string;
@ApiProperty({ description: "End time of the entry", example: "2026-07-15T10:30:00.000Z" })
@IsDateString()
@IsNotEmpty()
endedAt: string;
@ApiProperty({ description: "ID of the task this time entry belongs to", example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890" })
@IsUUID()
@IsNotEmpty()
taskId: string;
}
@@ -0,0 +1,4 @@
import { PartialType } from "@nestjs/swagger";
import { CreateTimeDto } from "./create-time.dto";
export class UpdateTimeDto extends PartialType(CreateTimeDto) {}
@@ -4,6 +4,7 @@ import { TMTaskPhase } from "./task-phase.entity";
import { TMRemark } from "./remark.entity";
import { TMAttachment } from "./attachment.entity";
import { TMCheckListItem } from "./check-list-item.entity";
import { TMTime } from "./time.entity";
import { User } from "../../users/entities/user.entity";
@Entity("tm_tasks")
@@ -53,6 +54,11 @@ export class TMTask extends BaseEntity {
})
checkListItems: TMCheckListItem[];
@OneToMany(() => TMTime, (time) => time.task, {
cascade: ["insert", "update"],
})
times: TMTime[];
@ManyToMany(() => User, (user) => user.tasks)
@JoinTable({
name: "tm_task_users",
@@ -65,4 +71,5 @@ export class TMTask extends BaseEntity {
attachmentCount?: number;
checkListItemCount?: number;
completedCheckListItemCount?: number;
time?: number;
}
@@ -0,0 +1,38 @@
import { Column, Entity, JoinColumn, ManyToOne } from "typeorm";
import { BaseEntity } from "../../../common/entities/base.entity";
import { TMTask } from "./task.entity";
import { User } from "../../users/entities/user.entity";
@Entity("tm_times")
export class TMTime extends BaseEntity {
@Column({ type: "timestamptz" })
startedAt: Date;
@Column({ type: "timestamptz", nullable: true })
endedAt: Date | null;
@Column({ type: "int", nullable: true })
duration: number | null;
// --- Relations ---
@ManyToOne(() => User, {
nullable: false,
onDelete: "RESTRICT",
})
@JoinColumn({ name: "adminId" })
admin: User;
@Column({ type: "uuid" })
adminId: string;
@ManyToOne(() => TMTask, (task) => task.times, {
nullable: false,
onDelete: "CASCADE",
})
@JoinColumn({ name: "taskId" })
task: TMTask;
@Column({ type: "uuid" })
taskId: string;
}
@@ -0,0 +1,119 @@
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { IsNull } from "typeorm";
import { TimeRepository } from "../repositories/time.repository";
import { TMTime } from "../entities/time.entity";
import { CreateTimeDto } from "../dto/time/create-time.dto";
import { UpdateTimeDto } from "../dto/time/update-time.dto";
import { timeMessage } from "../../../common/enums/message.enum";
import { TaskService } from "./task.service";
@Injectable()
export class TimeService {
constructor(
private readonly timeRepository: TimeRepository,
private readonly taskService: TaskService,
) { }
private calculateDuration(startedAt: Date, endedAt: Date): number {
if (endedAt.getTime() < startedAt.getTime()) {
throw new BadRequestException(timeMessage.INVALID_TIME_RANGE);
}
return Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000);
}
private async findRunningTime(adminId: string, taskId: string): Promise<TMTime | null> {
return this.timeRepository.findOne({
where: { adminId, taskId, endedAt: IsNull() },
order: { startedAt: "DESC" },
});
}
async findOneOrFail(id: string): Promise<TMTime> {
const time = await this.timeRepository.findOne({
where: { id },
relations: { admin: true, task: true },
});
if (!time) throw new NotFoundException(timeMessage.TIME_NOT_FOUND);
return time;
}
async start(adminId: string, taskId: string): Promise<TMTime> {
await this.taskService.findOneOrFail(taskId);
const runningTime = await this.findRunningTime(adminId, taskId);
if (runningTime) throw new BadRequestException(timeMessage.TIME_ALREADY_STARTED);
const time = this.timeRepository.create({
taskId,
adminId,
startedAt: new Date(),
endedAt: null,
duration: null,
});
return this.timeRepository.save(time);
}
async getTaskTimes(adminId: string, taskId: string): Promise<TMTime[]> {
return this.timeRepository.find({
where: { adminId, taskId },
order: { startedAt: "DESC" },
});
}
async end(adminId: string, taskId: string): Promise<TMTime> {
await this.taskService.findOneOrFail(taskId);
const runningTime = await this.findRunningTime(adminId, taskId);
if (!runningTime) throw new BadRequestException(timeMessage.NO_RUNNING_TIME);
const endedAt = new Date();
runningTime.endedAt = endedAt;
runningTime.duration = this.calculateDuration(runningTime.startedAt, endedAt);
return this.timeRepository.save(runningTime);
}
async create(adminId: string, dto: CreateTimeDto): Promise<TMTime> {
await this.taskService.findOneOrFail(dto.taskId);
const startedAt = new Date(dto.startedAt);
const endedAt = new Date(dto.endedAt);
const time = this.timeRepository.create({
taskId: dto.taskId,
adminId,
startedAt,
endedAt,
duration: this.calculateDuration(startedAt, endedAt),
});
return this.timeRepository.save(time);
}
async update(id: string, dto: UpdateTimeDto): Promise<TMTime> {
const time = await this.findOneOrFail(id);
if (dto.taskId) {
await this.taskService.findOneOrFail(dto.taskId);
}
const startedAt = dto.startedAt ? new Date(dto.startedAt) : time.startedAt;
const endedAt = dto.endedAt ? new Date(dto.endedAt) : time.endedAt;
Object.assign(time, {
...dto,
startedAt,
endedAt,
duration: endedAt ? this.calculateDuration(startedAt, endedAt) : null,
});
return this.timeRepository.save(time);
}
async remove(id: string): Promise<void> {
await this.findOneOrFail(id);
await this.timeRepository.delete(id);
}
}
@@ -2,6 +2,7 @@ import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { TMTask } from "../entities/task.entity";
import { TMTime } from "../entities/time.entity";
import { CursorDirection, CursorPaginationDto } from "../../../common/DTO/cursor-pagination.dto";
import { decodeCursor } from "../../utils/providers/cursor-pagination.utils";
@@ -12,11 +13,13 @@ export class TaskRepository extends Repository<TMTask> {
}
async getTaskDetail(taskId: string): Promise<TMTask | null> {
return this.createQueryBuilder("task")
const task = await this.createQueryBuilder("task")
.leftJoinAndSelect("task.remarks", "remark")
.leftJoinAndSelect("task.attachments", "attachment")
.leftJoinAndSelect("task.checkListItems", "checkListItem")
.leftJoinAndSelect("task.users", "user")
.leftJoinAndSelect("task.times", "time")
.leftJoinAndSelect("time.admin", "timeAdmin")
.loadRelationCountAndMap("task.checkListItemCount", "task.checkListItems")
@@ -41,12 +44,28 @@ export class TaskRepository extends Repository<TMTask> {
"user.id",
"user.firstName",
"user.lastName",
"time.id",
"time.startedAt",
"time.endedAt",
"time.duration",
"time.adminId",
"timeAdmin.id",
"timeAdmin.firstName",
"timeAdmin.lastName",
])
.where("task.id = :taskId", { taskId })
.orderBy("checkListItem.createdAt", "ASC")
.addOrderBy("time.createdAt", "ASC")
.getOne();
if (task) {
task.time = task.times?.reduce((sum, entry) => sum + (entry.duration ?? 0), 0) ?? 0;
}
return task;
}
async findTasksByTaskPhase(taskPhaseId: string, pagination: CursorPaginationDto): Promise<{ items: TMTask[]; hasMore: boolean }> {
@@ -103,7 +122,22 @@ export class TaskRepository extends Repository<TMTask> {
.where("task.id IN (:...ids)", { ids })
.getMany();
const detailById = new Map(detailRows.map((task) => [task.id, task]));
const timeSums = await this.manager
.createQueryBuilder(TMTime, "time")
.select("time.taskId", "taskId")
.addSelect("COALESCE(SUM(time.duration), 0)", "totalTime")
.where("time.taskId IN (:...ids)", { ids })
.groupBy("time.taskId")
.getRawMany<{ taskId: string; totalTime: string }>();
const timeByTaskId = new Map(timeSums.map((row) => [row.taskId, Number(row.totalTime)]));
const detailById = new Map(
detailRows.map((task) => {
task.time = timeByTaskId.get(task.id) ?? 0;
return [task.id, task];
}),
);
const items = ids.map((id) => detailById.get(id)).filter((task): task is TMTask => task !== undefined);
return { items, hasMore };
@@ -0,0 +1,11 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { TMTime } from "../entities/time.entity";
@Injectable()
export class TimeRepository extends Repository<TMTime> {
constructor(@InjectRepository(TMTime) timeRepository: Repository<TMTime>) {
super(timeRepository.target, timeRepository.manager, timeRepository.queryRunner);
}
}
@@ -8,6 +8,7 @@ import { TMTask } from "./entities/task.entity";
import { TMRemark } from "./entities/remark.entity";
import { TMAttachment } from "./entities/attachment.entity";
import { TMCheckListItem } from "./entities/check-list-item.entity";
import { TMTime } from "./entities/time.entity";
import { WorkspaceRepository } from "./repositories/workspace.repository";
import { WorkspaceController } from "./controllers/workspace.controller";
import { WorkspaceService } from "./providers/workspace.service";
@@ -30,10 +31,13 @@ import { RemarkService } from "./providers/remark.service";
import { AttachmentController } from "./controllers/attachment.controller";
import { AttachmentRepository } from "./repositories/attachment.repository";
import { AttachmentService } from "./providers/attachment.service";
import { TimeController } from "./controllers/time.controller";
import { TimeRepository } from "./repositories/time.repository";
import { TimeService } from "./providers/time.service";
@Module({
imports: [
TypeOrmModule.forFeature([TMWorkspace, TMProject, TMTaskPhase, TMTask, TMRemark, TMAttachment, TMCheckListItem, User]),
TypeOrmModule.forFeature([TMWorkspace, TMProject, TMTaskPhase, TMTask, TMRemark, TMAttachment, TMCheckListItem, TMTime, User]),
],
controllers: [
WorkspaceController,
@@ -42,7 +46,8 @@ import { AttachmentService } from "./providers/attachment.service";
TaskController,
CheckListItemController,
RemarkController,
AttachmentController
AttachmentController,
TimeController,
],
providers: [
WorkspaceRepository,
@@ -58,7 +63,9 @@ import { AttachmentService } from "./providers/attachment.service";
RemarkRepository,
RemarkService,
AttachmentRepository,
AttachmentService
AttachmentService,
TimeRepository,
TimeService,
]
})
export class TaskManagerModule {}