diff --git a/frontend/src/core/asset/__tests__/assetManager.test.ts b/frontend/src/core/asset/__tests__/assetManager.test.ts index f64bd471..75b75505 100644 --- a/frontend/src/core/asset/__tests__/assetManager.test.ts +++ b/frontend/src/core/asset/__tests__/assetManager.test.ts @@ -3,7 +3,7 @@ import { AssetManager } from "../assetManager"; import { type Asset, AssetStatus } from '@/core/asset/asset.types'; // Mock the services -vi.mock("@/services/projects", () => ({ +vi.mock("@/services/project", () => ({ assetService: { getAssetById: vi.fn(), }, diff --git a/frontend/src/core/timeTracking/timeTracker.ts b/frontend/src/core/timeTracking/timeTracker.ts index e7f13fce..04ecb32d 100644 --- a/frontend/src/core/timeTracking/timeTracker.ts +++ b/frontend/src/core/timeTracking/timeTracker.ts @@ -211,7 +211,7 @@ export class TimeTracker implements TimerService { } /** - * Start timer (simplified interface for TaskManager compatibility) + * Start timer */ start(): void { if (this.currentSession) { diff --git a/frontend/src/core/workspace/task/__tests__/taskManager.test.ts b/frontend/src/core/workspace/task/__tests__/taskManager.test.ts deleted file mode 100644 index 2998a7a6..00000000 --- a/frontend/src/core/workspace/task/__tests__/taskManager.test.ts +++ /dev/null @@ -1,536 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { TaskManager } from '../taskManager'; -import { TaskNavigationManager } from '../taskNavigationManager'; -import type { Task } from '@/services/project/task/task.types'; -import { TaskStatus } from '@/services/project/task/task.types'; -import type { PipelineResultDto } from '@/services/project/task/task.types'; - -// Mock logger -const mockLogger = { - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - debug: vi.fn() -}; - -// Mock task service -const mockTaskService = { - getTaskById: vi.fn(), - getTasksForAsset: vi.fn(), - getTasksForStage: vi.fn(), - completeTaskPipeline: vi.fn(), - vetoTaskPipeline: vi.fn(), - changeTaskStatus: vi.fn(), - updateWorkingTime: vi.fn(), - saveWorkingTimeBeforeUnload: vi.fn() -}; - -// Mock permissions -const mockPermissions = { - canUpdateProject: vi.fn() -}; - -// Mock timer -const mockTimer = { - getElapsedTime: vi.fn(() => 1000), - isRunning: vi.fn(() => true) -}; - -// Test data -const mockTask: Task = { - id: 1, - priority: 1, - workingTimeMs: 5000, - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - assetId: 1, - projectId: 1, - workflowId: 1, - workflowStageId: 1, - status: TaskStatus.IN_PROGRESS, - assignedToEmail: 'test@example.com' -}; - -const mockCompletedTask: Task = { - ...mockTask, - status: TaskStatus.COMPLETED, - completedAt: '2024-01-01T01:00:00Z' -}; - -const mockSuspendedTask: Task = { - ...mockTask, - status: TaskStatus.SUSPENDED, - suspendedAt: '2024-01-01T01:00:00Z' -}; - -const mockDeferredTask: Task = { - ...mockTask, - status: TaskStatus.DEFERRED, - deferredAt: '2024-01-01T01:00:00Z' -}; - -const mockPipelineSuccess: PipelineResultDto = { - isSuccess: true, - updatedTask: mockCompletedTask, - details: 'Task completed successfully' -}; - -const mockPipelineFailure: PipelineResultDto = { - isSuccess: false, - errorMessage: 'Pipeline failed' -}; - -describe('TaskManager', () => { - let taskManager: TaskManager; - - beforeEach(() => { - vi.clearAllMocks(); - taskManager = new TaskManager( - mockTaskService as any, - mockPermissions as any, - mockTimer as any, - ); - }); - - describe('Task Completion', () => { - it('should complete a task successfully using pipeline system', async () => { - mockTaskService.completeTaskPipeline.mockResolvedValue(mockPipelineSuccess); - mockTaskService.getTaskById.mockResolvedValue(mockCompletedTask); - - const result = await taskManager.completeTask(123, 1); - - expect(result.success).toBe(true); - expect(result.task).toEqual(mockCompletedTask); - expect(result.message).toContain('successfully completed'); - expect(mockTaskService.completeTaskPipeline).toHaveBeenCalledWith(123, 1); - expect(mockTaskService.getTaskById).toHaveBeenCalledWith(123, 1); - }); - - it('should handle task completion failure', async () => { - mockTaskService.completeTaskPipeline.mockResolvedValue(mockPipelineFailure); - - const result = await taskManager.completeTask(123, 1); - - expect(result.success).toBe(false); - expect(result.error).toBe('Pipeline failed'); - }); - - it('should handle task completion with service error', async () => { - mockTaskService.completeTaskPipeline.mockRejectedValue(new Error('Service error')); - - const result = await taskManager.completeTask(123, 1); - - expect(result.success).toBe(false); - expect(result.error).toBe('Service error'); - }); - }); - - describe('Task Suspension', () => { - it('should suspend a task successfully with working time preservation', async () => { - mockTaskService.changeTaskStatus.mockResolvedValue(mockSuspendedTask); - - const result = await taskManager.suspendTask(123, 1, 2000); - - expect(result.success).toBe(true); - expect(result.task).toEqual(mockSuspendedTask); - expect(mockTaskService.changeTaskStatus).toHaveBeenCalledWith( - 123, 1, { targetStatus: TaskStatus.SUSPENDED } - ); - }); - - it('should handle task suspension failure', async () => { - mockTaskService.changeTaskStatus.mockRejectedValue(new Error('Suspension failed')); - - const result = await taskManager.suspendTask(123, 1, 2000); - - expect(result.success).toBe(false); - expect(result.error).toBe('Suspension failed'); - }); - - it('should properly preserve working time during suspension', async () => { - const currentWorkingTime = 5000; - const elapsedTime = 3000; - const expectedTotalTime = currentWorkingTime + elapsedTime; - - mockTimer.getElapsedTime.mockReturnValue(elapsedTime); - mockTaskService.changeTaskStatus.mockResolvedValue({ - ...mockSuspendedTask, - workingTimeMs: expectedTotalTime - }); - - const result = await taskManager.suspendTask(123, 1, currentWorkingTime); - - expect(result.success).toBe(true); - // The manager should calculate total time and pass it through - expect(mockTaskService.changeTaskStatus).toHaveBeenCalledWith( - 123, 1, { targetStatus: TaskStatus.SUSPENDED } - ); - }); - }); - - describe('Task Deferring', () => { - it('should defer a task successfully', async () => { - mockTaskService.changeTaskStatus.mockResolvedValue(mockDeferredTask); - - const result = await taskManager.deferTask(123, 1, 2000); - - expect(result.success).toBe(true); - expect(result.task).toEqual(mockDeferredTask); - expect(mockTaskService.changeTaskStatus).toHaveBeenCalledWith( - 123, 1, { targetStatus: TaskStatus.DEFERRED } - ); - }); - - it('should handle task deferring failure', async () => { - mockTaskService.changeTaskStatus.mockRejectedValue(new Error('Defer failed')); - - const result = await taskManager.deferTask(123, 1, 2000); - - expect(result.success).toBe(false); - expect(result.error).toBe('Defer failed'); - }); - }); - - describe('Task Veto', () => { - it('should veto a task successfully with reason', async () => { - const vetoResult = { ...mockPipelineSuccess, updatedTask: { ...mockTask, status: TaskStatus.VETOED } }; - mockTaskService.vetoTaskPipeline.mockResolvedValue(vetoResult); - - const result = await taskManager.vetoTask(123, 1, 'Quality issues'); - - expect(result.success).toBe(true); - expect(result.task?.status).toBe(TaskStatus.VETOED); - expect(mockTaskService.vetoTaskPipeline).toHaveBeenCalledWith(123, 1, { - reason: 'Quality issues' - }); - }); - - it('should handle task veto failure', async () => { - mockTaskService.vetoTaskPipeline.mockResolvedValue(mockPipelineFailure); - - const result = await taskManager.vetoTask(123, 1, 'Quality issues'); - - expect(result.success).toBe(false); - expect(result.error).toBe('Pipeline failed'); - }); - - it('should use default reason when none provided', async () => { - const vetoResult = { ...mockPipelineSuccess, updatedTask: { ...mockTask, status: TaskStatus.VETOED } }; - mockTaskService.vetoTaskPipeline.mockResolvedValue(vetoResult); - - await taskManager.vetoTask(123, 1); - - expect(mockTaskService.vetoTaskPipeline).toHaveBeenCalledWith(123, 1, { - reason: 'Task returned for rework' - }); - }); - }); - - describe('Task Validation', () => { - it('should correctly identify if task can be completed', () => { - const inProgressTask = { ...mockTask, status: TaskStatus.IN_PROGRESS }; - const completedTask = { ...mockTask, status: TaskStatus.COMPLETED }; - const suspendedTask = { ...mockTask, status: TaskStatus.SUSPENDED }; - - expect(taskManager.canCompleteTask(inProgressTask)).toBe(true); - expect(taskManager.canCompleteTask(completedTask)).toBe(false); - expect(taskManager.canCompleteTask(suspendedTask)).toBe(false); - }); - - it('should validate task permissions correctly for regular tasks', async () => { - const result = await taskManager.canOpenTask(mockTask); - - expect(result).toBe(true); - // Regular tasks don't require permission checks - expect(mockPermissions.canUpdateProject).not.toHaveBeenCalled(); - }); - - it('should handle deferred tasks requiring manager permissions', async () => { - const deferredTask = { ...mockTask, status: TaskStatus.DEFERRED }; - mockPermissions.canUpdateProject.mockResolvedValue(false); - - const result = await taskManager.canOpenTask(deferredTask); - - expect(result).toBe(false); - }); - - it('should allow managers to open deferred tasks', async () => { - const deferredTask = { ...mockTask, status: TaskStatus.DEFERRED }; - mockPermissions.canUpdateProject.mockResolvedValue(true); - - const result = await taskManager.canOpenTask(deferredTask); - - expect(result).toBe(true); - }); - }); - - describe('Working Time Management', () => { - it('should calculate total working time correctly', () => { - const currentTime = 5000; - const elapsedTime = 3000; - mockTimer.getElapsedTime.mockReturnValue(elapsedTime); - - const totalTime = taskManager.calculateTotalWorkingTime(currentTime); - - expect(totalTime).toBe(8000); - expect(mockTimer.getElapsedTime).toHaveBeenCalled(); - }); - - it('should return current time if timer is not running', () => { - const currentTime = 5000; - mockTimer.isRunning.mockReturnValue(false); - mockTimer.getElapsedTime.mockReturnValue(0); - - const totalTime = taskManager.calculateTotalWorkingTime(currentTime); - - expect(totalTime).toBe(5000); - }); - }); - - describe('Error Handling', () => { - it('should log appropriate errors for each operation', async () => { - mockTaskService.completeTaskPipeline.mockRejectedValue(new Error('Test error')); - - await taskManager.completeTask(123, 1); - - expect(mockLogger.error).toHaveBeenCalledWith( - 'Failed to complete task via pipeline:', - expect.any(Error) - ); - }); - - it('should handle non-Error exceptions', async () => { - mockTaskService.completeTaskPipeline.mockRejectedValue('String error'); - - const result = await taskManager.completeTask(123, 1); - - expect(result.success).toBe(false); - expect(result.error).toBe('Failed to complete task'); - }); - }); -}); - -describe('TaskNavigationManager', () => { - let navigationManager: TaskNavigationManager; - - const mockTasks: Task[] = [ - { ...mockTask, id: 1, status: TaskStatus.COMPLETED }, - { ...mockTask, id: 2, status: TaskStatus.IN_PROGRESS }, - { ...mockTask, id: 3, status: TaskStatus.NOT_STARTED }, - { ...mockTask, id: 4, status: TaskStatus.COMPLETED } - ]; - - beforeEach(() => { - vi.clearAllMocks(); - navigationManager = new TaskNavigationManager( - mockPermissions as any, - ); - }); - - describe('Next Task Navigation', () => { - it('should find next available task', async () => { - mockPermissions.canUpdateProject.mockResolvedValue(true); - - const result = await navigationManager.navigateToNext( - mockTasks[1], // current task (id: 2) - mockTasks, - '123' - ); - - expect(result.success).toBe(true); - expect(result.navigation).toEqual({ - projectId: '123', - assetId: '1', - taskId: '3' - }); - }); - - it('should return null when no next task available', async () => { - mockPermissions.canUpdateProject.mockResolvedValue(true); - - const result = await navigationManager.navigateToNext( - mockTasks[3], // current task (id: 4) - last task - mockTasks, - '123' - ); - - expect(result.success).toBe(true); - expect(result.navigation).toBeNull(); - }); - - it('should skip tasks that cannot be opened', async () => { - // Mock deferred task that requires manager permissions - const tasksWithDeferred = [ - ...mockTasks, - { ...mockTask, id: 5, status: TaskStatus.DEFERRED } - ]; - - // User is not a manager - mockPermissions.canUpdateProject.mockResolvedValue(false); - - const result = await navigationManager.navigateToNext( - mockTasks[1], - tasksWithDeferred, - '123' - ); - - expect(result.success).toBe(true); - // Should skip deferred task and go to id: 3 - expect(result.navigation?.taskId).toBe('3'); - }); - }); - - describe('Previous Task Navigation', () => { - it('should find previous available task', async () => { - mockPermissions.canUpdateProject.mockResolvedValue(true); - - const result = await navigationManager.navigateToPrevious( - mockTasks[2], // current task (id: 3) - mockTasks, - '123' - ); - - expect(result.success).toBe(true); - expect(result.navigation).toEqual({ - projectId: '123', - assetId: '1', - taskId: '2' - }); - }); - - it('should return null when no previous task available', async () => { - mockPermissions.canUpdateProject.mockResolvedValue(true); - - const result = await navigationManager.navigateToPrevious( - mockTasks[0], // first task - mockTasks, - '123' - ); - - expect(result.success).toBe(true); - expect(result.navigation).toBeNull(); - }); - }); - - describe('Next Available Task (Completion Helper)', () => { - it('should find next uncompleted task', async () => { - const result = await navigationManager.getNextAvailableTask( - mockTasks[1], // current task (id: 2) - mockTasks, - '123' - ); - - expect(result.success).toBe(true); - expect(result.navigation).toEqual({ - projectId: '123', - assetId: '1', - taskId: '3' - }); - }); - - it('should wrap around to beginning if needed', async () => { - const tasksWithGap = [ - { ...mockTask, id: 1, status: TaskStatus.NOT_STARTED }, - { ...mockTask, id: 2, status: TaskStatus.COMPLETED }, - { ...mockTask, id: 3, status: TaskStatus.COMPLETED } - ]; - - const result = await navigationManager.getNextAvailableTask( - tasksWithGap[2], // last task - tasksWithGap, - '123' - ); - - expect(result.success).toBe(true); - expect(result.navigation?.taskId).toBe('1'); - }); - - it('should return null when all tasks are completed', async () => { - const completedTasks = mockTasks.map(task => ({ - ...task, - status: TaskStatus.COMPLETED, - completedAt: '2024-01-01T01:00:00Z' - })); - - const result = await navigationManager.getNextAvailableTask( - completedTasks[1], - completedTasks, - '123' - ); - - expect(result.success).toBe(true); - expect(result.navigation).toBeNull(); - }); - }); - - describe('Navigation Info', () => { - it('should provide correct navigation information', () => { - const info = navigationManager.getNavigationInfo( - mockTasks[1], - mockTasks - ); - - expect(info.currentIndex).toBe(1); - expect(info.totalTasks).toBe(4); - expect(info.hasNext).toBe(true); - expect(info.hasPrevious).toBe(true); - }); - - it('should handle edge cases correctly', () => { - // First task - const firstInfo = navigationManager.getNavigationInfo( - mockTasks[0], - mockTasks - ); - expect(firstInfo.currentIndex).toBe(0); - expect(firstInfo.hasPrevious).toBe(false); - expect(firstInfo.hasNext).toBe(true); - - // Last task - const lastInfo = navigationManager.getNavigationInfo( - mockTasks[mockTasks.length - 1], - mockTasks - ); - expect(lastInfo.currentIndex).toBe(3); - expect(lastInfo.hasPrevious).toBe(true); - expect(lastInfo.hasNext).toBe(false); - }); - - it('should handle single task scenario', () => { - const singleTask = [mockTasks[0]]; - const info = navigationManager.getNavigationInfo( - singleTask[0], - singleTask - ); - - expect(info.currentIndex).toBe(0); - expect(info.totalTasks).toBe(1); - expect(info.hasNext).toBe(false); - expect(info.hasPrevious).toBe(false); - }); - }); - - describe('Error Handling', () => { - it('should handle empty task list', async () => { - const result = await navigationManager.navigateToNext( - mockTask, - [], - '123' - ); - - expect(result.success).toBe(true); - expect(result.navigation).toBeNull(); - }); - - it('should handle missing current task', async () => { - const result = await navigationManager.navigateToNext( - { ...mockTask, id: 999 }, // not in the list - mockTasks, - '123' - ); - - expect(result.success).toBe(true); - expect(result.navigation).toBeNull(); - }); - }); -}); \ No newline at end of file diff --git a/frontend/src/core/workspace/task/index.ts b/frontend/src/core/workspace/task/index.ts index f691b819..bddc2431 100644 --- a/frontend/src/core/workspace/task/index.ts +++ b/frontend/src/core/workspace/task/index.ts @@ -1,6 +1,4 @@ -export { TaskManager } from './taskManager'; export { TaskNavigationManager } from './taskNavigationManager'; // Re-export types from their proper location -export type { TaskResult, TaskService, PermissionsService } from './taskManager.types'; export type { NavigationResult, NavigationInfo } from './taskNavigationManager.types'; \ No newline at end of file diff --git a/frontend/src/core/workspace/task/taskManager.ts b/frontend/src/core/workspace/task/taskManager.ts deleted file mode 100644 index 82833505..00000000 --- a/frontend/src/core/workspace/task/taskManager.ts +++ /dev/null @@ -1,293 +0,0 @@ -import type { Task, TaskStatus } from '@/services/project/task/task.types'; -import type { TaskResult, TaskService, PermissionsService } from './taskManager.types'; -import type { TimerService } from '@/core/timeTracking'; -import { AppLogger } from '@/core/logger/logger'; - -/** - * Core business logic for task management operations. - * Handles task completion, suspension, deferring, veto operations, - * and task status validations. - */ -export class TaskManager { - private logger = AppLogger.createServiceLogger('TaskManager'); - - constructor( - private taskService: TaskService, - private permissions: PermissionsService, - private timer: TimerService - ) {} - - /** - * Complete a task using the pipeline system - * @param projectId - The project ID - * @param taskId - The task ID to complete - * @returns Promise resolving to TaskResult - */ - async completeTask(projectId: number, taskId: number): Promise { - try { - this.logger.info('Completing task using pipeline system', { taskId }); - - const result = await this.taskService.completeTaskPipeline(projectId, taskId); - - if (!result.isSuccess) { - return { - success: false, - error: result.errorMessage || 'Task completion failed' - }; - } - - // Refresh current task data - const updatedTask = await this.taskService.getTaskById(projectId, taskId); - - this.logger.info(`Successfully completed task ${taskId} via pipeline`, { details: result.details }); - - return { - success: true, - task: updatedTask, - message: `Task ${taskId} successfully completed via pipeline`, - error: undefined - }; - } catch (error) { - this.logger.error('Failed to complete task via pipeline:', error); - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to complete task' - }; - } - } - - /** - * Suspend a task with working time preservation - * @param projectId - The project ID - * @param taskId - The task ID to suspend - * @param currentWorkingTime - Current working time in milliseconds - * @returns Promise resolving to TaskResult - */ - async suspendTask(projectId: number, taskId: number, currentWorkingTime: number): Promise { - try { - // Calculate total working time including current session - const totalWorkingTime = this.calculateTotalWorkingTime(currentWorkingTime); - - this.logger.info('Suspending task with working time preservation', { - taskId, - currentTime: currentWorkingTime, - totalTime: totalWorkingTime - }); - - // Use the shared working time preservation logic - const suspendedTask = await this._saveWorkingTimeAndChangeStatus( - () => this.taskService.changeTaskStatus(projectId, taskId, { targetStatus: 'SUSPENDED' as TaskStatus }), - 'suspension', - projectId, - taskId, - totalWorkingTime - ); - - this.logger.info(`Successfully suspended task ${taskId}`); - - return { - success: true, - task: suspendedTask, - message: `Task ${taskId} successfully suspended`, - error: undefined - }; - } catch (error) { - this.logger.error('Failed to suspend task:', error); - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to suspend task' - }; - } - } - - /** - * Defer a task (skip for now) with working time preservation - * @param projectId - The project ID - * @param taskId - The task ID to defer - * @param currentWorkingTime - Current working time in milliseconds - * @returns Promise resolving to TaskResult - */ - async deferTask(projectId: number, taskId: number, currentWorkingTime: number): Promise { - try { - // Calculate total working time including current session - const totalWorkingTime = this.calculateTotalWorkingTime(currentWorkingTime); - - this.logger.info('Deferring task with working time preservation', { - taskId, - currentTime: currentWorkingTime, - totalTime: totalWorkingTime - }); - - // Use the shared working time preservation logic - const deferredTask = await this._saveWorkingTimeAndChangeStatus( - () => this.taskService.changeTaskStatus(projectId, taskId, { targetStatus: 'DEFERRED' as TaskStatus }), - 'deferring', - projectId, - taskId, - totalWorkingTime - ); - - this.logger.info(`Successfully deferred task ${taskId}`); - - return { - success: true, - task: deferredTask, - message: `Task ${taskId} successfully deferred`, - error: undefined - }; - } catch (error) { - this.logger.error('Failed to defer task:', error); - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to defer task' - }; - } - } - - /** - * Veto/return a task for rework using the veto pipeline - * @param projectId - The project ID - * @param taskId - The task ID to veto - * @param reason - Optional reason for the veto - * @returns Promise resolving to TaskResult - */ - async vetoTask(projectId: number, taskId: number, reason?: string): Promise { - try { - const vetoReason = reason || 'Task returned for rework'; - - this.logger.info('Returning task for rework using veto pipeline', { taskId, reason: vetoReason }); - - // Use the veto pipeline to handle all operations atomically - const pipelineResult = await this.taskService.vetoTaskPipeline(projectId, taskId, { - reason: vetoReason - }); - - if (!pipelineResult.isSuccess) { - return { - success: false, - error: pipelineResult.errorMessage || 'Veto pipeline failed' - }; - } - - this.logger.info(`Successfully returned task ${taskId} for rework`, { reason: vetoReason }); - - return { - success: true, - task: pipelineResult.updatedTask, - message: `Task ${taskId} successfully returned for rework`, - error: undefined - }; - } catch (error) { - this.logger.error('Failed to return task for rework:', error); - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to return task for rework' - }; - } - } - - /** - * Check if a task can be completed based on its current status - * @param task - The task to check - * @returns Boolean indicating if the task can be completed - */ - canCompleteTask(task: Task): boolean { - // Only tasks that are in progress or ready states can be completed - const completableStatuses = [ - 'IN_PROGRESS' as TaskStatus, - 'READY_FOR_ANNOTATION' as TaskStatus, - 'READY_FOR_REVIEW' as TaskStatus, - 'READY_FOR_COMPLETION' as TaskStatus, - 'CHANGES_REQUIRED' as TaskStatus - ]; - - return task.status ? completableStatuses.includes(task.status) : false; - } - - /** - * Check if a task can be opened by the current user - * @param task - The task to check - * @returns Promise resolving to boolean indicating if the task can be opened - */ - async canOpenTask(task: Task): Promise { - // Deferred tasks can only be opened by managers - if (task.status === 'DEFERRED') { - return await this.permissions.canUpdateProject(); - } - - // Vetoed tasks cannot be opened (they are view-only) - if (task.status === 'VETOED') { - return false; - } - - // Completed and archived tasks cannot be opened - if (task.status && ['COMPLETED', 'ARCHIVED'].includes(task.status as string)) { - return false; - } - - // All other tasks can be opened if user has basic permissions - return true; - } - - /** - * Calculate total working time including current timer session - * @param currentWorkingTime - Current saved working time in milliseconds - * @returns Total working time in milliseconds - */ - calculateTotalWorkingTime(currentWorkingTime: number): number { - if (!this.timer.isRunning()) { - return currentWorkingTime; - } - - const elapsedTime = this.timer.getElapsedTime(); - return currentWorkingTime + elapsedTime; - } - - /** - * Common helper to save working time and preserve it across task status changes - * @private - * @param statusChangeOperation - Function that performs the status change and returns the updated task - * @param operationName - Name of the operation for logging (e.g., "completion", "suspension") - * @param projectId - The project ID - * @param taskId - The task ID - * @param finalWorkingTime - The final working time to preserve - */ - private async _saveWorkingTimeAndChangeStatus( - statusChangeOperation: () => Promise, - operationName: string, - projectId: number, - taskId: number, - finalWorkingTime: number - ): Promise { - this.logger.info(`Starting ${operationName} with working time preservation`, { - taskId, - finalWorkingTime - }); - - // Execute the status change operation - const updatedTask = await statusChangeOperation(); - - // Ensure the working time is preserved in case the backend didn't return the latest value - if (updatedTask.workingTimeMs < finalWorkingTime) { - this.logger.warn(`Working time mismatch after ${operationName}. Backend: ${updatedTask.workingTimeMs}ms, Expected: ${finalWorkingTime}ms. Updating...`); - try { - const correctedTask = await this.taskService.updateWorkingTime(projectId, taskId, finalWorkingTime); - this.logger.info(`Working time corrected after ${operationName}`, { - taskId, - correctedTime: correctedTask.workingTimeMs - }); - return correctedTask as T; - } catch (correctionError) { - this.logger.error(`Failed to correct working time after ${operationName}:`, correctionError); - // Return the original task even if correction failed - return updatedTask; - } - } - - this.logger.info(`${operationName} completed successfully with preserved working time`, { - taskId, - workingTime: updatedTask.workingTimeMs - }); - return updatedTask; - } -} \ No newline at end of file diff --git a/frontend/src/core/workspace/task/taskManager.types.ts b/frontend/src/core/workspace/task/taskManager.types.ts deleted file mode 100644 index 872b6e91..00000000 --- a/frontend/src/core/workspace/task/taskManager.types.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { Task, TaskStatus, PipelineResultDto } from '@/services/project/task/task.types'; -import type { GetTasksResponse } from '@/services/project/task/taskService.types'; - -/** - * Result types for task operations - */ -export interface TaskResult { - success: boolean; - task?: Task; - message?: string; - error?: string; -} - -/** - * Interface for task service dependency - */ -export interface TaskService { - getTaskById(projectId: number, taskId: number): Promise; - getTasksForAsset(projectId: number, assetId: number): Promise; - getTasksForStage(projectId: number, stageId: number): Promise; - completeTaskPipeline(projectId: number, taskId: number): Promise; - vetoTaskPipeline(projectId: number, taskId: number, data: { reason: string }): Promise; - changeTaskStatus(projectId: number, taskId: number, data: { targetStatus: TaskStatus }): Promise; - updateWorkingTime(projectId: number, taskId: number, workingTimeMs: number): Promise; - saveWorkingTimeBeforeUnload(projectId: number, taskId: number, workingTimeMs: number): Promise; -} - -/** - * Interface for permissions service dependency - */ -export interface PermissionsService { - canUpdateProject(): Promise; -} \ No newline at end of file diff --git a/frontend/src/core/workspace/task/taskNavigationManager.ts b/frontend/src/core/workspace/task/taskNavigationManager.ts index fb8d27fc..82ec5e04 100644 --- a/frontend/src/core/workspace/task/taskNavigationManager.ts +++ b/frontend/src/core/workspace/task/taskNavigationManager.ts @@ -1,8 +1,11 @@ import type { Task } from '@/services/project/task/task.types'; import type { NavigationResult, NavigationInfo } from './taskNavigationManager.types'; -import type { PermissionsService } from './taskManager.types'; import { AppLogger } from '@/core/logger/logger'; +interface PermissionsService { + canUpdateProject(): Promise; +} + /** * Core business logic for task navigation operations. * Handles navigation between tasks, finding next/previous available tasks, diff --git a/frontend/src/services/project/asset/assetService.ts b/frontend/src/services/project/asset/assetService.ts index 86a31334..45a04418 100644 --- a/frontend/src/services/project/asset/assetService.ts +++ b/frontend/src/services/project/asset/assetService.ts @@ -1,8 +1,8 @@ import { BaseProjectService } from '../baseProjectService'; import { buildQueryParams } from '@/services/base/requests'; -import type { AssetListParams } from '@/services/project/asset'; -import type { UploadResult, BulkUploadResult } from '@/services/project/asset'; -import { NoFilesProvidedError } from '@/services/project/asset'; +import type { AssetListParams } from './requests'; +import type { UploadResult, BulkUploadResult } from './upload.types'; +import { NoFilesProvidedError } from './uploadErrors'; import apiClient from '../../apiClient'; import { transformApiError, isValidApiResponse, isValidPaginatedResponse } from '@/services/interceptors'; import type { PaginatedResponse } from '@/services/base/paginatedResponse'; diff --git a/frontend/src/services/project/labelScheme/__tests__/labelSchemeService.test.ts b/frontend/src/services/project/labelScheme/__tests__/labelSchemeService.test.ts index faa73828..9873bb0f 100644 --- a/frontend/src/services/project/labelScheme/__tests__/labelSchemeService.test.ts +++ b/frontend/src/services/project/labelScheme/__tests__/labelSchemeService.test.ts @@ -4,7 +4,7 @@ import type { LabelSchemeResponse, CreateLabelSchemeRequest, UpdateLabelSchemeRe import type { PaginatedResponse } from '@/services/base/paginatedResponse'; // Mock the API client -vi.mock('../apiClient', () => ({ +vi.mock('../../../apiClient', () => ({ default: { get: vi.fn(), post: vi.fn(), diff --git a/frontend/src/services/project/labelScheme/__tests__/labelService.test.ts b/frontend/src/services/project/labelScheme/__tests__/labelService.test.ts index 6dc4e5d8..593e8504 100644 --- a/frontend/src/services/project/labelScheme/__tests__/labelService.test.ts +++ b/frontend/src/services/project/labelScheme/__tests__/labelService.test.ts @@ -4,7 +4,7 @@ import type { LabelResponse, CreateLabelRequest, UpdateLabelRequest } from '../l import type { PaginatedResponse } from '@/services/base/paginatedResponse'; // Mock the API client -vi.mock('../apiClient', () => ({ +vi.mock('../../../apiClient', () => ({ default: { get: vi.fn(), post: vi.fn(), diff --git a/frontend/src/services/project/task/taskNavigation.types.ts b/frontend/src/services/project/task/taskNavigation.types.ts new file mode 100644 index 00000000..0eb99154 --- /dev/null +++ b/frontend/src/services/project/task/taskNavigation.types.ts @@ -0,0 +1,34 @@ +/** + * Response from task navigation endpoints + */ +export interface TaskNavigationResponse { + /** ID of the next/previous task, null if none available */ + taskId?: number | null; + + /** Asset ID of the next/previous task, null if none available */ + assetId?: number | null; + + /** Whether there is a next task available */ + hasNext: boolean; + + /** Whether there is a previous task available */ + hasPrevious: boolean; + + /** Current position in the navigation sequence (1-based) */ + currentPosition: number; + + /** Total number of navigable tasks */ + totalTasks: number; + + /** Optional message for user feedback */ + message?: string | null; +} + +/** + * Information for navigating to a task + */ +export interface TaskNavigationInfo { + projectId: string; + assetId: string; + taskId: string; +} \ No newline at end of file diff --git a/frontend/src/services/project/task/taskService.ts b/frontend/src/services/project/task/taskService.ts index 38d208fe..b18f9db8 100644 --- a/frontend/src/services/project/task/taskService.ts +++ b/frontend/src/services/project/task/taskService.ts @@ -4,6 +4,7 @@ import type { GetTasksResponse, } from './taskService.types'; import { TaskStatus, type ChangeTaskStatusDto, type CompleteTaskDto, type CreateTaskRequest, type PipelineResultDto, type Task, type TaskTableRow, type TaskWithDetails, type UpdateTaskRequest, type VetoTaskDto } from './task.types'; +import type { TaskNavigationResponse } from './taskNavigation.types'; import { workflowStageService } from '../workflow/workflowStageService'; import { assetService } from '../asset/assetService'; @@ -169,10 +170,10 @@ class TaskService extends BaseProjectService { /** * Get current user's tasks */ - async getMyTasks(params: TasksQueryParams = {}): Promise { + async getMyTasks(projectId: number, params: TasksQueryParams = {}): Promise { this.logger.info('Fetching current user tasks', params); - const url = this.getBaseUrl('tasks/my-tasks'); + const url = this.buildProjectUrl(projectId, 'tasks/my-tasks'); const paginatedResponse = await this.getPaginated(url, params); const tasks: Task[] = paginatedResponse.data.map((dto: any) => this.transformTaskDto(dto)); @@ -188,6 +189,21 @@ class TaskService extends BaseProjectService { }; } + /** + * Get current user's assigned tasks for a specific workflow stage + */ + async getMyTasksForStage(projectId: number, stageId: number, params: TasksQueryParams = {}): Promise { + this.logger.info(`Fetching current user tasks for workflow stage ${stageId}`, params); + + const stageParams = { + ...params, + filterOn: 'current_workflow_stage_id', + filterQuery: stageId.toString() + }; + + return this.getMyTasks(projectId, stageParams); + } + /** * Get tasks for a specific asset */ @@ -534,6 +550,59 @@ class TaskService extends BaseProjectService { this.logger.debug(`Can veto task ${taskId}: ${canVeto}`); return canVeto; } + + // ==================== Navigation API ==================== + + /** + * Get the next available task for the current user + */ + async getNextTask(projectId: number, currentTaskId: number, workflowStageId?: number): Promise { + this.logger.info(`Getting next task for current user from task ${currentTaskId} in project ${projectId}`, { workflowStageId }); + + const url = this.buildProjectUrl(projectId, 'tasks/navigation/next'); + const params = new URLSearchParams({ + currentTaskId: currentTaskId.toString(), + ...(workflowStageId && { workflowStageId: workflowStageId.toString() }) + }); + + const response = await this.get(`${url}?${params}`); + this.logger.info(`Next task result: taskId=${response.taskId}, hasNext=${response.hasNext}`); + return response; + } + + /** + * Get the previous available task for the current user + */ + async getPreviousTask(projectId: number, currentTaskId: number, workflowStageId?: number): Promise { + this.logger.info(`Getting previous task for current user from task ${currentTaskId} in project ${projectId}`, { workflowStageId }); + + const url = this.buildProjectUrl(projectId, 'tasks/navigation/previous'); + const params = new URLSearchParams({ + currentTaskId: currentTaskId.toString(), + ...(workflowStageId && { workflowStageId: workflowStageId.toString() }) + }); + + const response = await this.get(`${url}?${params}`); + this.logger.info(`Previous task result: taskId=${response.taskId}, hasPrevious=${response.hasPrevious}`); + return response; + } + + /** + * Get navigation context for the current task + */ + async getNavigationContext(projectId: number, currentTaskId: number, workflowStageId?: number): Promise { + this.logger.info(`Getting navigation context for task ${currentTaskId} in project ${projectId}`, { workflowStageId }); + + const url = this.buildProjectUrl(projectId, 'tasks/navigation/context'); + const params = new URLSearchParams({ + currentTaskId: currentTaskId.toString(), + ...(workflowStageId && { workflowStageId: workflowStageId.toString() }) + }); + + const response = await this.get(`${url}?${params}`); + this.logger.info(`Navigation context: position=${response.currentPosition}/${response.totalTasks}, hasNext=${response.hasNext}, hasPrevious=${response.hasPrevious}`); + return response; + } } export const taskService = new TaskService(); \ No newline at end of file diff --git a/frontend/src/stores/__tests__/authStore.test.ts b/frontend/src/stores/__tests__/authStore.test.ts index 1625dda4..dbcbeb75 100644 --- a/frontend/src/stores/__tests__/authStore.test.ts +++ b/frontend/src/stores/__tests__/authStore.test.ts @@ -11,6 +11,13 @@ vi.mock("@/services/auth/authService", () => ({ }, })); +vi.mock("../permissionStore", () => ({ + usePermissionStore: vi.fn(() => ({ + loadUserPermissions: vi.fn().mockResolvedValue(undefined), + clearPermissions: vi.fn(), + })), +})); + import { authService } from "@/services/auth/authService.ts"; import { RoleEnum, type AuthTokens, type LoginDto, type UserDto } from "@/services/auth/auth.types"; diff --git a/frontend/src/stores/__tests__/workspaceStore.test.ts b/frontend/src/stores/__tests__/workspaceStore.test.ts index eb315857..bff84b4c 100644 --- a/frontend/src/stores/__tests__/workspaceStore.test.ts +++ b/frontend/src/stores/__tests__/workspaceStore.test.ts @@ -9,8 +9,8 @@ import { AnnotationType } from "@/core/workspace/annotation.types"; import type { LabelScheme } from "@/services/project/labelScheme/label.types"; import { AssetStatus } from '@/core/asset/asset.types'; -// Mock the services from projects -vi.mock("@/services/projects", () => ({ +// Mock the services from project +vi.mock("@/services/project", () => ({ annotationService: { getAnnotationsForAsset: vi.fn(), createAnnotation: vi.fn(), @@ -40,7 +40,8 @@ vi.mock("@/services/projects", () => ({ import { annotationService, assetService, - labelSchemeService + labelSchemeService, + taskService } from "@/services/project"; // Mock the TimeTracker @@ -286,7 +287,6 @@ describe("Workspace Store", () => { }); // Mock task service - const { taskService } = await import("@/services/project"); vi.mocked(taskService.getTasksForAsset).mockResolvedValue([]); await workspaceStore.loadAsset("1", "1"); @@ -351,7 +351,7 @@ describe("Workspace Store", () => { labelId: mockAnnotation.labelId, isPrediction: false, confidenceScore: undefined, - isGroundTruth: false, + isGroundTruth: undefined, version: 1, notes: undefined, annotatorEmail: undefined, diff --git a/frontend/src/stores/workspaceStore.ts b/frontend/src/stores/workspaceStore.ts index 6c2ec100..62e895f6 100644 --- a/frontend/src/stores/workspaceStore.ts +++ b/frontend/src/stores/workspaceStore.ts @@ -1,12 +1,12 @@ import { defineStore } from "pinia"; import { faArrowPointer, faDotCircle, faMinus, faWaveSquare, faSquare, faDrawPolygon } from '@fortawesome/free-solid-svg-icons'; import type { ImageDimensions } from "@/core/asset/asset.types"; -import type { WorkspaceState } from "./workspaceStore.types"; +import type { WorkspaceState, TaskNavigationContext } from "./workspaceStore.types"; import type { Point } from "@/core/geometry/geometry.types"; import { ToolName, type Tool } from "@/core/workspace/tools.types"; import type { Annotation, CreateAnnotationDto } from '@/core/workspace/annotation.types'; import type { LabelScheme, Label } from '@/services/project/labelScheme/label.types'; -import { AssetManager, TaskManager, TaskNavigationManager } from '@/core/workspace'; +import { AssetManager } from '@/core/workspace'; import { TimeTracker } from '@/core/timeTracking'; import { annotationService, @@ -65,6 +65,7 @@ export const useWorkspaceStore = defineStore("workspace", { currentWorkflowStageType: null as WorkflowStageType | null, availableTasks: [] as Task[], initialTaskId: null as number | null, // Store the initial task ID from URL query + navigationContext: null as TaskNavigationContext | null, isLoading: false, error: null, }), @@ -131,31 +132,16 @@ export const useWorkspaceStore = defineStore("workspace", { } return state.availableTasks.findIndex((task: Task) => task.id === state.currentTaskData?.id); }, - getTaskNavigationInfo(state): { current: number; total: number } { - // Filter out tasks that cannot be opened for navigation - // Note: Deferred tasks permission checking is handled in the navigation buttons themselves - const accessibleTasks = state.availableTasks.filter((task: Task) => { - // Vetoed tasks cannot be opened (they are view-only) - if (task.status === TaskStatus.VETOED) { - return false; - } - - // Completed and archived tasks cannot be opened for editing - if (task.status && [TaskStatus.COMPLETED, TaskStatus.ARCHIVED].includes(task.status)) { - return false; - } - - return true; - }); - - const currentIndex = state.currentTaskData && accessibleTasks.length > 0 - ? accessibleTasks.findIndex((task: Task) => task.id === state.currentTaskData?.id) - : -1; + getTaskNavigationInfo(): { current: number; total: number } { + // Simple navigation info from server context + if (this.navigationContext) { + return { + current: this.navigationContext.currentPosition, + total: this.navigationContext.totalTasks + }; + } - return { - current: currentIndex >= 0 ? currentIndex + 1 : 0, - total: accessibleTasks.length - }; + return { current: 0, total: 0 }; }, isTaskCompleted(state): boolean { if (!state.currentTaskData) return false; @@ -172,15 +158,11 @@ export const useWorkspaceStore = defineStore("workspace", { // Annotation editing is disabled when task is completed (preview mode) return this.isTaskCompleted; }, - canNavigateToPrevious(state): boolean { - if (!state.currentTaskData || state.availableTasks.length === 0) return false; - const currentIndex = state.availableTasks.findIndex((task: Task) => task.id === state.currentTaskData?.id); - return currentIndex > 0; + canNavigateToPrevious(): boolean { + return this.navigationContext?.hasPrevious ?? false; }, - canNavigateToNext(state): boolean { - if (!state.currentTaskData || state.availableTasks.length === 0) return false; - const currentIndex = state.availableTasks.findIndex((task: Task) => task.id === state.currentTaskData?.id); - return currentIndex < state.availableTasks.length - 1; + canNavigateToNext(): boolean { + return this.navigationContext?.hasNext ?? false; }, canCompleteCurrentTask(state): boolean { if (!state.currentTaskData || !state.currentAssetData) { @@ -226,40 +208,6 @@ export const useWorkspaceStore = defineStore("workspace", { return false; }, - // Task management core instances with access to store state - taskManager(): TaskManager { - const permissionsService = { - canUpdateProject: async () => { - // Import permission check dynamically to avoid circular dependencies - try { - const { usePermissions } = await import('@/composables/usePermissions'); - const { canUpdateProject } = usePermissions(); - return canUpdateProject.value; - } catch { - return false; - } - } - }; - - return new TaskManager(taskService, permissionsService, timeTracker); - }, - - taskNavigationManager(): TaskNavigationManager { - const permissionsService = { - canUpdateProject: async () => { - // Import permission check dynamically to avoid circular dependencies - try { - const { usePermissions } = await import('@/composables/usePermissions'); - const { canUpdateProject } = usePermissions(); - return canUpdateProject.value; - } catch { - return false; - } - } - }; - - return new TaskNavigationManager(permissionsService); - } }, actions: { @@ -338,12 +286,42 @@ export const useWorkspaceStore = defineStore("workspace", { } if (currentTask && workflowStageId) { - // Load all tasks for this workflow stage to enable navigation - const stageTasks = await taskService.getTasksForStage(numericProjectId, workflowStageId); - this.availableTasks = stageTasks.tasks; + // Load user's assigned tasks for this workflow stage to enable proper navigation + const assignedTasks = await taskService.getMyTasksForStage(numericProjectId, workflowStageId); + let tasksForNavigation = assignedTasks.tasks; + + // If current task is not in the assigned tasks list (e.g., completed task viewed in preview mode), + // add it to the list so the UI shows correct context, but navigation will still skip completed tasks + const currentTaskInList = tasksForNavigation.find(task => task.id === currentTask.id); + if (!currentTaskInList) { + tasksForNavigation = [...tasksForNavigation, currentTask]; + logger.info(`Added current task ${currentTask.id} to navigation context (likely viewing completed task in preview mode)`); + } + + this.availableTasks = tasksForNavigation; this.currentTaskData = currentTask; this.currentTaskId = currentTask.id; + // Load navigation context from server + try { + const navigationContext = await taskService.getNavigationContext( + numericProjectId, + currentTask.id, + currentTask.workflowStageId + ); + this.navigationContext = { + hasNext: navigationContext.hasNext, + hasPrevious: navigationContext.hasPrevious, + currentPosition: navigationContext.currentPosition, + totalTasks: navigationContext.totalTasks, + message: navigationContext.message + }; + logger.info(`Loaded navigation context: ${navigationContext.currentPosition}/${navigationContext.totalTasks}, hasNext=${navigationContext.hasNext}, hasPrevious=${navigationContext.hasPrevious}`); + } catch (navigationError) { + logger.warn('Failed to load navigation context:', navigationError); + this.navigationContext = null; + } + // Fetch current workflow stage type for completion logic try { const stageData = await workflowStageService.getWorkflowStageById( @@ -816,56 +794,92 @@ export const useWorkspaceStore = defineStore("workspace", { * Check if a task can be opened by the current user */ async canOpenTask(task: Task): Promise { - // Use TaskManager to check if the task can be opened - return await this.taskManager.canOpenTask(task); + // Simple check - most tasks can be opened, except archived + return task.status !== TaskStatus.ARCHIVED; }, /** * Navigate to the previous task in the current workflow stage */ async navigateToPreviousTask(): Promise<{ projectId: string; assetId: string; taskId: string } | null> { - if (!this.currentTaskData || this.availableTasks.length === 0) { - logger.warn('Cannot navigate to previous task: no current task or available tasks'); + if (!this.currentTaskData || !this.currentProjectId) { + logger.warn('Cannot navigate to previous task: no current task or project'); return null; } - // Use TaskNavigationManager to find previous task - const result = await this.taskNavigationManager.navigateToPrevious( - this.currentTaskData, - this.availableTasks, - this.currentProjectId! - ); + try { + const navigation = await taskService.getPreviousTask( + parseInt(this.currentProjectId), + this.currentTaskData.id, + this.currentTaskData.workflowStageId + ); + + if (navigation.taskId && navigation.assetId) { + logger.info('Successfully navigated to previous task:', navigation); + // Update navigation context + this.navigationContext = { + hasNext: navigation.hasNext, + hasPrevious: navigation.hasPrevious, + currentPosition: navigation.currentPosition, + totalTasks: navigation.totalTasks, + message: navigation.message + }; + + return { + projectId: this.currentProjectId, + assetId: navigation.assetId.toString(), + taskId: navigation.taskId.toString() + }; + } - if (!result.success) { - logger.error('Failed to navigate to previous task:', result.error); + logger.info('No previous task available'); + return null; + } catch (error) { + logger.error('Failed to navigate to previous task:', error); return null; } - - return result.navigation; }, /** * Navigate to the next task in the current workflow stage */ async navigateToNextTask(): Promise<{ projectId: string; assetId: string; taskId: string } | null> { - if (!this.currentTaskData || this.availableTasks.length === 0) { - logger.warn('Cannot navigate to next task: no current task or available tasks'); + if (!this.currentTaskData || !this.currentProjectId) { + logger.warn('Cannot navigate to next task: no current task or project'); return null; } - // Use TaskNavigationManager to find next task - const result = await this.taskNavigationManager.navigateToNext( - this.currentTaskData, - this.availableTasks, - this.currentProjectId! - ); + try { + const navigation = await taskService.getNextTask( + parseInt(this.currentProjectId), + this.currentTaskData.id, + this.currentTaskData.workflowStageId + ); - if (!result.success) { - logger.error('Failed to navigate to next task:', result.error); + if (navigation.taskId && navigation.assetId) { + logger.info('Successfully navigated to next task:', navigation); + // Update navigation context + this.navigationContext = { + hasNext: navigation.hasNext, + hasPrevious: navigation.hasPrevious, + currentPosition: navigation.currentPosition, + totalTasks: navigation.totalTasks, + message: navigation.message + }; + + return { + projectId: this.currentProjectId, + assetId: navigation.assetId.toString(), + taskId: navigation.taskId.toString() + }; + } + + logger.info('No next task available'); + return null; + } catch (error) { + logger.error('Failed to navigate to next task:', error); return null; } - - return result.navigation; }, /** @@ -880,28 +894,28 @@ export const useWorkspaceStore = defineStore("workspace", { try { const numericProjectId = parseInt(this.currentProjectId); - // Use TaskManager to complete the task - const result = await this.taskManager.completeTask(numericProjectId, this.currentTaskData.id); + // Use TaskService pipeline to complete the task + const result = await taskService.completeTaskPipeline(numericProjectId, this.currentTaskData.id); - if (!result.success) { - this.error = result.error || 'Failed to complete task'; + if (!result.isSuccess) { + this.error = result.errorMessage || 'Failed to complete task'; return false; } // Update current task data - if (result.task) { - this.currentTaskData = result.task; + if (result.updatedTask) { + this.currentTaskData = result.updatedTask; // Update the task in the available tasks list - const taskIndex = this.availableTasks.findIndex((task: Task) => task.id === result.task!.id); + const taskIndex = this.availableTasks.findIndex((task: Task) => task.id === result.updatedTask!.id); if (taskIndex !== -1) { - this.availableTasks[taskIndex] = result.task; + this.availableTasks[taskIndex] = result.updatedTask; } } return true; } catch (error) { - logger.error('Failed to complete task via TaskManager:', error); + logger.error('Failed to complete task:', error); this.error = error instanceof Error ? error.message : 'Failed to complete task'; return false; } @@ -919,32 +933,33 @@ export const useWorkspaceStore = defineStore("workspace", { try { const numericProjectId = parseInt(this.currentProjectId); - // Use TaskManager to suspend the task with working time preservation - const result = await this.taskManager.suspendTask( + // Update working time before suspending + await taskService.updateWorkingTime(numericProjectId, this.currentTaskData.id, this.lastSavedWorkingTime); + + // Use TaskService to suspend the task + const result = await taskService.changeTaskStatus( numericProjectId, this.currentTaskData.id, - this.lastSavedWorkingTime + { targetStatus: TaskStatus.SUSPENDED } ); - if (!result.success) { - this.error = result.error || 'Failed to suspend task'; + if (!result) { + this.error = 'Failed to suspend task'; return false; } // Update current task data - if (result.task) { - this.currentTaskData = result.task; - - // Update the task in the available tasks list - const taskIndex = this.availableTasks.findIndex((task: Task) => task.id === result.task!.id); - if (taskIndex !== -1) { - this.availableTasks[taskIndex] = result.task; - } + this.currentTaskData = result; + + // Update the task in the available tasks list + const taskIndex = this.availableTasks.findIndex((task: Task) => task.id === result.id); + if (taskIndex !== -1) { + this.availableTasks[taskIndex] = result; } return true; } catch (error) { - logger.error('Failed to suspend task via TaskManager:', error); + logger.error('Failed to suspend task:', error); this.error = error instanceof Error ? error.message : 'Failed to suspend task'; return false; } @@ -962,32 +977,33 @@ export const useWorkspaceStore = defineStore("workspace", { try { const numericProjectId = parseInt(this.currentProjectId); - // Use TaskManager to defer the task with working time preservation - const result = await this.taskManager.deferTask( + // Update working time before deferring + await taskService.updateWorkingTime(numericProjectId, this.currentTaskData.id, this.lastSavedWorkingTime); + + // Use TaskService to defer the task + const result = await taskService.changeTaskStatus( numericProjectId, this.currentTaskData.id, - this.lastSavedWorkingTime + { targetStatus: TaskStatus.DEFERRED } ); - if (!result.success) { - this.error = result.error || 'Failed to defer task'; + if (!result) { + this.error = 'Failed to defer task'; return false; } // Update current task data - if (result.task) { - this.currentTaskData = result.task; - - // Update the task in the available tasks list - const taskIndex = this.availableTasks.findIndex((task: Task) => task.id === result.task!.id); - if (taskIndex !== -1) { - this.availableTasks[taskIndex] = result.task; - } + this.currentTaskData = result; + + // Update the task in the available tasks list + const taskIndex = this.availableTasks.findIndex((task: Task) => task.id === result.id); + if (taskIndex !== -1) { + this.availableTasks[taskIndex] = result; } return true; } catch (error) { - logger.error('Failed to defer task via TaskManager:', error); + logger.error('Failed to defer task:', error); this.error = error instanceof Error ? error.message : 'Failed to defer task'; return false; } @@ -1005,32 +1021,32 @@ export const useWorkspaceStore = defineStore("workspace", { try { const numericProjectId = parseInt(this.currentProjectId); - // Use TaskManager to veto the task - const result = await this.taskManager.vetoTask( + // Use TaskService pipeline to veto the task + const result = await taskService.vetoTaskPipeline( numericProjectId, this.currentTaskData.id, - reason + { reason: reason || 'Task returned for rework' } ); - if (!result.success) { - this.error = result.error || 'Failed to return task for rework'; + if (!result.isSuccess) { + this.error = result.errorMessage || 'Failed to return task for rework'; return false; } // Update current task data - if (result.task) { - this.currentTaskData = result.task; + if (result.updatedTask) { + this.currentTaskData = result.updatedTask; // Update the task in the available tasks list - const taskIndex = this.availableTasks.findIndex((task: Task) => task.id === result.task!.id); + const taskIndex = this.availableTasks.findIndex((task: Task) => task.id === result.updatedTask!.id); if (taskIndex !== -1) { - this.availableTasks[taskIndex] = result.task; + this.availableTasks[taskIndex] = result.updatedTask; } } return true; } catch (error) { - logger.error('Failed to return task for rework via TaskManager:', error); + logger.error('Failed to return task for rework:', error); this.error = error instanceof Error ? error.message : 'Failed to return task for rework'; return false; } @@ -1040,24 +1056,8 @@ export const useWorkspaceStore = defineStore("workspace", { * Get the next available task for seamless transitions */ async getNextAvailableTask(): Promise<{ projectId: string; assetId: string; taskId: string } | null> { - if (!this.currentTaskData || this.availableTasks.length === 0) { - logger.warn('Cannot get next task: no current task or available tasks'); - return null; - } - - // Use TaskNavigationManager to find next available task - const result = await this.taskNavigationManager.getNextAvailableTask( - this.currentTaskData, - this.availableTasks, - this.currentProjectId! - ); - - if (!result.success) { - logger.error('Failed to get next available task:', result.error); - return null; - } - - return result.navigation; + // Simply delegate to navigateToNextTask - they do the same thing + return this.navigateToNextTask(); }, /** diff --git a/frontend/src/stores/workspaceStore.types.ts b/frontend/src/stores/workspaceStore.types.ts index afe91ebc..10479c33 100644 --- a/frontend/src/stores/workspaceStore.types.ts +++ b/frontend/src/stores/workspaceStore.types.ts @@ -7,6 +7,17 @@ import type { Asset } from '@/core/asset/asset.types'; import type { Task } from '@/services/project/task/task.types'; import type { WorkflowStageType } from '@/services/project/workflow/workflowStage.types'; +/** + * Simple navigation context from server + */ +export interface TaskNavigationContext { + hasNext: boolean; + hasPrevious: boolean; + currentPosition: number; + totalTasks: number; + message?: string | null; +} + /** * Current flat workspace store state interface (legacy structure) * This matches the actual current implementation in workspaceStore.ts @@ -43,8 +54,11 @@ export interface WorkspaceState { currentTaskId: number | null; currentTaskData: Task | null; currentWorkflowStageType: WorkflowStageType | null; - availableTasks: Task[]; + availableTasks: Task[]; // Legacy - will be removed initialTaskId: number | null; + + // Simple navigation context + navigationContext: TaskNavigationContext | null; } /** diff --git a/frontend/src/views/AnnotationWorkspace.vue b/frontend/src/views/AnnotationWorkspace.vue index de069a9d..080b46bb 100644 --- a/frontend/src/views/AnnotationWorkspace.vue +++ b/frontend/src/views/AnnotationWorkspace.vue @@ -415,17 +415,6 @@ onMounted(async () => { window.addEventListener('beforeunload', handleBeforeUnload); window.addEventListener('pagehide', handlePageHide); document.addEventListener('visibilitychange', handleVisibilityChange); - - // Also handle focus/blur events - window.addEventListener('blur', () => { - // Window lost focus - time tracker will handle this - console.log('Window lost focus'); - }); - - window.addEventListener('focus', () => { - // Window gained focus - time tracker will handle this - console.log('Window gained focus'); - }); }); onUnmounted(() => { diff --git a/frontend/src/views/project/TasksView.vue b/frontend/src/views/project/TasksView.vue index 843c1d0d..d3ec8947 100644 --- a/frontend/src/views/project/TasksView.vue +++ b/frontend/src/views/project/TasksView.vue @@ -40,6 +40,13 @@ > {{ showDeferredTasks ? 'Hide Deferred' : 'Show Deferred' }} +