From e2475d0d9fcd06826c97cfcc033780d4230da222 Mon Sep 17 00:00:00 2001 From: hskwon Date: Fri, 28 Nov 2025 08:49:30 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[pm]=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EC=A7=84=ED=96=89=20=EA=B4=80=EB=A6=AC=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Models: AdminPmProject, AdminPmTask, AdminPmIssue - Services: ProjectService, TaskService, IssueService, ImportService - API Controllers: ProjectController, TaskController, IssueController, ImportController - FormRequests: Store/Update/BulkAction 요청 검증 - Views: 대시보드, 프로젝트 CRUD, JSON Import 화면 - Routes: API 42개 + Web 6개 엔드포인트 주요 기능: - 프로젝트/작업/이슈 계층 구조 관리 - 상태 변경, 우선순위, 마감일 추적 - 작업 순서 드래그앤드롭 (reorder API) - JSON Import로 일괄 등록 - Soft Delete 및 복원 --- .../ProjectManagement/ImportController.php | 110 ++++ .../ProjectManagement/IssueController.php | 256 ++++++++ .../ProjectManagement/ProjectController.php | 198 ++++++ .../ProjectManagement/TaskController.php | 242 +++++++ .../ProjectManagementController.php | 104 +++ .../ProjectManagement/BulkActionRequest.php | 97 +++ .../ImportProjectRequest.php | 51 ++ .../ProjectManagement/StoreIssueRequest.php | 78 +++ .../ProjectManagement/StoreProjectRequest.php | 72 ++ .../ProjectManagement/StoreTaskRequest.php | 84 +++ .../ProjectManagement/UpdateIssueRequest.php | 63 ++ .../UpdateProjectRequest.php | 61 ++ .../ProjectManagement/UpdateTaskRequest.php | 69 ++ app/Models/Admin/AdminPmIssue.php | 180 +++++ app/Models/Admin/AdminPmProject.php | 154 +++++ app/Models/Admin/AdminPmTask.php | 210 ++++++ .../ProjectManagement/ImportService.php | 191 ++++++ .../ProjectManagement/IssueService.php | 305 +++++++++ .../ProjectManagement/ProjectService.php | 241 +++++++ .../ProjectManagement/TaskService.php | 299 +++++++++ resources/views/partials/sidebar.blade.php | 66 +- .../views/project-management/import.blade.php | 444 +++++++++++++ .../views/project-management/index.blade.php | 300 +++++++++ .../projects/create.blade.php | 128 ++++ .../projects/edit.blade.php | 129 ++++ .../projects/index.blade.php | 135 ++++ .../projects/partials/table.blade.php | 105 +++ .../projects/show.blade.php | 621 ++++++++++++++++++ routes/api.php | 115 ++++ routes/web.php | 24 + 30 files changed, 5131 insertions(+), 1 deletion(-) create mode 100644 app/Http/Controllers/Api/Admin/ProjectManagement/ImportController.php create mode 100644 app/Http/Controllers/Api/Admin/ProjectManagement/IssueController.php create mode 100644 app/Http/Controllers/Api/Admin/ProjectManagement/ProjectController.php create mode 100644 app/Http/Controllers/Api/Admin/ProjectManagement/TaskController.php create mode 100644 app/Http/Controllers/ProjectManagementController.php create mode 100644 app/Http/Requests/ProjectManagement/BulkActionRequest.php create mode 100644 app/Http/Requests/ProjectManagement/ImportProjectRequest.php create mode 100644 app/Http/Requests/ProjectManagement/StoreIssueRequest.php create mode 100644 app/Http/Requests/ProjectManagement/StoreProjectRequest.php create mode 100644 app/Http/Requests/ProjectManagement/StoreTaskRequest.php create mode 100644 app/Http/Requests/ProjectManagement/UpdateIssueRequest.php create mode 100644 app/Http/Requests/ProjectManagement/UpdateProjectRequest.php create mode 100644 app/Http/Requests/ProjectManagement/UpdateTaskRequest.php create mode 100644 app/Models/Admin/AdminPmIssue.php create mode 100644 app/Models/Admin/AdminPmProject.php create mode 100644 app/Models/Admin/AdminPmTask.php create mode 100644 app/Services/ProjectManagement/ImportService.php create mode 100644 app/Services/ProjectManagement/IssueService.php create mode 100644 app/Services/ProjectManagement/ProjectService.php create mode 100644 app/Services/ProjectManagement/TaskService.php create mode 100644 resources/views/project-management/import.blade.php create mode 100644 resources/views/project-management/index.blade.php create mode 100644 resources/views/project-management/projects/create.blade.php create mode 100644 resources/views/project-management/projects/edit.blade.php create mode 100644 resources/views/project-management/projects/index.blade.php create mode 100644 resources/views/project-management/projects/partials/table.blade.php create mode 100644 resources/views/project-management/projects/show.blade.php diff --git a/app/Http/Controllers/Api/Admin/ProjectManagement/ImportController.php b/app/Http/Controllers/Api/Admin/ProjectManagement/ImportController.php new file mode 100644 index 00000000..d22fe234 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/ProjectManagement/ImportController.php @@ -0,0 +1,110 @@ +importService->importFromJson($request->validated()); + + return response()->json([ + 'success' => true, + 'message' => "프로젝트가 생성되었습니다. (작업: {$result['tasks_count']}개, 이슈: {$result['issues_count']}개)", + 'data' => [ + 'project_id' => $result['project']->id, + 'project_name' => $result['project']->name, + 'tasks_count' => $result['tasks_count'], + 'issues_count' => $result['issues_count'], + ], + ]); + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => '가져오기 실패: '.$e->getMessage(), + ], 500); + } + } + + /** + * 기존 프로젝트에 작업/이슈 추가 + */ + public function importTasks(Request $request, int $projectId): JsonResponse + { + $validated = $request->validate([ + 'tasks' => 'required|array|min:1', + 'tasks.*.title' => 'required|string|max:255', + 'tasks.*.description' => 'nullable|string', + 'tasks.*.status' => 'nullable|in:todo,in_progress,done', + 'tasks.*.priority' => 'nullable|in:low,medium,high', + 'tasks.*.due_date' => 'nullable|date', + 'tasks.*.issues' => 'nullable|array', + 'tasks.*.issues.*.title' => 'required|string|max:255', + 'tasks.*.issues.*.description' => 'nullable|string', + 'tasks.*.issues.*.type' => 'nullable|in:bug,feature,improvement', + 'tasks.*.issues.*.status' => 'nullable|in:open,in_progress,resolved,closed', + ]); + + try { + $result = $this->importService->importTasksToProject($projectId, $validated['tasks']); + + return response()->json([ + 'success' => true, + 'message' => "작업이 추가되었습니다. (작업: {$result['tasks_count']}개, 이슈: {$result['issues_count']}개)", + 'data' => $result, + ]); + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => '가져오기 실패: '.$e->getMessage(), + ], 500); + } + } + + /** + * JSON 구조 사전 검증 + */ + public function validate(Request $request): JsonResponse + { + $data = $request->all(); + $errors = $this->importService->validateJsonStructure($data); + + if (! empty($errors)) { + return response()->json([ + 'success' => false, + 'message' => 'JSON 구조가 올바르지 않습니다.', + 'errors' => $errors, + ], 422); + } + + return response()->json([ + 'success' => true, + 'message' => 'JSON 구조가 유효합니다.', + ]); + } + + /** + * 샘플 JSON 템플릿 반환 + */ + public function template(): JsonResponse + { + return response()->json([ + 'success' => true, + 'data' => $this->importService->getSampleTemplate(), + ]); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/Admin/ProjectManagement/IssueController.php b/app/Http/Controllers/Api/Admin/ProjectManagement/IssueController.php new file mode 100644 index 00000000..5f6d6a8f --- /dev/null +++ b/app/Http/Controllers/Api/Admin/ProjectManagement/IssueController.php @@ -0,0 +1,256 @@ +only([ + 'project_id', 'task_id', 'search', 'type', + 'status', 'open_only', 'trashed', 'sort_by', 'sort_direction', + ]); + $issues = $this->issueService->getIssues($filters, 15); + $types = AdminPmIssue::getTypes(); + $statuses = AdminPmIssue::getStatuses(); + + // HTMX 요청이면 HTML 파셜 반환 + if ($request->header('HX-Request')) { + return view('project-management.issues.partials.table', compact('issues', 'types', 'statuses')); + } + + return response()->json([ + 'success' => true, + 'data' => $issues, + ]); + } + + /** + * 프로젝트별 이슈 목록 + */ + public function byProject(int $projectId, Request $request): View|JsonResponse + { + $issues = $this->issueService->getIssuesByProject($projectId); + $types = AdminPmIssue::getTypes(); + $statuses = AdminPmIssue::getStatuses(); + + // HTMX 요청이면 HTML 파셜 반환 + if ($request->header('HX-Request')) { + return view('project-management.issues.partials.list', compact('issues', 'types', 'statuses')); + } + + return response()->json([ + 'success' => true, + 'data' => $issues, + ]); + } + + /** + * 작업별 이슈 목록 + */ + public function byTask(int $taskId, Request $request): View|JsonResponse + { + $issues = $this->issueService->getIssuesByTask($taskId); + $types = AdminPmIssue::getTypes(); + $statuses = AdminPmIssue::getStatuses(); + + // HTMX 요청이면 HTML 파셜 반환 + if ($request->header('HX-Request')) { + return view('project-management.issues.partials.list', compact('issues', 'types', 'statuses')); + } + + return response()->json([ + 'success' => true, + 'data' => $issues, + ]); + } + + /** + * 열린 이슈 목록 (대시보드용) + */ + public function open(Request $request): JsonResponse + { + $projectId = $request->input('project_id'); + $limit = $request->input('limit', 10); + $issues = $this->issueService->getOpenIssues($projectId, $limit); + + return response()->json([ + 'success' => true, + 'data' => $issues, + ]); + } + + /** + * 이슈 통계 + */ + public function stats(Request $request): JsonResponse + { + $projectId = $request->input('project_id'); + + if ($projectId) { + $stats = $this->issueService->getIssueStatsByProject($projectId); + } else { + $stats = $this->issueService->getIssueStats(); + } + + return response()->json([ + 'success' => true, + 'data' => $stats, + ]); + } + + /** + * 이슈 상세 조회 + */ + public function show(int $id): JsonResponse + { + $issue = $this->issueService->getIssueById($id, true); + + if (! $issue) { + return response()->json([ + 'success' => false, + 'message' => '이슈를 찾을 수 없습니다.', + ], 404); + } + + return response()->json([ + 'success' => true, + 'data' => $issue, + ]); + } + + /** + * 이슈 생성 + */ + public function store(StoreIssueRequest $request): JsonResponse + { + $issue = $this->issueService->createIssue($request->validated()); + + return response()->json([ + 'success' => true, + 'message' => '이슈가 생성되었습니다.', + 'data' => $issue, + ]); + } + + /** + * 이슈 수정 + */ + public function update(UpdateIssueRequest $request, int $id): JsonResponse + { + $this->issueService->updateIssue($id, $request->validated()); + + return response()->json([ + 'success' => true, + 'message' => '이슈가 수정되었습니다.', + ]); + } + + /** + * 이슈 삭제 (Soft Delete) + */ + public function destroy(int $id): JsonResponse + { + $this->issueService->deleteIssue($id); + + return response()->json([ + 'success' => true, + 'message' => '이슈가 삭제되었습니다.', + ]); + } + + /** + * 이슈 복원 + */ + public function restore(int $id): JsonResponse + { + $this->issueService->restoreIssue($id); + + return response()->json([ + 'success' => true, + 'message' => '이슈가 복원되었습니다.', + ]); + } + + /** + * 이슈 영구 삭제 + */ + public function forceDestroy(int $id): JsonResponse + { + $this->issueService->forceDeleteIssue($id); + + return response()->json([ + 'success' => true, + 'message' => '이슈가 영구 삭제되었습니다.', + ]); + } + + /** + * 이슈 상태 변경 + */ + public function changeStatus(Request $request, int $id): JsonResponse + { + $validated = $request->validate([ + 'status' => 'required|in:open,in_progress,resolved,closed', + ]); + + $issue = $this->issueService->changeStatus($id, $validated['status']); + + return response()->json([ + 'success' => true, + 'message' => '이슈 상태가 변경되었습니다.', + 'data' => $issue, + ]); + } + + /** + * 이슈 일괄 처리 + */ + public function bulk(BulkActionRequest $request): JsonResponse + { + $data = $request->validated(); + $ids = $data['ids']; + $action = $data['action']; + $value = $data['value'] ?? null; + + $count = match ($action) { + 'change_status' => $this->issueService->bulkChangeStatus($ids, $value), + 'change_type' => $this->issueService->bulkChangeType($ids, $value), + 'link_task' => $this->issueService->bulkLinkToTask($ids, $value), + 'delete' => $this->issueService->bulkDelete($ids), + 'restore' => $this->issueService->bulkRestore($ids), + default => 0, + }; + + $messages = [ + 'change_status' => '상태가 변경되었습니다.', + 'change_type' => '타입이 변경되었습니다.', + 'link_task' => '작업 연결이 변경되었습니다.', + 'delete' => '삭제되었습니다.', + 'restore' => '복원되었습니다.', + ]; + + return response()->json([ + 'success' => true, + 'message' => "{$count}개 이슈가 ".$messages[$action], + 'data' => ['affected' => $count], + ]); + } +} diff --git a/app/Http/Controllers/Api/Admin/ProjectManagement/ProjectController.php b/app/Http/Controllers/Api/Admin/ProjectManagement/ProjectController.php new file mode 100644 index 00000000..024b3dfd --- /dev/null +++ b/app/Http/Controllers/Api/Admin/ProjectManagement/ProjectController.php @@ -0,0 +1,198 @@ +only(['search', 'status', 'trashed', 'sort_by', 'sort_direction']); + $projects = $this->projectService->getProjects($filters, 15); + + // HTMX 요청이면 HTML 파셜 반환 + if ($request->header('HX-Request')) { + return view('project-management.projects.partials.table', compact('projects')); + } + + return response()->json([ + 'success' => true, + 'data' => $projects, + ]); + } + + /** + * 활성 프로젝트 목록 (드롭다운용) + */ + public function dropdown(): JsonResponse + { + $projects = $this->projectService->getActiveProjects(); + + return response()->json([ + 'success' => true, + 'data' => $projects, + ]); + } + + /** + * 프로젝트 통계 + */ + public function stats(): JsonResponse + { + $stats = $this->projectService->getProjectStats(); + + return response()->json([ + 'success' => true, + 'data' => $stats, + ]); + } + + /** + * 대시보드 요약 + */ + public function dashboard(): JsonResponse + { + $summary = $this->projectService->getDashboardSummary(); + + return response()->json([ + 'success' => true, + 'data' => $summary, + ]); + } + + /** + * 프로젝트 상세 조회 + */ + public function show(int $id): JsonResponse + { + $project = $this->projectService->getProjectById($id, true); + + if (! $project) { + return response()->json([ + 'success' => false, + 'message' => '프로젝트를 찾을 수 없습니다.', + ], 404); + } + + return response()->json([ + 'success' => true, + 'data' => $project, + ]); + } + + /** + * 프로젝트 생성 + */ + public function store(StoreProjectRequest $request): JsonResponse + { + $project = $this->projectService->createProject($request->validated()); + + return response()->json([ + 'success' => true, + 'message' => '프로젝트가 생성되었습니다.', + 'data' => $project, + ]); + } + + /** + * 프로젝트 수정 + */ + public function update(UpdateProjectRequest $request, int $id): JsonResponse + { + $this->projectService->updateProject($id, $request->validated()); + + return response()->json([ + 'success' => true, + 'message' => '프로젝트가 수정되었습니다.', + ]); + } + + /** + * 프로젝트 삭제 (Soft Delete) + */ + public function destroy(int $id): JsonResponse + { + $this->projectService->deleteProject($id); + + return response()->json([ + 'success' => true, + 'message' => '프로젝트가 삭제되었습니다.', + ]); + } + + /** + * 프로젝트 복원 + */ + public function restore(int $id): JsonResponse + { + $this->projectService->restoreProject($id); + + return response()->json([ + 'success' => true, + 'message' => '프로젝트가 복원되었습니다.', + ]); + } + + /** + * 프로젝트 영구 삭제 + */ + public function forceDestroy(int $id): JsonResponse + { + $this->projectService->forceDeleteProject($id); + + return response()->json([ + 'success' => true, + 'message' => '프로젝트가 영구 삭제되었습니다.', + ]); + } + + /** + * 프로젝트 상태 변경 + */ + public function changeStatus(Request $request, int $id): JsonResponse + { + $validated = $request->validate([ + 'status' => 'required|in:active,completed,on_hold', + ]); + + $project = $this->projectService->changeStatus($id, $validated['status']); + + return response()->json([ + 'success' => true, + 'message' => '프로젝트 상태가 변경되었습니다.', + 'data' => $project, + ]); + } + + /** + * 프로젝트 복제 + */ + public function duplicate(Request $request, int $id): JsonResponse + { + $validated = $request->validate([ + 'name' => 'nullable|string|max:100', + ]); + + $project = $this->projectService->duplicateProject($id, $validated['name'] ?? null); + + return response()->json([ + 'success' => true, + 'message' => '프로젝트가 복제되었습니다.', + 'data' => $project, + ]); + } +} diff --git a/app/Http/Controllers/Api/Admin/ProjectManagement/TaskController.php b/app/Http/Controllers/Api/Admin/ProjectManagement/TaskController.php new file mode 100644 index 00000000..241d043b --- /dev/null +++ b/app/Http/Controllers/Api/Admin/ProjectManagement/TaskController.php @@ -0,0 +1,242 @@ +only([ + 'project_id', 'search', 'status', 'priority', + 'assignee_id', 'due_status', 'trashed', 'sort_by', 'sort_direction', + ]); + $tasks = $this->taskService->getTasks($filters, 15); + + // HTMX 요청이면 HTML 파셜 반환 + if ($request->header('HX-Request')) { + return view('project-management.tasks.partials.table', compact('tasks')); + } + + return response()->json([ + 'success' => true, + 'data' => $tasks, + ]); + } + + /** + * 프로젝트별 작업 목록 (칸반보드용) + */ + public function byProject(int $projectId, Request $request): View|JsonResponse + { + $tasks = $this->taskService->getTasksByProject($projectId); + + // HTMX 요청이면 HTML 파셜 반환 + if ($request->header('HX-Request')) { + return view('project-management.tasks.partials.kanban', compact('tasks')); + } + + return response()->json([ + 'success' => true, + 'data' => $tasks, + ]); + } + + /** + * 마감 임박/지연 작업 목록 + */ + public function urgent(Request $request): JsonResponse + { + $projectId = $request->input('project_id'); + $urgentTasks = $this->taskService->getUrgentTasks($projectId); + + return response()->json([ + 'success' => true, + 'data' => $urgentTasks, + ]); + } + + /** + * 작업 상세 조회 + */ + public function show(int $id): JsonResponse + { + $task = $this->taskService->getTaskById($id, true); + + if (! $task) { + return response()->json([ + 'success' => false, + 'message' => '작업을 찾을 수 없습니다.', + ], 404); + } + + return response()->json([ + 'success' => true, + 'data' => $task, + ]); + } + + /** + * 작업 생성 + */ + public function store(StoreTaskRequest $request): JsonResponse + { + $task = $this->taskService->createTask($request->validated()); + + return response()->json([ + 'success' => true, + 'message' => '작업이 생성되었습니다.', + 'data' => $task, + ]); + } + + /** + * 작업 수정 + */ + public function update(UpdateTaskRequest $request, int $id): JsonResponse + { + $this->taskService->updateTask($id, $request->validated()); + + return response()->json([ + 'success' => true, + 'message' => '작업이 수정되었습니다.', + ]); + } + + /** + * 작업 삭제 (Soft Delete) + */ + public function destroy(int $id): JsonResponse + { + $this->taskService->deleteTask($id); + + return response()->json([ + 'success' => true, + 'message' => '작업이 삭제되었습니다.', + ]); + } + + /** + * 작업 복원 + */ + public function restore(int $id): JsonResponse + { + $this->taskService->restoreTask($id); + + return response()->json([ + 'success' => true, + 'message' => '작업이 복원되었습니다.', + ]); + } + + /** + * 작업 영구 삭제 + */ + public function forceDestroy(int $id): JsonResponse + { + $this->taskService->forceDeleteTask($id); + + return response()->json([ + 'success' => true, + 'message' => '작업이 영구 삭제되었습니다.', + ]); + } + + /** + * 작업 상태 변경 + */ + public function changeStatus(Request $request, int $id): JsonResponse + { + $validated = $request->validate([ + 'status' => 'required|in:todo,in_progress,done', + ]); + + $task = $this->taskService->changeStatus($id, $validated['status']); + + return response()->json([ + 'success' => true, + 'message' => '작업 상태가 변경되었습니다.', + 'data' => $task, + ]); + } + + /** + * 작업 순서 변경 (드래그앤드롭) + */ + public function reorder(Request $request, int $projectId): JsonResponse + { + $validated = $request->validate([ + 'task_ids' => 'required|array', + 'task_ids.*' => 'integer', + ]); + + $this->taskService->reorderTasks($projectId, $validated['task_ids']); + + return response()->json([ + 'success' => true, + 'message' => '작업 순서가 변경되었습니다.', + ]); + } + + /** + * 작업 일괄 처리 + */ + public function bulk(BulkActionRequest $request): JsonResponse + { + $data = $request->validated(); + $ids = $data['ids']; + $action = $data['action']; + $value = $data['value'] ?? null; + + $count = match ($action) { + 'change_status' => $this->taskService->bulkChangeStatus($ids, $value), + 'change_priority' => $this->taskService->bulkChangePriority($ids, $value), + 'change_assignee' => $this->taskService->bulkChangeAssignee($ids, $value), + 'delete' => $this->taskService->bulkDelete($ids), + 'restore' => $this->taskService->bulkRestore($ids), + default => 0, + }; + + $messages = [ + 'change_status' => '상태가 변경되었습니다.', + 'change_priority' => '우선순위가 변경되었습니다.', + 'change_assignee' => '담당자가 변경되었습니다.', + 'delete' => '삭제되었습니다.', + 'restore' => '복원되었습니다.', + ]; + + return response()->json([ + 'success' => true, + 'message' => "{$count}개 작업이 ".$messages[$action], + 'data' => ['affected' => $count], + ]); + } + + /** + * 프로젝트별 작업 통계 + */ + public function stats(int $projectId): JsonResponse + { + $stats = $this->taskService->getTaskStatsByProject($projectId); + + return response()->json([ + 'success' => true, + 'data' => $stats, + ]); + } +} diff --git a/app/Http/Controllers/ProjectManagementController.php b/app/Http/Controllers/ProjectManagementController.php new file mode 100644 index 00000000..902dbfaa --- /dev/null +++ b/app/Http/Controllers/ProjectManagementController.php @@ -0,0 +1,104 @@ +projectService->getDashboardSummary(); + $statuses = AdminPmProject::getStatuses(); + $taskStatuses = AdminPmTask::getStatuses(); + $priorities = AdminPmTask::getPriorities(); + + return view('project-management.index', compact( + 'summary', + 'statuses', + 'taskStatuses', + 'priorities' + )); + } + + /** + * 프로젝트 목록 화면 + */ + public function projects(): View + { + $statuses = AdminPmProject::getStatuses(); + + return view('project-management.projects.index', compact('statuses')); + } + + /** + * 프로젝트 생성 화면 + */ + public function createProject(): View + { + $statuses = AdminPmProject::getStatuses(); + + return view('project-management.projects.create', compact('statuses')); + } + + /** + * 프로젝트 수정 화면 + */ + public function editProject(int $id): View + { + $project = $this->projectService->getProjectById($id, true); + + if (! $project) { + abort(404, '프로젝트를 찾을 수 없습니다.'); + } + + $statuses = AdminPmProject::getStatuses(); + + return view('project-management.projects.edit', compact('project', 'statuses')); + } + + /** + * 프로젝트 상세 화면 (작업/이슈 목록 포함) + */ + public function showProject(int $id): View + { + $project = $this->projectService->getProjectById($id, true); + + if (! $project) { + abort(404, '프로젝트를 찾을 수 없습니다.'); + } + + $statuses = AdminPmProject::getStatuses(); + $taskStatuses = AdminPmTask::getStatuses(); + $priorities = AdminPmTask::getPriorities(); + + return view('project-management.projects.show', compact( + 'project', + 'statuses', + 'taskStatuses', + 'priorities' + )); + } + + /** + * JSON Import 화면 + */ + public function import(ImportService $importService): View + { + $template = $importService->getSampleTemplate(); + $projects = $this->projectService->getActiveProjects(); + + return view('project-management.import', compact('template', 'projects')); + } +} diff --git a/app/Http/Requests/ProjectManagement/BulkActionRequest.php b/app/Http/Requests/ProjectManagement/BulkActionRequest.php new file mode 100644 index 00000000..2fba8bad --- /dev/null +++ b/app/Http/Requests/ProjectManagement/BulkActionRequest.php @@ -0,0 +1,97 @@ + 'required|array|min:1', + 'ids.*' => 'required|integer', + 'action' => 'required|in:change_status,change_priority,change_assignee,change_type,link_task,delete,restore', + ]; + + // 액션에 따른 추가 유효성 검사 + switch ($this->input('action')) { + case 'change_status': + $statusOptions = $this->getStatusOptions(); + $rules['value'] = 'required|in:'.implode(',', $statusOptions); + break; + case 'change_priority': + $rules['value'] = 'required|in:'.implode(',', array_keys(AdminPmTask::getPriorities())); + break; + case 'change_assignee': + $rules['value'] = 'nullable|exists:users,id'; + break; + case 'change_type': + $rules['value'] = 'required|in:'.implode(',', array_keys(AdminPmIssue::getTypes())); + break; + case 'link_task': + $rules['value'] = 'nullable|exists:admin_pm_tasks,id'; + break; + } + + return $rules; + } + + /** + * Get status options based on resource type. + */ + protected function getStatusOptions(): array + { + $type = $this->input('type', 'task'); + + if ($type === 'issue') { + return array_keys(AdminPmIssue::getStatuses()); + } + + return array_keys(AdminPmTask::getStatuses()); + } + + /** + * Get custom attributes for validator errors. + */ + public function attributes(): array + { + return [ + 'ids' => '선택된 항목', + 'ids.*' => '항목 ID', + 'action' => '작업', + 'value' => '값', + ]; + } + + /** + * Get the error messages for the defined validation rules. + */ + public function messages(): array + { + return [ + 'ids.required' => '최소 하나의 항목을 선택해주세요.', + 'ids.array' => '선택된 항목이 올바르지 않습니다.', + 'ids.min' => '최소 하나의 항목을 선택해주세요.', + 'ids.*.integer' => '항목 ID가 올바르지 않습니다.', + 'action.required' => '수행할 작업을 선택해주세요.', + 'action.in' => '올바른 작업을 선택해주세요.', + 'value.required' => '값을 입력해주세요.', + 'value.in' => '올바른 값을 선택해주세요.', + 'value.exists' => '존재하지 않는 항목입니다.', + ]; + } +} diff --git a/app/Http/Requests/ProjectManagement/ImportProjectRequest.php b/app/Http/Requests/ProjectManagement/ImportProjectRequest.php new file mode 100644 index 00000000..75f7297a --- /dev/null +++ b/app/Http/Requests/ProjectManagement/ImportProjectRequest.php @@ -0,0 +1,51 @@ + 'required|array', + 'project.name' => 'required|string|max:255', + 'project.description' => 'nullable|string', + 'project.status' => 'nullable|in:active,completed,on_hold', + 'project.start_date' => 'nullable|date', + 'project.end_date' => 'nullable|date|after_or_equal:project.start_date', + + // 작업 목록 + 'tasks' => 'nullable|array', + 'tasks.*.title' => 'required|string|max:255', + 'tasks.*.description' => 'nullable|string', + 'tasks.*.status' => 'nullable|in:todo,in_progress,done', + 'tasks.*.priority' => 'nullable|in:low,medium,high', + 'tasks.*.due_date' => 'nullable|date', + + // 작업별 이슈 목록 + 'tasks.*.issues' => 'nullable|array', + 'tasks.*.issues.*.title' => 'required|string|max:255', + 'tasks.*.issues.*.description' => 'nullable|string', + 'tasks.*.issues.*.type' => 'nullable|in:bug,feature,improvement', + 'tasks.*.issues.*.status' => 'nullable|in:open,in_progress,resolved,closed', + ]; + } + + public function messages(): array + { + return [ + 'project.required' => 'project 객체는 필수입니다.', + 'project.name.required' => 'project.name은 필수입니다.', + 'tasks.*.title.required' => '각 작업의 title은 필수입니다.', + 'tasks.*.issues.*.title.required' => '각 이슈의 title은 필수입니다.', + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/ProjectManagement/StoreIssueRequest.php b/app/Http/Requests/ProjectManagement/StoreIssueRequest.php new file mode 100644 index 00000000..68b33de2 --- /dev/null +++ b/app/Http/Requests/ProjectManagement/StoreIssueRequest.php @@ -0,0 +1,78 @@ + 'required|exists:admin_pm_projects,id', + 'task_id' => 'nullable|exists:admin_pm_tasks,id', + 'title' => 'required|string|max:255', + 'description' => 'nullable|string|max:5000', + 'type' => 'nullable|in:'.implode(',', array_keys(AdminPmIssue::getTypes())), + 'status' => 'nullable|in:'.implode(',', array_keys(AdminPmIssue::getStatuses())), + ]; + } + + /** + * Get custom attributes for validator errors. + */ + public function attributes(): array + { + return [ + 'project_id' => '프로젝트', + 'task_id' => '연결된 작업', + 'title' => '이슈 제목', + 'description' => '이슈 설명', + 'type' => '타입', + 'status' => '상태', + ]; + } + + /** + * Get the error messages for the defined validation rules. + */ + public function messages(): array + { + return [ + 'project_id.required' => '프로젝트를 선택해주세요.', + 'project_id.exists' => '존재하지 않는 프로젝트입니다.', + 'task_id.exists' => '존재하지 않는 작업입니다.', + 'title.required' => '이슈 제목은 필수입니다.', + 'title.max' => '이슈 제목은 최대 255자까지 입력 가능합니다.', + 'description.max' => '이슈 설명은 최대 5000자까지 입력 가능합니다.', + 'type.in' => '올바른 타입을 선택해주세요.', + 'status.in' => '올바른 상태를 선택해주세요.', + ]; + } + + /** + * Prepare the data for validation. + */ + protected function prepareForValidation(): void + { + // 기본값 설정 + if (! $this->has('type')) { + $this->merge(['type' => AdminPmIssue::TYPE_BUG]); + } + if (! $this->has('status')) { + $this->merge(['status' => AdminPmIssue::STATUS_OPEN]); + } + } +} diff --git a/app/Http/Requests/ProjectManagement/StoreProjectRequest.php b/app/Http/Requests/ProjectManagement/StoreProjectRequest.php new file mode 100644 index 00000000..a18f9dc5 --- /dev/null +++ b/app/Http/Requests/ProjectManagement/StoreProjectRequest.php @@ -0,0 +1,72 @@ + 'required|string|max:100', + 'description' => 'nullable|string|max:2000', + 'status' => 'nullable|in:'.implode(',', array_keys(AdminPmProject::getStatuses())), + 'start_date' => 'nullable|date', + 'end_date' => 'nullable|date|after_or_equal:start_date', + ]; + } + + /** + * Get custom attributes for validator errors. + */ + public function attributes(): array + { + return [ + 'name' => '프로젝트 이름', + 'description' => '설명', + 'status' => '상태', + 'start_date' => '시작일', + 'end_date' => '종료일', + ]; + } + + /** + * Get the error messages for the defined validation rules. + */ + public function messages(): array + { + return [ + 'name.required' => '프로젝트 이름은 필수입니다.', + 'name.max' => '프로젝트 이름은 최대 100자까지 입력 가능합니다.', + 'description.max' => '설명은 최대 2000자까지 입력 가능합니다.', + 'status.in' => '올바른 상태를 선택해주세요.', + 'start_date.date' => '올바른 날짜 형식이 아닙니다.', + 'end_date.date' => '올바른 날짜 형식이 아닙니다.', + 'end_date.after_or_equal' => '종료일은 시작일 이후여야 합니다.', + ]; + } + + /** + * Prepare the data for validation. + */ + protected function prepareForValidation(): void + { + // 기본값 설정 + if (! $this->has('status')) { + $this->merge(['status' => AdminPmProject::STATUS_ACTIVE]); + } + } +} diff --git a/app/Http/Requests/ProjectManagement/StoreTaskRequest.php b/app/Http/Requests/ProjectManagement/StoreTaskRequest.php new file mode 100644 index 00000000..085f5d95 --- /dev/null +++ b/app/Http/Requests/ProjectManagement/StoreTaskRequest.php @@ -0,0 +1,84 @@ + 'required|exists:admin_pm_projects,id', + 'title' => 'required|string|max:255', + 'description' => 'nullable|string|max:5000', + 'status' => 'nullable|in:'.implode(',', array_keys(AdminPmTask::getStatuses())), + 'priority' => 'nullable|in:'.implode(',', array_keys(AdminPmTask::getPriorities())), + 'due_date' => 'nullable|date', + 'sort_order' => 'nullable|integer|min:0', + 'assignee_id' => 'nullable|exists:users,id', + ]; + } + + /** + * Get custom attributes for validator errors. + */ + public function attributes(): array + { + return [ + 'project_id' => '프로젝트', + 'title' => '작업 제목', + 'description' => '작업 설명', + 'status' => '상태', + 'priority' => '우선순위', + 'due_date' => '마감일', + 'sort_order' => '정렬 순서', + 'assignee_id' => '담당자', + ]; + } + + /** + * Get the error messages for the defined validation rules. + */ + public function messages(): array + { + return [ + 'project_id.required' => '프로젝트를 선택해주세요.', + 'project_id.exists' => '존재하지 않는 프로젝트입니다.', + 'title.required' => '작업 제목은 필수입니다.', + 'title.max' => '작업 제목은 최대 255자까지 입력 가능합니다.', + 'description.max' => '작업 설명은 최대 5000자까지 입력 가능합니다.', + 'status.in' => '올바른 상태를 선택해주세요.', + 'priority.in' => '올바른 우선순위를 선택해주세요.', + 'due_date.date' => '올바른 날짜 형식이 아닙니다.', + 'sort_order.integer' => '정렬 순서는 숫자여야 합니다.', + 'assignee_id.exists' => '존재하지 않는 담당자입니다.', + ]; + } + + /** + * Prepare the data for validation. + */ + protected function prepareForValidation(): void + { + // 기본값 설정 + if (! $this->has('status')) { + $this->merge(['status' => AdminPmTask::STATUS_TODO]); + } + if (! $this->has('priority')) { + $this->merge(['priority' => AdminPmTask::PRIORITY_MEDIUM]); + } + } +} diff --git a/app/Http/Requests/ProjectManagement/UpdateIssueRequest.php b/app/Http/Requests/ProjectManagement/UpdateIssueRequest.php new file mode 100644 index 00000000..80fbe0f4 --- /dev/null +++ b/app/Http/Requests/ProjectManagement/UpdateIssueRequest.php @@ -0,0 +1,63 @@ + 'sometimes|exists:admin_pm_projects,id', + 'task_id' => 'nullable|exists:admin_pm_tasks,id', + 'title' => 'sometimes|required|string|max:255', + 'description' => 'nullable|string|max:5000', + 'type' => 'sometimes|in:'.implode(',', array_keys(AdminPmIssue::getTypes())), + 'status' => 'sometimes|in:'.implode(',', array_keys(AdminPmIssue::getStatuses())), + ]; + } + + /** + * Get custom attributes for validator errors. + */ + public function attributes(): array + { + return [ + 'project_id' => '프로젝트', + 'task_id' => '연결된 작업', + 'title' => '이슈 제목', + 'description' => '이슈 설명', + 'type' => '타입', + 'status' => '상태', + ]; + } + + /** + * Get the error messages for the defined validation rules. + */ + public function messages(): array + { + return [ + 'project_id.exists' => '존재하지 않는 프로젝트입니다.', + 'task_id.exists' => '존재하지 않는 작업입니다.', + 'title.required' => '이슈 제목은 필수입니다.', + 'title.max' => '이슈 제목은 최대 255자까지 입력 가능합니다.', + 'description.max' => '이슈 설명은 최대 5000자까지 입력 가능합니다.', + 'type.in' => '올바른 타입을 선택해주세요.', + 'status.in' => '올바른 상태를 선택해주세요.', + ]; + } +} diff --git a/app/Http/Requests/ProjectManagement/UpdateProjectRequest.php b/app/Http/Requests/ProjectManagement/UpdateProjectRequest.php new file mode 100644 index 00000000..ff62f1ea --- /dev/null +++ b/app/Http/Requests/ProjectManagement/UpdateProjectRequest.php @@ -0,0 +1,61 @@ + 'sometimes|required|string|max:100', + 'description' => 'nullable|string|max:2000', + 'status' => 'sometimes|in:'.implode(',', array_keys(AdminPmProject::getStatuses())), + 'start_date' => 'nullable|date', + 'end_date' => 'nullable|date|after_or_equal:start_date', + ]; + } + + /** + * Get custom attributes for validator errors. + */ + public function attributes(): array + { + return [ + 'name' => '프로젝트 이름', + 'description' => '설명', + 'status' => '상태', + 'start_date' => '시작일', + 'end_date' => '종료일', + ]; + } + + /** + * Get the error messages for the defined validation rules. + */ + public function messages(): array + { + return [ + 'name.required' => '프로젝트 이름은 필수입니다.', + 'name.max' => '프로젝트 이름은 최대 100자까지 입력 가능합니다.', + 'description.max' => '설명은 최대 2000자까지 입력 가능합니다.', + 'status.in' => '올바른 상태를 선택해주세요.', + 'start_date.date' => '올바른 날짜 형식이 아닙니다.', + 'end_date.date' => '올바른 날짜 형식이 아닙니다.', + 'end_date.after_or_equal' => '종료일은 시작일 이후여야 합니다.', + ]; + } +} diff --git a/app/Http/Requests/ProjectManagement/UpdateTaskRequest.php b/app/Http/Requests/ProjectManagement/UpdateTaskRequest.php new file mode 100644 index 00000000..02923d6a --- /dev/null +++ b/app/Http/Requests/ProjectManagement/UpdateTaskRequest.php @@ -0,0 +1,69 @@ + 'sometimes|exists:admin_pm_projects,id', + 'title' => 'sometimes|required|string|max:255', + 'description' => 'nullable|string|max:5000', + 'status' => 'sometimes|in:'.implode(',', array_keys(AdminPmTask::getStatuses())), + 'priority' => 'sometimes|in:'.implode(',', array_keys(AdminPmTask::getPriorities())), + 'due_date' => 'nullable|date', + 'sort_order' => 'nullable|integer|min:0', + 'assignee_id' => 'nullable|exists:users,id', + ]; + } + + /** + * Get custom attributes for validator errors. + */ + public function attributes(): array + { + return [ + 'project_id' => '프로젝트', + 'title' => '작업 제목', + 'description' => '작업 설명', + 'status' => '상태', + 'priority' => '우선순위', + 'due_date' => '마감일', + 'sort_order' => '정렬 순서', + 'assignee_id' => '담당자', + ]; + } + + /** + * Get the error messages for the defined validation rules. + */ + public function messages(): array + { + return [ + 'project_id.exists' => '존재하지 않는 프로젝트입니다.', + 'title.required' => '작업 제목은 필수입니다.', + 'title.max' => '작업 제목은 최대 255자까지 입력 가능합니다.', + 'description.max' => '작업 설명은 최대 5000자까지 입력 가능합니다.', + 'status.in' => '올바른 상태를 선택해주세요.', + 'priority.in' => '올바른 우선순위를 선택해주세요.', + 'due_date.date' => '올바른 날짜 형식이 아닙니다.', + 'sort_order.integer' => '정렬 순서는 숫자여야 합니다.', + 'assignee_id.exists' => '존재하지 않는 담당자입니다.', + ]; + } +} diff --git a/app/Models/Admin/AdminPmIssue.php b/app/Models/Admin/AdminPmIssue.php new file mode 100644 index 00000000..e273f62a --- /dev/null +++ b/app/Models/Admin/AdminPmIssue.php @@ -0,0 +1,180 @@ + 'integer', + 'task_id' => 'integer', + 'created_by' => 'integer', + 'updated_by' => 'integer', + 'deleted_by' => 'integer', + ]; + + /** + * 타입 상수 + */ + public const TYPE_BUG = 'bug'; + + public const TYPE_FEATURE = 'feature'; + + public const TYPE_IMPROVEMENT = 'improvement'; + + /** + * 상태 상수 + */ + public const STATUS_OPEN = 'open'; + + public const STATUS_IN_PROGRESS = 'in_progress'; + + public const STATUS_RESOLVED = 'resolved'; + + public const STATUS_CLOSED = 'closed'; + + /** + * 타입 목록 + */ + public static function getTypes(): array + { + return [ + self::TYPE_BUG => '버그', + self::TYPE_FEATURE => '기능', + self::TYPE_IMPROVEMENT => '개선', + ]; + } + + /** + * 상태 목록 + */ + public static function getStatuses(): array + { + return [ + self::STATUS_OPEN => '대기중', + self::STATUS_IN_PROGRESS => '처리중', + self::STATUS_RESOLVED => '해결됨', + self::STATUS_CLOSED => '종료', + ]; + } + + /** + * 타입별 필터 + */ + public function scopeType($query, string $type) + { + return $query->where('type', $type); + } + + /** + * 상태별 필터 + */ + public function scopeStatus($query, string $status) + { + return $query->where('status', $status); + } + + /** + * 열린 이슈 (open, in_progress) + */ + public function scopeOpen($query) + { + return $query->whereIn('status', [self::STATUS_OPEN, self::STATUS_IN_PROGRESS]); + } + + /** + * 관계: 프로젝트 + */ + public function project(): BelongsTo + { + return $this->belongsTo(AdminPmProject::class, 'project_id'); + } + + /** + * 관계: 연결된 작업 + */ + public function task(): BelongsTo + { + return $this->belongsTo(AdminPmTask::class, 'task_id'); + } + + /** + * 관계: 생성자 + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * 관계: 수정자 + */ + public function updater(): BelongsTo + { + return $this->belongsTo(User::class, 'updated_by'); + } + + /** + * 타입 아이콘 + */ + public function getTypeIconAttribute(): string + { + return match ($this->type) { + self::TYPE_BUG => 'bug', + self::TYPE_FEATURE => 'sparkles', + self::TYPE_IMPROVEMENT => 'arrow-trending-up', + default => 'question-mark-circle', + }; + } + + /** + * 상태 색상 + */ + public function getStatusColorAttribute(): string + { + return match ($this->status) { + self::STATUS_OPEN => 'red', + self::STATUS_IN_PROGRESS => 'yellow', + self::STATUS_RESOLVED => 'green', + self::STATUS_CLOSED => 'gray', + default => 'gray', + }; + } +} diff --git a/app/Models/Admin/AdminPmProject.php b/app/Models/Admin/AdminPmProject.php new file mode 100644 index 00000000..a129bab2 --- /dev/null +++ b/app/Models/Admin/AdminPmProject.php @@ -0,0 +1,154 @@ + 'date', + 'end_date' => 'date', + 'created_by' => 'integer', + 'updated_by' => 'integer', + 'deleted_by' => 'integer', + ]; + + /** + * 상태 상수 + */ + public const STATUS_ACTIVE = 'active'; + + public const STATUS_COMPLETED = 'completed'; + + public const STATUS_ON_HOLD = 'on_hold'; + + /** + * 상태 목록 + */ + public static function getStatuses(): array + { + return [ + self::STATUS_ACTIVE => '진행중', + self::STATUS_COMPLETED => '완료', + self::STATUS_ON_HOLD => '보류', + ]; + } + + /** + * 활성 프로젝트만 조회 + */ + public function scopeActive($query) + { + return $query->where('status', self::STATUS_ACTIVE); + } + + /** + * 관계: 작업 목록 + */ + public function tasks(): HasMany + { + return $this->hasMany(AdminPmTask::class, 'project_id'); + } + + /** + * 관계: 이슈 목록 + */ + public function issues(): HasMany + { + return $this->hasMany(AdminPmIssue::class, 'project_id'); + } + + /** + * 관계: 생성자 + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * 관계: 수정자 + */ + public function updater(): BelongsTo + { + return $this->belongsTo(User::class, 'updated_by'); + } + + /** + * 진행률 계산 (완료 작업 / 전체 작업) + */ + public function getProgressAttribute(): float + { + $total = $this->tasks()->count(); + if ($total === 0) { + return 0; + } + + $done = $this->tasks()->where('status', AdminPmTask::STATUS_DONE)->count(); + + return round(($done / $total) * 100, 1); + } + + /** + * 작업 통계 + */ + public function getTaskStatsAttribute(): array + { + return [ + 'total' => $this->tasks()->count(), + 'todo' => $this->tasks()->where('status', AdminPmTask::STATUS_TODO)->count(), + 'in_progress' => $this->tasks()->where('status', AdminPmTask::STATUS_IN_PROGRESS)->count(), + 'done' => $this->tasks()->where('status', AdminPmTask::STATUS_DONE)->count(), + ]; + } + + /** + * 이슈 통계 + */ + public function getIssueStatsAttribute(): array + { + return [ + 'total' => $this->issues()->count(), + 'open' => $this->issues()->where('status', AdminPmIssue::STATUS_OPEN)->count(), + 'in_progress' => $this->issues()->where('status', AdminPmIssue::STATUS_IN_PROGRESS)->count(), + 'resolved' => $this->issues()->where('status', AdminPmIssue::STATUS_RESOLVED)->count(), + 'closed' => $this->issues()->where('status', AdminPmIssue::STATUS_CLOSED)->count(), + ]; + } +} diff --git a/app/Models/Admin/AdminPmTask.php b/app/Models/Admin/AdminPmTask.php new file mode 100644 index 00000000..ddf238ea --- /dev/null +++ b/app/Models/Admin/AdminPmTask.php @@ -0,0 +1,210 @@ + 'integer', + 'due_date' => 'date', + 'sort_order' => 'integer', + 'assignee_id' => 'integer', + 'created_by' => 'integer', + 'updated_by' => 'integer', + 'deleted_by' => 'integer', + ]; + + /** + * 상태 상수 + */ + public const STATUS_TODO = 'todo'; + + public const STATUS_IN_PROGRESS = 'in_progress'; + + public const STATUS_DONE = 'done'; + + /** + * 우선순위 상수 + */ + public const PRIORITY_LOW = 'low'; + + public const PRIORITY_MEDIUM = 'medium'; + + public const PRIORITY_HIGH = 'high'; + + /** + * 상태 목록 + */ + public static function getStatuses(): array + { + return [ + self::STATUS_TODO => '예정', + self::STATUS_IN_PROGRESS => '진행중', + self::STATUS_DONE => '완료', + ]; + } + + /** + * 우선순위 목록 + */ + public static function getPriorities(): array + { + return [ + self::PRIORITY_LOW => '낮음', + self::PRIORITY_MEDIUM => '보통', + self::PRIORITY_HIGH => '높음', + ]; + } + + /** + * 상태별 필터 + */ + public function scopeStatus($query, string $status) + { + return $query->where('status', $status); + } + + /** + * 우선순위별 필터 + */ + public function scopePriority($query, string $priority) + { + return $query->where('priority', $priority); + } + + /** + * 마감일 지난 작업 + */ + public function scopeOverdue($query) + { + return $query->whereNotNull('due_date') + ->where('due_date', '<', now()->startOfDay()) + ->where('status', '!=', self::STATUS_DONE); + } + + /** + * 마감일 임박 (3일 이내) + */ + public function scopeDueSoon($query, int $days = 3) + { + return $query->whereNotNull('due_date') + ->whereBetween('due_date', [now()->startOfDay(), now()->addDays($days)->endOfDay()]) + ->where('status', '!=', self::STATUS_DONE); + } + + /** + * 관계: 프로젝트 + */ + public function project(): BelongsTo + { + return $this->belongsTo(AdminPmProject::class, 'project_id'); + } + + /** + * 관계: 담당자 + */ + public function assignee(): BelongsTo + { + return $this->belongsTo(User::class, 'assignee_id'); + } + + /** + * 관계: 연결된 이슈들 + */ + public function issues(): HasMany + { + return $this->hasMany(AdminPmIssue::class, 'task_id'); + } + + /** + * 관계: 생성자 + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * 관계: 수정자 + */ + public function updater(): BelongsTo + { + return $this->belongsTo(User::class, 'updated_by'); + } + + /** + * D-day 계산 (마감일까지 남은 일수) + */ + public function getDdayAttribute(): ?int + { + if (! $this->due_date) { + return null; + } + + return now()->startOfDay()->diffInDays($this->due_date, false); + } + + /** + * 마감 상태 (overdue, due_soon, normal, null) + */ + public function getDueStatusAttribute(): ?string + { + if (! $this->due_date || $this->status === self::STATUS_DONE) { + return null; + } + + $dday = $this->dday; + + if ($dday < 0) { + return 'overdue'; + } + if ($dday <= 3) { + return 'due_soon'; + } + + return 'normal'; + } +} diff --git a/app/Services/ProjectManagement/ImportService.php b/app/Services/ProjectManagement/ImportService.php new file mode 100644 index 00000000..cf7034ea --- /dev/null +++ b/app/Services/ProjectManagement/ImportService.php @@ -0,0 +1,191 @@ + null, + 'tasks_count' => 0, + 'issues_count' => 0, + ]; + + // 1. 프로젝트 생성 + $projectData = $data['project']; + $project = AdminPmProject::create([ + 'name' => $projectData['name'], + 'description' => $projectData['description'] ?? null, + 'status' => $projectData['status'] ?? AdminPmProject::STATUS_ACTIVE, + 'start_date' => $projectData['start_date'] ?? null, + 'end_date' => $projectData['end_date'] ?? null, + 'created_by' => auth()->id(), + ]); + $result['project'] = $project; + + // 2. 작업 생성 + $tasks = $data['tasks'] ?? []; + $sortOrder = 1; + + foreach ($tasks as $taskData) { + $task = AdminPmTask::create([ + 'project_id' => $project->id, + 'title' => $taskData['title'], + 'description' => $taskData['description'] ?? null, + 'status' => $taskData['status'] ?? AdminPmTask::STATUS_TODO, + 'priority' => $taskData['priority'] ?? AdminPmTask::PRIORITY_MEDIUM, + 'due_date' => $taskData['due_date'] ?? null, + 'sort_order' => $sortOrder++, + 'created_by' => auth()->id(), + ]); + $result['tasks_count']++; + + // 3. 작업별 이슈 생성 + $issues = $taskData['issues'] ?? []; + foreach ($issues as $issueData) { + AdminPmIssue::create([ + 'project_id' => $project->id, + 'task_id' => $task->id, + 'title' => $issueData['title'], + 'description' => $issueData['description'] ?? null, + 'type' => $issueData['type'] ?? AdminPmIssue::TYPE_BUG, + 'status' => $issueData['status'] ?? AdminPmIssue::STATUS_OPEN, + 'created_by' => auth()->id(), + ]); + $result['issues_count']++; + } + } + + return $result; + }); + } + + /** + * 기존 프로젝트에 작업/이슈 추가 + */ + public function importTasksToProject(int $projectId, array $tasks): array + { + return DB::transaction(function () use ($projectId, $tasks) { + $project = AdminPmProject::findOrFail($projectId); + + $result = [ + 'project_id' => $projectId, + 'tasks_count' => 0, + 'issues_count' => 0, + ]; + + $maxSortOrder = AdminPmTask::where('project_id', $projectId)->max('sort_order') ?? 0; + $sortOrder = $maxSortOrder + 1; + + foreach ($tasks as $taskData) { + $task = AdminPmTask::create([ + 'project_id' => $project->id, + 'title' => $taskData['title'], + 'description' => $taskData['description'] ?? null, + 'status' => $taskData['status'] ?? AdminPmTask::STATUS_TODO, + 'priority' => $taskData['priority'] ?? AdminPmTask::PRIORITY_MEDIUM, + 'due_date' => $taskData['due_date'] ?? null, + 'sort_order' => $sortOrder++, + 'created_by' => auth()->id(), + ]); + $result['tasks_count']++; + + $issues = $taskData['issues'] ?? []; + foreach ($issues as $issueData) { + AdminPmIssue::create([ + 'project_id' => $project->id, + 'task_id' => $task->id, + 'title' => $issueData['title'], + 'description' => $issueData['description'] ?? null, + 'type' => $issueData['type'] ?? AdminPmIssue::TYPE_BUG, + 'status' => $issueData['status'] ?? AdminPmIssue::STATUS_OPEN, + 'created_by' => auth()->id(), + ]); + $result['issues_count']++; + } + } + + return $result; + }); + } + + /** + * JSON 샘플 템플릿 반환 + */ + public function getSampleTemplate(): array + { + return [ + 'project' => [ + 'name' => '프로젝트명 (필수)', + 'description' => '프로젝트 설명 (선택)', + 'status' => 'active', + 'start_date' => date('Y-m-d'), + 'end_date' => date('Y-m-d', strtotime('+3 months')), + ], + 'tasks' => [ + [ + 'title' => '작업 1 (필수)', + 'description' => '작업 설명 (선택)', + 'status' => 'todo', + 'priority' => 'medium', + 'due_date' => date('Y-m-d', strtotime('+1 week')), + 'issues' => [ + [ + 'title' => '이슈 1', + 'description' => '이슈 설명', + 'type' => 'bug', + 'status' => 'open', + ], + ], + ], + [ + 'title' => '작업 2', + 'status' => 'todo', + 'priority' => 'high', + ], + ], + ]; + } + + /** + * JSON 유효성 검사 (FormRequest 전에 미리 체크) + */ + public function validateJsonStructure(array $data): array + { + $errors = []; + + if (! isset($data['project'])) { + $errors[] = 'project 객체가 필요합니다.'; + } elseif (! isset($data['project']['name']) || empty($data['project']['name'])) { + $errors[] = 'project.name은 필수입니다.'; + } + + if (isset($data['tasks']) && is_array($data['tasks'])) { + foreach ($data['tasks'] as $index => $task) { + if (! isset($task['title']) || empty($task['title'])) { + $errors[] = "tasks[{$index}].title은 필수입니다."; + } + + if (isset($task['issues']) && is_array($task['issues'])) { + foreach ($task['issues'] as $issueIndex => $issue) { + if (! isset($issue['title']) || empty($issue['title'])) { + $errors[] = "tasks[{$index}].issues[{$issueIndex}].title은 필수입니다."; + } + } + } + } + } + + return $errors; + } +} \ No newline at end of file diff --git a/app/Services/ProjectManagement/IssueService.php b/app/Services/ProjectManagement/IssueService.php new file mode 100644 index 00000000..8728d3ce --- /dev/null +++ b/app/Services/ProjectManagement/IssueService.php @@ -0,0 +1,305 @@ +with(['project', 'task', 'creator']) + ->withTrashed(); + + // 프로젝트 필터 + if (! empty($filters['project_id'])) { + $query->where('project_id', $filters['project_id']); + } + + // 작업 필터 + if (! empty($filters['task_id'])) { + $query->where('task_id', $filters['task_id']); + } + + // 검색 필터 + if (! empty($filters['search'])) { + $search = $filters['search']; + $query->where(function ($q) use ($search) { + $q->where('title', 'like', "%{$search}%") + ->orWhere('description', 'like', "%{$search}%"); + }); + } + + // 타입 필터 + if (! empty($filters['type'])) { + $query->where('type', $filters['type']); + } + + // 상태 필터 + if (! empty($filters['status'])) { + $query->where('status', $filters['status']); + } + + // 열린 이슈만 필터 + if (! empty($filters['open_only']) && $filters['open_only']) { + $query->open(); + } + + // Soft Delete 필터 + if (isset($filters['trashed'])) { + if ($filters['trashed'] === 'only') { + $query->onlyTrashed(); + } elseif ($filters['trashed'] === 'with') { + $query->withTrashed(); + } + } + + // 정렬 + $sortBy = $filters['sort_by'] ?? 'id'; + $sortDirection = $filters['sort_direction'] ?? 'desc'; + $query->orderBy($sortBy, $sortDirection); + + return $query->paginate($perPage); + } + + /** + * 프로젝트별 이슈 목록 + */ + public function getIssuesByProject(int $projectId): Collection + { + return AdminPmIssue::query() + ->where('project_id', $projectId) + ->with(['task', 'creator']) + ->latest() + ->get(); + } + + /** + * 작업별 이슈 목록 + */ + public function getIssuesByTask(int $taskId): Collection + { + return AdminPmIssue::query() + ->where('task_id', $taskId) + ->with(['creator']) + ->latest() + ->get(); + } + + /** + * 특정 이슈 조회 + */ + public function getIssueById(int $id, bool $withTrashed = false): ?AdminPmIssue + { + $query = AdminPmIssue::query() + ->with(['project', 'task', 'creator', 'updater']); + + if ($withTrashed) { + $query->withTrashed(); + } + + return $query->find($id); + } + + /** + * 이슈 생성 + */ + public function createIssue(array $data): AdminPmIssue + { + $data['created_by'] = auth()->id(); + + return AdminPmIssue::create($data); + } + + /** + * 이슈 수정 + */ + public function updateIssue(int $id, array $data): bool + { + $issue = AdminPmIssue::findOrFail($id); + + $data['updated_by'] = auth()->id(); + + return $issue->update($data); + } + + /** + * 이슈 삭제 (Soft Delete) + */ + public function deleteIssue(int $id): bool + { + $issue = AdminPmIssue::findOrFail($id); + + $issue->deleted_by = auth()->id(); + $issue->save(); + + return $issue->delete(); + } + + /** + * 이슈 복원 + */ + public function restoreIssue(int $id): bool + { + $issue = AdminPmIssue::onlyTrashed()->findOrFail($id); + + $issue->deleted_by = null; + + return $issue->restore(); + } + + /** + * 이슈 영구 삭제 + */ + public function forceDeleteIssue(int $id): bool + { + return AdminPmIssue::withTrashed()->findOrFail($id)->forceDelete(); + } + + /** + * 이슈 상태 변경 + */ + public function changeStatus(int $id, string $status): AdminPmIssue + { + $issue = AdminPmIssue::findOrFail($id); + + $issue->status = $status; + $issue->updated_by = auth()->id(); + $issue->save(); + + return $issue; + } + + /** + * 다중 이슈 상태 일괄 변경 + */ + public function bulkChangeStatus(array $issueIds, string $status): int + { + return AdminPmIssue::whereIn('id', $issueIds) + ->update([ + 'status' => $status, + 'updated_by' => auth()->id(), + 'updated_at' => now(), + ]); + } + + /** + * 다중 이슈 타입 일괄 변경 + */ + public function bulkChangeType(array $issueIds, string $type): int + { + return AdminPmIssue::whereIn('id', $issueIds) + ->update([ + 'type' => $type, + 'updated_by' => auth()->id(), + 'updated_at' => now(), + ]); + } + + /** + * 다중 이슈 작업 연결 일괄 변경 + */ + public function bulkLinkToTask(array $issueIds, ?int $taskId): int + { + return AdminPmIssue::whereIn('id', $issueIds) + ->update([ + 'task_id' => $taskId, + 'updated_by' => auth()->id(), + 'updated_at' => now(), + ]); + } + + /** + * 다중 이슈 일괄 삭제 + */ + public function bulkDelete(array $issueIds): int + { + AdminPmIssue::whereIn('id', $issueIds) + ->update([ + 'deleted_by' => auth()->id(), + ]); + + return AdminPmIssue::whereIn('id', $issueIds)->delete(); + } + + /** + * 다중 이슈 일괄 복원 + */ + public function bulkRestore(array $issueIds): int + { + return AdminPmIssue::onlyTrashed() + ->whereIn('id', $issueIds) + ->update([ + 'deleted_by' => null, + 'deleted_at' => null, + ]); + } + + /** + * 이슈 통계 (프로젝트별) + */ + public function getIssueStatsByProject(int $projectId): array + { + return [ + 'total' => AdminPmIssue::where('project_id', $projectId)->count(), + 'open' => AdminPmIssue::where('project_id', $projectId) + ->status(AdminPmIssue::STATUS_OPEN)->count(), + 'in_progress' => AdminPmIssue::where('project_id', $projectId) + ->status(AdminPmIssue::STATUS_IN_PROGRESS)->count(), + 'resolved' => AdminPmIssue::where('project_id', $projectId) + ->status(AdminPmIssue::STATUS_RESOLVED)->count(), + 'closed' => AdminPmIssue::where('project_id', $projectId) + ->status(AdminPmIssue::STATUS_CLOSED)->count(), + 'by_type' => [ + 'bug' => AdminPmIssue::where('project_id', $projectId) + ->type(AdminPmIssue::TYPE_BUG)->count(), + 'feature' => AdminPmIssue::where('project_id', $projectId) + ->type(AdminPmIssue::TYPE_FEATURE)->count(), + 'improvement' => AdminPmIssue::where('project_id', $projectId) + ->type(AdminPmIssue::TYPE_IMPROVEMENT)->count(), + ], + ]; + } + + /** + * 전체 이슈 통계 + */ + public function getIssueStats(): array + { + return [ + 'total' => AdminPmIssue::count(), + 'open' => AdminPmIssue::status(AdminPmIssue::STATUS_OPEN)->count(), + 'in_progress' => AdminPmIssue::status(AdminPmIssue::STATUS_IN_PROGRESS)->count(), + 'resolved' => AdminPmIssue::status(AdminPmIssue::STATUS_RESOLVED)->count(), + 'closed' => AdminPmIssue::status(AdminPmIssue::STATUS_CLOSED)->count(), + 'trashed' => AdminPmIssue::onlyTrashed()->count(), + 'by_type' => [ + 'bug' => AdminPmIssue::type(AdminPmIssue::TYPE_BUG)->count(), + 'feature' => AdminPmIssue::type(AdminPmIssue::TYPE_FEATURE)->count(), + 'improvement' => AdminPmIssue::type(AdminPmIssue::TYPE_IMPROVEMENT)->count(), + ], + ]; + } + + /** + * 열린 이슈 목록 (대시보드용) + */ + public function getOpenIssues(?int $projectId = null, int $limit = 10): Collection + { + $query = AdminPmIssue::open() + ->with(['project', 'task', 'creator']) + ->latest(); + + if ($projectId) { + $query->where('project_id', $projectId); + } + + return $query->limit($limit)->get(); + } +} diff --git a/app/Services/ProjectManagement/ProjectService.php b/app/Services/ProjectManagement/ProjectService.php new file mode 100644 index 00000000..8cb70f7f --- /dev/null +++ b/app/Services/ProjectManagement/ProjectService.php @@ -0,0 +1,241 @@ +withCount(['tasks', 'issues']) + ->withTrashed(); + + // 검색 필터 + if (! empty($filters['search'])) { + $search = $filters['search']; + $query->where(function ($q) use ($search) { + $q->where('name', 'like', "%{$search}%") + ->orWhere('description', 'like', "%{$search}%"); + }); + } + + // 상태 필터 + if (! empty($filters['status'])) { + $query->where('status', $filters['status']); + } + + // Soft Delete 필터 + if (isset($filters['trashed'])) { + if ($filters['trashed'] === 'only') { + $query->onlyTrashed(); + } elseif ($filters['trashed'] === 'with') { + $query->withTrashed(); + } + } + + // 정렬 + $sortBy = $filters['sort_by'] ?? 'id'; + $sortDirection = $filters['sort_direction'] ?? 'desc'; + $query->orderBy($sortBy, $sortDirection); + + return $query->paginate($perPage); + } + + /** + * 활성 프로젝트 목록 (드롭다운용) + */ + public function getActiveProjects(): Collection + { + return AdminPmProject::query() + ->active() + ->orderBy('name') + ->get(['id', 'name', 'status']); + } + + /** + * 특정 프로젝트 조회 + */ + public function getProjectById(int $id, bool $withTrashed = false): ?AdminPmProject + { + $query = AdminPmProject::query() + ->with(['tasks' => function ($q) { + $q->orderBy('sort_order')->orderBy('id'); + }, 'issues' => function ($q) { + $q->latest(); + }]) + ->withCount(['tasks', 'issues']); + + if ($withTrashed) { + $query->withTrashed(); + } + + return $query->find($id); + } + + /** + * 프로젝트 생성 + */ + public function createProject(array $data): AdminPmProject + { + $data['created_by'] = auth()->id(); + + return AdminPmProject::create($data); + } + + /** + * 프로젝트 수정 + */ + public function updateProject(int $id, array $data): bool + { + $project = AdminPmProject::findOrFail($id); + + $data['updated_by'] = auth()->id(); + + return $project->update($data); + } + + /** + * 프로젝트 삭제 (Soft Delete) + */ + public function deleteProject(int $id): bool + { + $project = AdminPmProject::findOrFail($id); + + $project->deleted_by = auth()->id(); + $project->save(); + + return $project->delete(); + } + + /** + * 프로젝트 복원 + */ + public function restoreProject(int $id): bool + { + $project = AdminPmProject::onlyTrashed()->findOrFail($id); + + $project->deleted_by = null; + + return $project->restore(); + } + + /** + * 프로젝트 영구 삭제 + */ + public function forceDeleteProject(int $id): bool + { + $project = AdminPmProject::withTrashed()->findOrFail($id); + + // 관련 작업/이슈 삭제 (cascade 설정됨) + return $project->forceDelete(); + } + + /** + * 프로젝트 통계 + */ + public function getProjectStats(): array + { + return [ + 'total' => AdminPmProject::count(), + 'active' => AdminPmProject::where('status', AdminPmProject::STATUS_ACTIVE)->count(), + 'completed' => AdminPmProject::where('status', AdminPmProject::STATUS_COMPLETED)->count(), + 'on_hold' => AdminPmProject::where('status', AdminPmProject::STATUS_ON_HOLD)->count(), + 'trashed' => AdminPmProject::onlyTrashed()->count(), + ]; + } + + /** + * 대시보드용 프로젝트 요약 + */ + public function getDashboardSummary(): array + { + $activeProjects = AdminPmProject::active() + ->withCount(['tasks', 'issues']) + ->with(['tasks' => function ($q) { + $q->select('id', 'project_id', 'status'); + }, 'issues' => function ($q) { + $q->whereIn('status', [AdminPmIssue::STATUS_OPEN, AdminPmIssue::STATUS_IN_PROGRESS]); + }]) + ->get(); + + $taskStats = [ + 'total' => AdminPmTask::count(), + 'todo' => AdminPmTask::status(AdminPmTask::STATUS_TODO)->count(), + 'in_progress' => AdminPmTask::status(AdminPmTask::STATUS_IN_PROGRESS)->count(), + 'done' => AdminPmTask::status(AdminPmTask::STATUS_DONE)->count(), + 'overdue' => AdminPmTask::overdue()->count(), + 'due_soon' => AdminPmTask::dueSoon()->count(), + ]; + + $issueStats = [ + 'total' => AdminPmIssue::count(), + 'open' => AdminPmIssue::status(AdminPmIssue::STATUS_OPEN)->count(), + 'in_progress' => AdminPmIssue::status(AdminPmIssue::STATUS_IN_PROGRESS)->count(), + 'resolved' => AdminPmIssue::status(AdminPmIssue::STATUS_RESOLVED)->count(), + 'closed' => AdminPmIssue::status(AdminPmIssue::STATUS_CLOSED)->count(), + ]; + + return [ + 'projects' => $activeProjects, + 'project_stats' => $this->getProjectStats(), + 'task_stats' => $taskStats, + 'issue_stats' => $issueStats, + ]; + } + + /** + * 프로젝트 상태 변경 + */ + public function changeStatus(int $id, string $status): AdminPmProject + { + $project = AdminPmProject::findOrFail($id); + + $project->status = $status; + $project->updated_by = auth()->id(); + $project->save(); + + return $project; + } + + /** + * 프로젝트 복제 + */ + public function duplicateProject(int $id, ?string $newName = null): AdminPmProject + { + $original = AdminPmProject::with('tasks')->findOrFail($id); + + $newProject = $original->replicate(); + $newProject->name = $newName ?? $original->name.' (복사본)'; + $newProject->status = AdminPmProject::STATUS_ACTIVE; + $newProject->created_by = auth()->id(); + $newProject->updated_by = null; + $newProject->deleted_by = null; + $newProject->created_at = now(); + $newProject->updated_at = now(); + $newProject->save(); + + // 작업 복제 + foreach ($original->tasks as $task) { + $newTask = $task->replicate(); + $newTask->project_id = $newProject->id; + $newTask->status = AdminPmTask::STATUS_TODO; + $newTask->created_by = auth()->id(); + $newTask->updated_by = null; + $newTask->deleted_by = null; + $newTask->created_at = now(); + $newTask->updated_at = now(); + $newTask->save(); + } + + return $newProject->load('tasks'); + } +} diff --git a/app/Services/ProjectManagement/TaskService.php b/app/Services/ProjectManagement/TaskService.php new file mode 100644 index 00000000..fca55aca --- /dev/null +++ b/app/Services/ProjectManagement/TaskService.php @@ -0,0 +1,299 @@ +with(['project', 'assignee', 'issues']) + ->withCount('issues') + ->withTrashed(); + + // 프로젝트 필터 + if (! empty($filters['project_id'])) { + $query->where('project_id', $filters['project_id']); + } + + // 검색 필터 + if (! empty($filters['search'])) { + $search = $filters['search']; + $query->where(function ($q) use ($search) { + $q->where('title', 'like', "%{$search}%") + ->orWhere('description', 'like', "%{$search}%"); + }); + } + + // 상태 필터 + if (! empty($filters['status'])) { + $query->where('status', $filters['status']); + } + + // 우선순위 필터 + if (! empty($filters['priority'])) { + $query->where('priority', $filters['priority']); + } + + // 담당자 필터 + if (! empty($filters['assignee_id'])) { + $query->where('assignee_id', $filters['assignee_id']); + } + + // 마감 상태 필터 + if (! empty($filters['due_status'])) { + if ($filters['due_status'] === 'overdue') { + $query->overdue(); + } elseif ($filters['due_status'] === 'due_soon') { + $query->dueSoon(); + } + } + + // Soft Delete 필터 + if (isset($filters['trashed'])) { + if ($filters['trashed'] === 'only') { + $query->onlyTrashed(); + } elseif ($filters['trashed'] === 'with') { + $query->withTrashed(); + } + } + + // 정렬 + $sortBy = $filters['sort_by'] ?? 'sort_order'; + $sortDirection = $filters['sort_direction'] ?? 'asc'; + $query->orderBy($sortBy, $sortDirection)->orderBy('id'); + + return $query->paginate($perPage); + } + + /** + * 프로젝트별 작업 목록 (칸반보드용) + */ + public function getTasksByProject(int $projectId): Collection + { + return AdminPmTask::query() + ->where('project_id', $projectId) + ->with(['assignee', 'issues']) + ->withCount('issues') + ->orderBy('sort_order') + ->orderBy('id') + ->get(); + } + + /** + * 특정 작업 조회 + */ + public function getTaskById(int $id, bool $withTrashed = false): ?AdminPmTask + { + $query = AdminPmTask::query() + ->with(['project', 'assignee', 'issues', 'creator', 'updater']) + ->withCount('issues'); + + if ($withTrashed) { + $query->withTrashed(); + } + + return $query->find($id); + } + + /** + * 작업 생성 + */ + public function createTask(array $data): AdminPmTask + { + // 정렬 순서 자동 설정 + if (! isset($data['sort_order'])) { + $maxOrder = AdminPmTask::where('project_id', $data['project_id'])->max('sort_order') ?? 0; + $data['sort_order'] = $maxOrder + 1; + } + + $data['created_by'] = auth()->id(); + + return AdminPmTask::create($data); + } + + /** + * 작업 수정 + */ + public function updateTask(int $id, array $data): bool + { + $task = AdminPmTask::findOrFail($id); + + $data['updated_by'] = auth()->id(); + + return $task->update($data); + } + + /** + * 작업 삭제 (Soft Delete) + */ + public function deleteTask(int $id): bool + { + $task = AdminPmTask::findOrFail($id); + + $task->deleted_by = auth()->id(); + $task->save(); + + return $task->delete(); + } + + /** + * 작업 복원 + */ + public function restoreTask(int $id): bool + { + $task = AdminPmTask::onlyTrashed()->findOrFail($id); + + $task->deleted_by = null; + + return $task->restore(); + } + + /** + * 작업 영구 삭제 + */ + public function forceDeleteTask(int $id): bool + { + return AdminPmTask::withTrashed()->findOrFail($id)->forceDelete(); + } + + /** + * 작업 상태 변경 + */ + public function changeStatus(int $id, string $status): AdminPmTask + { + $task = AdminPmTask::findOrFail($id); + + $task->status = $status; + $task->updated_by = auth()->id(); + $task->save(); + + return $task; + } + + /** + * 작업 순서 변경 (드래그앤드롭) + */ + public function reorderTasks(int $projectId, array $taskIds): bool + { + return DB::transaction(function () use ($projectId, $taskIds) { + foreach ($taskIds as $order => $taskId) { + AdminPmTask::where('id', $taskId) + ->where('project_id', $projectId) + ->update(['sort_order' => $order + 1]); + } + + return true; + }); + } + + /** + * 다중 작업 상태 일괄 변경 + */ + public function bulkChangeStatus(array $taskIds, string $status): int + { + return AdminPmTask::whereIn('id', $taskIds) + ->update([ + 'status' => $status, + 'updated_by' => auth()->id(), + 'updated_at' => now(), + ]); + } + + /** + * 다중 작업 담당자 일괄 변경 + */ + public function bulkChangeAssignee(array $taskIds, ?int $assigneeId): int + { + return AdminPmTask::whereIn('id', $taskIds) + ->update([ + 'assignee_id' => $assigneeId, + 'updated_by' => auth()->id(), + 'updated_at' => now(), + ]); + } + + /** + * 다중 작업 우선순위 일괄 변경 + */ + public function bulkChangePriority(array $taskIds, string $priority): int + { + return AdminPmTask::whereIn('id', $taskIds) + ->update([ + 'priority' => $priority, + 'updated_by' => auth()->id(), + 'updated_at' => now(), + ]); + } + + /** + * 다중 작업 일괄 삭제 + */ + public function bulkDelete(array $taskIds): int + { + AdminPmTask::whereIn('id', $taskIds) + ->update([ + 'deleted_by' => auth()->id(), + ]); + + return AdminPmTask::whereIn('id', $taskIds)->delete(); + } + + /** + * 다중 작업 일괄 복원 + */ + public function bulkRestore(array $taskIds): int + { + return AdminPmTask::onlyTrashed() + ->whereIn('id', $taskIds) + ->update([ + 'deleted_by' => null, + 'deleted_at' => null, + ]); + } + + /** + * 작업 통계 (프로젝트별) + */ + public function getTaskStatsByProject(int $projectId): array + { + return [ + 'total' => AdminPmTask::where('project_id', $projectId)->count(), + 'todo' => AdminPmTask::where('project_id', $projectId) + ->status(AdminPmTask::STATUS_TODO)->count(), + 'in_progress' => AdminPmTask::where('project_id', $projectId) + ->status(AdminPmTask::STATUS_IN_PROGRESS)->count(), + 'done' => AdminPmTask::where('project_id', $projectId) + ->status(AdminPmTask::STATUS_DONE)->count(), + 'overdue' => AdminPmTask::where('project_id', $projectId)->overdue()->count(), + 'due_soon' => AdminPmTask::where('project_id', $projectId)->dueSoon()->count(), + ]; + } + + /** + * 마감 임박/지연 작업 목록 + */ + public function getUrgentTasks(?int $projectId = null): array + { + $overdueQuery = AdminPmTask::overdue()->with('project', 'assignee'); + $dueSoonQuery = AdminPmTask::dueSoon()->with('project', 'assignee'); + + if ($projectId) { + $overdueQuery->where('project_id', $projectId); + $dueSoonQuery->where('project_id', $projectId); + } + + return [ + 'overdue' => $overdueQuery->orderBy('due_date')->get(), + 'due_soon' => $dueSoonQuery->orderBy('due_date')->get(), + ]; + } +} diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index 2b8b65f0..d3b923f4 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -196,6 +196,28 @@ class="flex items-center gap-2 pr-3 py-2 rounded-lg text-sm text-gray-700 hover: + +
  • + + +
  • +
  • + +
  • + + +
  • +
  • + + + + + +
    + + +
    + + {{-- 파일 업로드 --}} +
    + + +
    + + + {{-- 액션 버튼 --}} +
    + + +
    + + + {{-- 오른쪽: 도움말 --}} +
    + {{-- 결과/상태 표시 --}} + + + {{-- JSON 구조 가이드 --}} +
    +

    JSON 구조 가이드

    +
    +

    새 프로젝트 생성

    +
    {
    +  "project": {
    +    "name": "프로젝트명 (필수)",
    +    "description": "설명",
    +    "status": "active|completed|on_hold",
    +    "start_date": "2025-01-01",
    +    "end_date": "2025-03-31"
    +  },
    +  "tasks": [
    +    {
    +      "title": "작업 제목 (필수)",
    +      "description": "작업 설명",
    +      "status": "todo|in_progress|done",
    +      "priority": "low|medium|high",
    +      "due_date": "2025-01-15",
    +      "issues": [
    +        {
    +          "title": "이슈 제목 (필수)",
    +          "description": "이슈 설명",
    +          "type": "bug|feature|improvement",
    +          "status": "open|in_progress|resolved|closed"
    +        }
    +      ]
    +    }
    +  ]
    +}
    + +

    기존 프로젝트에 작업 추가

    +
    {
    +  "tasks": [
    +    {
    +      "title": "추가할 작업",
    +      "priority": "high",
    +      "issues": [...]
    +    }
    +  ]
    +}
    +
    +
    + + {{-- 필드 설명 --}} +
    +

    필드 설명

    +
    +
    +

    프로젝트 상태

    +
      +
    • active - 진행중
    • +
    • completed - 완료
    • +
    • on_hold - 보류
    • +
    +
    +
    +

    작업 상태

    +
      +
    • todo - 예정
    • +
    • in_progress - 진행중
    • +
    • done - 완료
    • +
    +
    +
    +

    우선순위

    +
      +
    • low - 낮음
    • +
    • medium - 보통
    • +
    • high - 높음
    • +
    +
    +
    +

    이슈 타입

    +
      +
    • bug - 버그
    • +
    • feature - 기능
    • +
    • improvement - 개선
    • +
    +
    +
    +
    +
    + + + +{{-- 알림 토스트 --}} + +@endsection + +@push('scripts') + +@endpush \ No newline at end of file diff --git a/resources/views/project-management/index.blade.php b/resources/views/project-management/index.blade.php new file mode 100644 index 00000000..a3044382 --- /dev/null +++ b/resources/views/project-management/index.blade.php @@ -0,0 +1,300 @@ +@extends('layouts.app') + +@section('title', '프로젝트 관리 대시보드') + +@section('content') + +
    +

    📊 프로젝트 관리 대시보드

    + + + 새 프로젝트 + +
    + + +
    + +
    +
    +
    +

    전체 프로젝트

    +

    {{ $summary['project_stats']['total'] }}

    +
    +
    + 📁 +
    +
    +
    + 활성 {{ $summary['project_stats']['active'] }} + 완료 {{ $summary['project_stats']['completed'] }} + 보류 {{ $summary['project_stats']['on_hold'] }} +
    +
    + + +
    +
    +
    +

    전체 작업

    +

    {{ $summary['task_stats']['total'] }}

    +
    +
    + +
    +
    +
    + @php + $taskTotal = $summary['task_stats']['total'] ?: 1; + $donePercent = round(($summary['task_stats']['done'] / $taskTotal) * 100); + @endphp +
    +
    +
    +

    완료율 {{ $donePercent }}%

    +
    +
    + + +
    +
    +
    +

    마감 임박

    +

    {{ $summary['task_stats']['due_soon'] }}

    +
    +
    + +
    +
    +
    +

    + 지연됨: {{ $summary['task_stats']['overdue'] }}개 +

    +
    +
    + + +
    +
    +
    +

    열린 이슈

    +

    {{ $summary['issue_stats']['open'] + $summary['issue_stats']['in_progress'] }}

    +
    +
    + 🐛 +
    +
    +
    + Open {{ $summary['issue_stats']['open'] }} + 진행 {{ $summary['issue_stats']['in_progress'] }} + 해결 {{ $summary['issue_stats']['resolved'] }} +
    +
    +
    + + +
    +
    +

    📁 활성 프로젝트

    + 전체보기 → +
    +
    + @forelse($summary['projects'] as $project) +
    +
    +
    + + {{ $project->name }} + + @if($project->description) +

    {{ Str::limit($project->description, 100) }}

    + @endif +
    + + {{ $project->status_label }} + +
    + + +
    +
    + 진행률 + {{ $project->progress }}% +
    +
    +
    +
    +
    + + +
    +
    + 작업: + {{ $project->tasks_count ?? 0 }}개 + @php + $taskStats = $project->task_status_counts; + @endphp + @if(!empty($taskStats)) + + (할일 {{ $taskStats['todo'] ?? 0 }} / 진행 {{ $taskStats['in_progress'] ?? 0 }} / 완료 {{ $taskStats['done'] ?? 0 }}) + + @endif +
    +
    + 이슈: + @php + $openIssues = $project->issues->filter(fn($i) => in_array($i->status, ['open', 'in_progress']))->count(); + @endphp + + {{ $openIssues }}개 열림 + +
    +
    +
    + @empty +
    +

    활성 프로젝트가 없습니다.

    + + + 첫 프로젝트 만들기 + +
    + @endforelse +
    +
    + + +
    + +
    +
    +

    ⏰ 긴급 작업

    +
    +
    +
    +
    +
    +
    +
    + + +
    +
    +

    🐛 열린 이슈

    +
    +
    +
    +
    +
    +
    +
    +
    +@endsection + +@push('scripts') + +@endpush \ No newline at end of file diff --git a/resources/views/project-management/projects/create.blade.php b/resources/views/project-management/projects/create.blade.php new file mode 100644 index 00000000..25bba996 --- /dev/null +++ b/resources/views/project-management/projects/create.blade.php @@ -0,0 +1,128 @@ +@extends('layouts.app') + +@section('title', '새 프로젝트') + +@section('content') + +
    + + ← 프로젝트 목록 + +

    📁 새 프로젝트

    +
    + + +
    +
    + @csrf + + +
    + + +
    + + +
    + + +
    + + +
    + + +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + + + 취소 + +
    +
    +
    +@endsection + +@push('scripts') + +@endpush \ No newline at end of file diff --git a/resources/views/project-management/projects/edit.blade.php b/resources/views/project-management/projects/edit.blade.php new file mode 100644 index 00000000..44e98def --- /dev/null +++ b/resources/views/project-management/projects/edit.blade.php @@ -0,0 +1,129 @@ +@extends('layouts.app') + +@section('title', '프로젝트 수정') + +@section('content') + +
    + + ← 프로젝트 상세 + +

    ✏️ 프로젝트 수정

    +
    + + +
    +
    + +
    + + +
    + + +
    + + +
    + + +
    + + +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + + + 취소 + +
    +
    +
    +@endsection + +@push('scripts') + +@endpush \ No newline at end of file diff --git a/resources/views/project-management/projects/index.blade.php b/resources/views/project-management/projects/index.blade.php new file mode 100644 index 00000000..85422fb4 --- /dev/null +++ b/resources/views/project-management/projects/index.blade.php @@ -0,0 +1,135 @@ +@extends('layouts.app') + +@section('title', '프로젝트 목록') + +@section('content') + +
    +
    + + ← 대시보드 + +

    📁 프로젝트 목록

    +
    + + + 새 프로젝트 + +
    + + +
    +
    + +
    + +
    + + +
    + +
    + + +
    + +
    + + + +
    +
    + + +
    + +
    +
    +
    +
    +@endsection + +@push('scripts') + +@endpush \ No newline at end of file diff --git a/resources/views/project-management/projects/partials/table.blade.php b/resources/views/project-management/projects/partials/table.blade.php new file mode 100644 index 00000000..3dc03c04 --- /dev/null +++ b/resources/views/project-management/projects/partials/table.blade.php @@ -0,0 +1,105 @@ +
    + + + + + + + + + + + + + + + @forelse($projects as $project) + + + + + + + + + + + @empty + + + + @endforelse + +
    ID프로젝트명상태진행률작업이슈기간액션
    + {{ $project->id }} + + + {{ $project->name }} + + @if($project->description) +

    {{ Str::limit($project->description, 50) }}

    + @endif +
    + + {{ $project->status_label }} + + +
    +
    +
    +
    + {{ $project->progress }}% +
    +
    + {{ $project->tasks_count ?? 0 }} + + {{ $project->issues_count ?? 0 }} + + @if($project->start_date || $project->end_date) + {{ $project->start_date?->format('m/d') ?? '?' }} ~ {{ $project->end_date?->format('m/d') ?? '?' }} + @else + - + @endif + + @if($project->deleted_at) + + + + @else + + + 보기 + + + 수정 + + + + @endif +
    + 등록된 프로젝트가 없습니다. +
    +
    + + +@include('partials.pagination', [ + 'paginator' => $projects, + 'target' => '#project-table', + 'includeForm' => '#filterForm' +]) \ No newline at end of file diff --git a/resources/views/project-management/projects/show.blade.php b/resources/views/project-management/projects/show.blade.php new file mode 100644 index 00000000..8069a2a6 --- /dev/null +++ b/resources/views/project-management/projects/show.blade.php @@ -0,0 +1,621 @@ +@extends('layouts.app') + +@section('title', $project->name) + +@section('content') + +
    +
    + + ← 프로젝트 목록 + +
    +

    {{ $project->name }}

    + @if($project->description) +

    {{ $project->description }}

    + @endif +
    +
    +
    + + {{ $project->status_label }} + + + 수정 + +
    +
    + + +
    + +
    +

    진행률

    +
    +
    +
    +
    + {{ $project->progress }}% +
    +
    + + +
    +

    작업 현황

    + @php + $taskStats = $project->task_status_counts; + @endphp +
    + {{ $project->tasks_count ?? 0 }} + +
    +
    + 할일 {{ $taskStats['todo'] ?? 0 }} + 진행 {{ $taskStats['in_progress'] ?? 0 }} + 완료 {{ $taskStats['done'] ?? 0 }} +
    +
    + + +
    +

    이슈 현황

    + @php + $issueStats = $project->issue_status_counts; + @endphp +
    + {{ $project->issues_count ?? 0 }} + +
    +
    + 열림 {{ $issueStats['open'] ?? 0 }} + 진행 {{ $issueStats['in_progress'] ?? 0 }} + 해결 {{ ($issueStats['resolved'] ?? 0) + ($issueStats['closed'] ?? 0) }} +
    +
    + + +
    +

    기간

    + @if($project->start_date || $project->end_date) +
    +

    시작: {{ $project->start_date?->format('Y-m-d') ?? '-' }}

    +

    종료: {{ $project->end_date?->format('Y-m-d') ?? '-' }}

    +
    + @else +

    미설정

    + @endif +
    +
    + + +
    + +
    + +
    + + +
    + +
    +
    + +
    + +
    + + +
    +
    +
    +
    +
    +
    + + + +
    + + + + + + +@endsection + +@push('scripts') + +@endpush \ No newline at end of file diff --git a/routes/api.php b/routes/api.php index b2310db0..54fcfb92 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,8 +1,13 @@ name('destroy'); }); + // 시스템 게시판 관리 API + Route::prefix('boards')->name('boards.')->group(function () { + // 고정 경로는 먼저 정의 + Route::get('/stats', [BoardController::class, 'stats'])->name('stats'); + + // 기본 CRUD + Route::get('/', [BoardController::class, 'index'])->name('index'); + Route::post('/', [BoardController::class, 'store'])->name('store'); + Route::get('/{id}', [BoardController::class, 'show'])->name('show'); + Route::put('/{id}', [BoardController::class, 'update'])->name('update'); + Route::delete('/{id}', [BoardController::class, 'destroy'])->name('destroy'); + + // 추가 액션 + Route::post('/{id}/restore', [BoardController::class, 'restore'])->name('restore'); + Route::delete('/{id}/force', [BoardController::class, 'forceDestroy'])->name('forceDestroy'); + Route::post('/{id}/toggle-active', [BoardController::class, 'toggleActive'])->name('toggleActive'); + + // 필드 관리 API + Route::get('/{id}/fields', [BoardController::class, 'fields'])->name('fields'); + Route::post('/{id}/fields', [BoardController::class, 'storeField'])->name('storeField'); + Route::put('/{id}/fields/{fieldId}', [BoardController::class, 'updateField'])->name('updateField'); + Route::delete('/{id}/fields/{fieldId}', [BoardController::class, 'destroyField'])->name('destroyField'); + Route::post('/{id}/fields/reorder', [BoardController::class, 'reorderFields'])->name('reorderFields'); + }); + // 역할 권한 관리 API Route::prefix('role-permissions')->name('role-permissions.')->group(function () { Route::get('/matrix', [RolePermissionController::class, 'getMatrix'])->name('matrix'); @@ -151,4 +181,89 @@ Route::get('/', [\App\Http\Controllers\Api\Admin\ArchivedRecordController::class, 'index'])->name('index'); Route::get('/{id}', [\App\Http\Controllers\Api\Admin\ArchivedRecordController::class, 'show'])->name('show'); }); + + /* + |-------------------------------------------------------------------------- + | 프로젝트 관리 API + |-------------------------------------------------------------------------- + */ + Route::prefix('pm')->name('pm.')->group(function () { + + // 프로젝트 관리 API + Route::prefix('projects')->name('projects.')->group(function () { + // 고정 경로 + Route::get('/stats', [PmProjectController::class, 'stats'])->name('stats'); + Route::get('/dashboard', [PmProjectController::class, 'dashboard'])->name('dashboard'); + Route::get('/dropdown', [PmProjectController::class, 'dropdown'])->name('dropdown'); + + // 기본 CRUD + Route::get('/', [PmProjectController::class, 'index'])->name('index'); + Route::post('/', [PmProjectController::class, 'store'])->name('store'); + Route::get('/{id}', [PmProjectController::class, 'show'])->name('show'); + Route::put('/{id}', [PmProjectController::class, 'update'])->name('update'); + Route::delete('/{id}', [PmProjectController::class, 'destroy'])->name('destroy'); + + // 추가 액션 + Route::post('/{id}/restore', [PmProjectController::class, 'restore'])->name('restore'); + Route::delete('/{id}/force', [PmProjectController::class, 'forceDestroy'])->name('forceDestroy'); + Route::post('/{id}/status', [PmProjectController::class, 'changeStatus'])->name('changeStatus'); + Route::post('/{id}/duplicate', [PmProjectController::class, 'duplicate'])->name('duplicate'); + }); + + // 작업 관리 API + Route::prefix('tasks')->name('tasks.')->group(function () { + // 고정 경로 + Route::get('/urgent', [PmTaskController::class, 'urgent'])->name('urgent'); + Route::post('/bulk', [PmTaskController::class, 'bulk'])->name('bulk'); + + // 기본 CRUD + Route::get('/', [PmTaskController::class, 'index'])->name('index'); + Route::post('/', [PmTaskController::class, 'store'])->name('store'); + Route::get('/{id}', [PmTaskController::class, 'show'])->name('show'); + Route::put('/{id}', [PmTaskController::class, 'update'])->name('update'); + Route::delete('/{id}', [PmTaskController::class, 'destroy'])->name('destroy'); + + // 추가 액션 + Route::post('/{id}/restore', [PmTaskController::class, 'restore'])->name('restore'); + Route::delete('/{id}/force', [PmTaskController::class, 'forceDestroy'])->name('forceDestroy'); + Route::post('/{id}/status', [PmTaskController::class, 'changeStatus'])->name('changeStatus'); + + // 프로젝트별 + Route::get('/project/{projectId}', [PmTaskController::class, 'byProject'])->name('byProject'); + Route::post('/project/{projectId}/reorder', [PmTaskController::class, 'reorder'])->name('reorder'); + Route::get('/project/{projectId}/stats', [PmTaskController::class, 'stats'])->name('stats'); + }); + + // 이슈 관리 API + Route::prefix('issues')->name('issues.')->group(function () { + // 고정 경로 + Route::get('/stats', [PmIssueController::class, 'stats'])->name('stats'); + Route::get('/open', [PmIssueController::class, 'open'])->name('open'); + Route::post('/bulk', [PmIssueController::class, 'bulk'])->name('bulk'); + + // 기본 CRUD + Route::get('/', [PmIssueController::class, 'index'])->name('index'); + Route::post('/', [PmIssueController::class, 'store'])->name('store'); + Route::get('/{id}', [PmIssueController::class, 'show'])->name('show'); + Route::put('/{id}', [PmIssueController::class, 'update'])->name('update'); + Route::delete('/{id}', [PmIssueController::class, 'destroy'])->name('destroy'); + + // 추가 액션 + Route::post('/{id}/restore', [PmIssueController::class, 'restore'])->name('restore'); + Route::delete('/{id}/force', [PmIssueController::class, 'forceDestroy'])->name('forceDestroy'); + Route::post('/{id}/status', [PmIssueController::class, 'changeStatus'])->name('changeStatus'); + + // 연관별 + Route::get('/project/{projectId}', [PmIssueController::class, 'byProject'])->name('byProject'); + Route::get('/task/{taskId}', [PmIssueController::class, 'byTask'])->name('byTask'); + }); + + // JSON Import API + Route::prefix('import')->name('import.')->group(function () { + Route::get('/template', [PmImportController::class, 'template'])->name('template'); + Route::post('/validate', [PmImportController::class, 'validate'])->name('validate'); + Route::post('/', [PmImportController::class, 'import'])->name('import'); + Route::post('/project/{projectId}/tasks', [PmImportController::class, 'importTasks'])->name('importTasks'); + }); + }); }); diff --git a/routes/web.php b/routes/web.php index b9d8d399..f71bfe33 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,10 +2,12 @@ use App\Http\Controllers\ArchivedRecordController; use App\Http\Controllers\Auth\LoginController; +use App\Http\Controllers\BoardController; use App\Http\Controllers\DepartmentController; use App\Http\Controllers\DevTools\FlowTesterController; use App\Http\Controllers\MenuController; use App\Http\Controllers\PermissionController; +use App\Http\Controllers\ProjectManagementController; use App\Http\Controllers\RoleController; use App\Http\Controllers\RolePermissionController; use App\Http\Controllers\TenantController; @@ -77,6 +79,13 @@ Route::get('/{id}/edit', [PermissionController::class, 'edit'])->name('edit'); }); + // 시스템 게시판 관리 (Blade 화면만) + Route::prefix('boards')->name('boards.')->group(function () { + Route::get('/', [BoardController::class, 'index'])->name('index'); + Route::get('/create', [BoardController::class, 'create'])->name('create'); + Route::get('/{id}/edit', [BoardController::class, 'edit'])->name('edit'); + }); + // 역할 권한 관리 (Blade 화면만) Route::get('/role-permissions', [RolePermissionController::class, 'index'])->name('role-permissions.index'); @@ -95,6 +104,21 @@ Route::get('/{batchId}', [ArchivedRecordController::class, 'show'])->name('show'); }); + // 프로젝트 관리 (Blade 화면만) + Route::prefix('project-management')->name('pm.')->group(function () { + // 대시보드 + Route::get('/', [ProjectManagementController::class, 'index'])->name('index'); + + // 프로젝트 + Route::get('/projects', [ProjectManagementController::class, 'projects'])->name('projects.index'); + Route::get('/projects/create', [ProjectManagementController::class, 'createProject'])->name('projects.create'); + Route::get('/projects/{id}', [ProjectManagementController::class, 'showProject'])->name('projects.show'); + Route::get('/projects/{id}/edit', [ProjectManagementController::class, 'editProject'])->name('projects.edit'); + + // JSON Import + Route::get('/import', [ProjectManagementController::class, 'import'])->name('import'); + }); + // 대시보드 Route::get('/dashboard', function () { return view('dashboard.index');