From f7a957565575c1c73d7c72d0db6a603db3d431fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Mon, 2 Mar 2026 15:50:20 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[roadmap]=20=EC=A4=91=EC=9E=A5=EA=B8=B0?= =?UTF-8?q?=20=EA=B3=84=ED=9A=8D=20=EB=A9=94=EB=89=B4=20=EB=B0=8F=20?= =?UTF-8?q?=EC=A0=84=EC=9A=A9=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B0=9C?= =?UTF-8?q?=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 모델: AdminRoadmapPlan, AdminRoadmapMilestone - 서비스: RoadmapPlanService, RoadmapMilestoneService - FormRequest: Store/Update Plan/Milestone 4개 - 컨트롤러: Blade(RoadmapController), API(Plan/Milestone) 3개 - 라우트: web.php, api.php에 roadmap 라우트 추가 - Blade 뷰: 대시보드, 목록, 생성, 수정, 상세, 파셜 테이블 6개 - HTMX 기반 필터링/페이지네이션, 마일스톤 인라인 추가/토글 --- .../Roadmap/RoadmapMilestoneController.php | 68 +++++ .../Admin/Roadmap/RoadmapPlanController.php | 130 ++++++++ app/Http/Controllers/RoadmapController.php | 79 +++++ .../Roadmap/StoreMilestoneRequest.php | 46 +++ .../Requests/Roadmap/StorePlanRequest.php | 76 +++++ .../Roadmap/UpdateMilestoneRequest.php | 42 +++ .../Requests/Roadmap/UpdatePlanRequest.php | 60 ++++ app/Models/Admin/AdminRoadmapMilestone.php | 102 +++++++ app/Models/Admin/AdminRoadmapPlan.php | 231 ++++++++++++++ .../Roadmap/RoadmapMilestoneService.php | 78 +++++ app/Services/Roadmap/RoadmapPlanService.php | 185 ++++++++++++ resources/views/roadmap/index.blade.php | 174 +++++++++++ .../views/roadmap/plans/create.blade.php | 162 ++++++++++ resources/views/roadmap/plans/edit.blade.php | 164 ++++++++++ resources/views/roadmap/plans/index.blade.php | 127 ++++++++ .../roadmap/plans/partials/table.blade.php | 113 +++++++ resources/views/roadmap/plans/show.blade.php | 281 ++++++++++++++++++ routes/api.php | 33 ++ routes/web.php | 10 + 19 files changed, 2161 insertions(+) create mode 100644 app/Http/Controllers/Api/Admin/Roadmap/RoadmapMilestoneController.php create mode 100644 app/Http/Controllers/Api/Admin/Roadmap/RoadmapPlanController.php create mode 100644 app/Http/Controllers/RoadmapController.php create mode 100644 app/Http/Requests/Roadmap/StoreMilestoneRequest.php create mode 100644 app/Http/Requests/Roadmap/StorePlanRequest.php create mode 100644 app/Http/Requests/Roadmap/UpdateMilestoneRequest.php create mode 100644 app/Http/Requests/Roadmap/UpdatePlanRequest.php create mode 100644 app/Models/Admin/AdminRoadmapMilestone.php create mode 100644 app/Models/Admin/AdminRoadmapPlan.php create mode 100644 app/Services/Roadmap/RoadmapMilestoneService.php create mode 100644 app/Services/Roadmap/RoadmapPlanService.php create mode 100644 resources/views/roadmap/index.blade.php create mode 100644 resources/views/roadmap/plans/create.blade.php create mode 100644 resources/views/roadmap/plans/edit.blade.php create mode 100644 resources/views/roadmap/plans/index.blade.php create mode 100644 resources/views/roadmap/plans/partials/table.blade.php create mode 100644 resources/views/roadmap/plans/show.blade.php diff --git a/app/Http/Controllers/Api/Admin/Roadmap/RoadmapMilestoneController.php b/app/Http/Controllers/Api/Admin/Roadmap/RoadmapMilestoneController.php new file mode 100644 index 00000000..12388333 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/Roadmap/RoadmapMilestoneController.php @@ -0,0 +1,68 @@ +milestoneService->getMilestonesByPlan($planId); + + return response()->json([ + 'success' => true, + 'data' => $milestones, + ]); + } + + public function store(StoreMilestoneRequest $request): JsonResponse + { + $milestone = $this->milestoneService->createMilestone($request->validated()); + + return response()->json([ + 'success' => true, + 'message' => '마일스톤이 추가되었습니다.', + 'data' => $milestone, + ]); + } + + public function update(UpdateMilestoneRequest $request, int $id): JsonResponse + { + $this->milestoneService->updateMilestone($id, $request->validated()); + + return response()->json([ + 'success' => true, + 'message' => '마일스톤이 수정되었습니다.', + ]); + } + + public function destroy(int $id): JsonResponse + { + $this->milestoneService->deleteMilestone($id); + + return response()->json([ + 'success' => true, + 'message' => '마일스톤이 삭제되었습니다.', + ]); + } + + public function toggle(int $id): JsonResponse + { + $milestone = $this->milestoneService->toggleStatus($id); + + return response()->json([ + 'success' => true, + 'message' => $milestone->status === 'completed' ? '마일스톤이 완료 처리되었습니다.' : '마일스톤이 미완료로 변경되었습니다.', + 'data' => $milestone, + ]); + } +} diff --git a/app/Http/Controllers/Api/Admin/Roadmap/RoadmapPlanController.php b/app/Http/Controllers/Api/Admin/Roadmap/RoadmapPlanController.php new file mode 100644 index 00000000..a871e2d8 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/Roadmap/RoadmapPlanController.php @@ -0,0 +1,130 @@ +only([ + 'search', 'status', 'category', 'priority', 'phase', + 'trashed', 'sort_by', 'sort_direction', + ]); + $plans = $this->planService->getPlans($filters, 15); + + if ($request->header('HX-Request')) { + return view('roadmap.plans.partials.table', compact('plans')); + } + + return response()->json([ + 'success' => true, + 'data' => $plans, + ]); + } + + public function stats(): JsonResponse + { + $stats = $this->planService->getStats(); + + return response()->json([ + 'success' => true, + 'data' => $stats, + ]); + } + + public function timeline(Request $request): JsonResponse + { + $phase = $request->input('phase'); + $timeline = $this->planService->getTimelineData($phase); + + return response()->json([ + 'success' => true, + 'data' => $timeline, + ]); + } + + public function show(int $id): JsonResponse + { + $plan = $this->planService->getPlanById($id, true); + + if (! $plan) { + return response()->json([ + 'success' => false, + 'message' => '계획을 찾을 수 없습니다.', + ], 404); + } + + return response()->json([ + 'success' => true, + 'data' => $plan, + ]); + } + + public function store(StorePlanRequest $request): JsonResponse + { + $plan = $this->planService->createPlan($request->validated()); + + return response()->json([ + 'success' => true, + 'message' => '계획이 생성되었습니다.', + 'data' => $plan, + ]); + } + + public function update(UpdatePlanRequest $request, int $id): JsonResponse + { + $this->planService->updatePlan($id, $request->validated()); + + return response()->json([ + 'success' => true, + 'message' => '계획이 수정되었습니다.', + ]); + } + + public function destroy(int $id): JsonResponse + { + $this->planService->deletePlan($id); + + return response()->json([ + 'success' => true, + 'message' => '계획이 삭제되었습니다.', + ]); + } + + public function restore(int $id): JsonResponse + { + $this->planService->restorePlan($id); + + return response()->json([ + 'success' => true, + 'message' => '계획이 복원되었습니다.', + ]); + } + + public function changeStatus(Request $request, int $id): JsonResponse + { + $validated = $request->validate([ + 'status' => 'required|in:planned,in_progress,completed,delayed,cancelled', + ]); + + $plan = $this->planService->changeStatus($id, $validated['status']); + + return response()->json([ + 'success' => true, + 'message' => '상태가 변경되었습니다.', + 'data' => $plan, + ]); + } +} diff --git a/app/Http/Controllers/RoadmapController.php b/app/Http/Controllers/RoadmapController.php new file mode 100644 index 00000000..bb9c7de3 --- /dev/null +++ b/app/Http/Controllers/RoadmapController.php @@ -0,0 +1,79 @@ +planService->getDashboardSummary(); + $statuses = AdminRoadmapPlan::getStatuses(); + $categories = AdminRoadmapPlan::getCategories(); + $priorities = AdminRoadmapPlan::getPriorities(); + $phases = AdminRoadmapPlan::getPhases(); + + return view('roadmap.index', compact( + 'summary', 'statuses', 'categories', 'priorities', 'phases' + )); + } + + public function plans(): View + { + $statuses = AdminRoadmapPlan::getStatuses(); + $categories = AdminRoadmapPlan::getCategories(); + $priorities = AdminRoadmapPlan::getPriorities(); + $phases = AdminRoadmapPlan::getPhases(); + + return view('roadmap.plans.index', compact('statuses', 'categories', 'priorities', 'phases')); + } + + public function createPlan(): View + { + $statuses = AdminRoadmapPlan::getStatuses(); + $categories = AdminRoadmapPlan::getCategories(); + $priorities = AdminRoadmapPlan::getPriorities(); + $phases = AdminRoadmapPlan::getPhases(); + + return view('roadmap.plans.create', compact('statuses', 'categories', 'priorities', 'phases')); + } + + public function showPlan(int $id): View + { + $plan = $this->planService->getPlanById($id, true); + if (! $plan) { + abort(404, '계획을 찾을 수 없습니다.'); + } + $statuses = AdminRoadmapPlan::getStatuses(); + $categories = AdminRoadmapPlan::getCategories(); + $priorities = AdminRoadmapPlan::getPriorities(); + $phases = AdminRoadmapPlan::getPhases(); + + return view('roadmap.plans.show', compact( + 'plan', 'statuses', 'categories', 'priorities', 'phases' + )); + } + + public function editPlan(int $id): View + { + $plan = $this->planService->getPlanById($id, true); + if (! $plan) { + abort(404, '계획을 찾을 수 없습니다.'); + } + $statuses = AdminRoadmapPlan::getStatuses(); + $categories = AdminRoadmapPlan::getCategories(); + $priorities = AdminRoadmapPlan::getPriorities(); + $phases = AdminRoadmapPlan::getPhases(); + + return view('roadmap.plans.edit', compact( + 'plan', 'statuses', 'categories', 'priorities', 'phases' + )); + } +} diff --git a/app/Http/Requests/Roadmap/StoreMilestoneRequest.php b/app/Http/Requests/Roadmap/StoreMilestoneRequest.php new file mode 100644 index 00000000..943d36de --- /dev/null +++ b/app/Http/Requests/Roadmap/StoreMilestoneRequest.php @@ -0,0 +1,46 @@ + 'required|integer|exists:admin_roadmap_plans,id', + 'title' => 'required|string|max:255', + 'description' => 'nullable|string|max:2000', + 'due_date' => 'nullable|date', + 'assignee_id' => 'nullable|integer|exists:users,id', + 'sort_order' => 'nullable|integer', + ]; + } + + public function attributes(): array + { + return [ + 'plan_id' => '계획', + 'title' => '마일스톤 제목', + 'description' => '설명', + 'due_date' => '예정일', + 'assignee_id' => '담당자', + ]; + } + + public function messages(): array + { + return [ + 'plan_id.required' => '계획을 선택해주세요.', + 'plan_id.exists' => '유효하지 않은 계획입니다.', + 'title.required' => '마일스톤 제목은 필수입니다.', + 'title.max' => '마일스톤 제목은 최대 255자까지 입력 가능합니다.', + ]; + } +} diff --git a/app/Http/Requests/Roadmap/StorePlanRequest.php b/app/Http/Requests/Roadmap/StorePlanRequest.php new file mode 100644 index 00000000..290d0421 --- /dev/null +++ b/app/Http/Requests/Roadmap/StorePlanRequest.php @@ -0,0 +1,76 @@ + 'required|string|max:200', + 'description' => 'nullable|string|max:2000', + 'content' => 'nullable|string', + 'category' => 'nullable|in:'.implode(',', array_keys(AdminRoadmapPlan::getCategories())), + 'status' => 'nullable|in:'.implode(',', array_keys(AdminRoadmapPlan::getStatuses())), + 'priority' => 'nullable|in:'.implode(',', array_keys(AdminRoadmapPlan::getPriorities())), + 'phase' => 'nullable|in:'.implode(',', array_keys(AdminRoadmapPlan::getPhases())), + 'start_date' => 'nullable|date', + 'end_date' => 'nullable|date|after_or_equal:start_date', + 'progress' => 'nullable|integer|min:0|max:100', + 'color' => 'nullable|string|max:7', + 'sort_order' => 'nullable|integer', + ]; + } + + public function attributes(): array + { + return [ + 'title' => '계획 제목', + 'description' => '설명', + 'content' => '상세 내용', + 'category' => '카테고리', + 'status' => '상태', + 'priority' => '우선순위', + 'phase' => 'Phase', + 'start_date' => '시작일', + 'end_date' => '종료일', + 'progress' => '진행률', + 'color' => '색상', + ]; + } + + public function messages(): array + { + return [ + 'title.required' => '계획 제목은 필수입니다.', + 'title.max' => '계획 제목은 최대 200자까지 입력 가능합니다.', + 'end_date.after_or_equal' => '종료일은 시작일 이후여야 합니다.', + 'progress.min' => '진행률은 0 이상이어야 합니다.', + 'progress.max' => '진행률은 100 이하여야 합니다.', + ]; + } + + protected function prepareForValidation(): void + { + if (! $this->has('status')) { + $this->merge(['status' => AdminRoadmapPlan::STATUS_PLANNED]); + } + if (! $this->has('category')) { + $this->merge(['category' => AdminRoadmapPlan::CATEGORY_GENERAL]); + } + if (! $this->has('priority')) { + $this->merge(['priority' => AdminRoadmapPlan::PRIORITY_MEDIUM]); + } + if (! $this->has('phase')) { + $this->merge(['phase' => AdminRoadmapPlan::PHASE_1]); + } + } +} diff --git a/app/Http/Requests/Roadmap/UpdateMilestoneRequest.php b/app/Http/Requests/Roadmap/UpdateMilestoneRequest.php new file mode 100644 index 00000000..a414147d --- /dev/null +++ b/app/Http/Requests/Roadmap/UpdateMilestoneRequest.php @@ -0,0 +1,42 @@ + 'required|string|max:255', + 'description' => 'nullable|string|max:2000', + 'due_date' => 'nullable|date', + 'assignee_id' => 'nullable|integer|exists:users,id', + 'sort_order' => 'nullable|integer', + ]; + } + + public function attributes(): array + { + return [ + 'title' => '마일스톤 제목', + 'description' => '설명', + 'due_date' => '예정일', + 'assignee_id' => '담당자', + ]; + } + + public function messages(): array + { + return [ + 'title.required' => '마일스톤 제목은 필수입니다.', + 'title.max' => '마일스톤 제목은 최대 255자까지 입력 가능합니다.', + ]; + } +} diff --git a/app/Http/Requests/Roadmap/UpdatePlanRequest.php b/app/Http/Requests/Roadmap/UpdatePlanRequest.php new file mode 100644 index 00000000..048c6486 --- /dev/null +++ b/app/Http/Requests/Roadmap/UpdatePlanRequest.php @@ -0,0 +1,60 @@ + 'required|string|max:200', + 'description' => 'nullable|string|max:2000', + 'content' => 'nullable|string', + 'category' => 'nullable|in:'.implode(',', array_keys(AdminRoadmapPlan::getCategories())), + 'status' => 'nullable|in:'.implode(',', array_keys(AdminRoadmapPlan::getStatuses())), + 'priority' => 'nullable|in:'.implode(',', array_keys(AdminRoadmapPlan::getPriorities())), + 'phase' => 'nullable|in:'.implode(',', array_keys(AdminRoadmapPlan::getPhases())), + 'start_date' => 'nullable|date', + 'end_date' => 'nullable|date|after_or_equal:start_date', + 'progress' => 'nullable|integer|min:0|max:100', + 'color' => 'nullable|string|max:7', + 'sort_order' => 'nullable|integer', + ]; + } + + public function attributes(): array + { + return [ + 'title' => '계획 제목', + 'description' => '설명', + 'content' => '상세 내용', + 'category' => '카테고리', + 'status' => '상태', + 'priority' => '우선순위', + 'phase' => 'Phase', + 'start_date' => '시작일', + 'end_date' => '종료일', + 'progress' => '진행률', + 'color' => '색상', + ]; + } + + public function messages(): array + { + return [ + 'title.required' => '계획 제목은 필수입니다.', + 'title.max' => '계획 제목은 최대 200자까지 입력 가능합니다.', + 'end_date.after_or_equal' => '종료일은 시작일 이후여야 합니다.', + 'progress.min' => '진행률은 0 이상이어야 합니다.', + 'progress.max' => '진행률은 100 이하여야 합니다.', + ]; + } +} diff --git a/app/Models/Admin/AdminRoadmapMilestone.php b/app/Models/Admin/AdminRoadmapMilestone.php new file mode 100644 index 00000000..52b5a3ba --- /dev/null +++ b/app/Models/Admin/AdminRoadmapMilestone.php @@ -0,0 +1,102 @@ + 'integer', + 'due_date' => 'date', + 'completed_at' => 'datetime', + 'assignee_id' => 'integer', + 'sort_order' => 'integer', + 'created_by' => 'integer', + 'updated_by' => 'integer', + 'deleted_by' => 'integer', + ]; + + public const STATUS_PENDING = 'pending'; + + public const STATUS_COMPLETED = 'completed'; + + public static function getStatuses(): array + { + return [ + self::STATUS_PENDING => '진행중', + self::STATUS_COMPLETED => '완료', + ]; + } + + public function plan(): BelongsTo + { + return $this->belongsTo(AdminRoadmapPlan::class, 'plan_id'); + } + + public function assignee(): BelongsTo + { + return $this->belongsTo(User::class, 'assignee_id'); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function getStatusLabelAttribute(): string + { + return self::getStatuses()[$this->status] ?? $this->status; + } + + public function getIsCompletedAttribute(): bool + { + return $this->status === self::STATUS_COMPLETED; + } + + public function getDdayAttribute(): ?int + { + if (! $this->due_date) { + return null; + } + + return now()->startOfDay()->diffInDays($this->due_date, false); + } + + public function getDueStatusAttribute(): ?string + { + if (! $this->due_date || $this->status === self::STATUS_COMPLETED) { + return null; + } + $dday = $this->dday; + if ($dday < 0) { + return 'overdue'; + } + if ($dday <= 7) { + return 'due_soon'; + } + + return 'normal'; + } +} diff --git a/app/Models/Admin/AdminRoadmapPlan.php b/app/Models/Admin/AdminRoadmapPlan.php new file mode 100644 index 00000000..67c5d4a9 --- /dev/null +++ b/app/Models/Admin/AdminRoadmapPlan.php @@ -0,0 +1,231 @@ + 'date', + 'end_date' => 'date', + 'progress' => 'integer', + 'sort_order' => 'integer', + 'created_by' => 'integer', + 'updated_by' => 'integer', + 'deleted_by' => 'integer', + ]; + + // 상태 + public const STATUS_PLANNED = 'planned'; + + public const STATUS_IN_PROGRESS = 'in_progress'; + + public const STATUS_COMPLETED = 'completed'; + + public const STATUS_DELAYED = 'delayed'; + + public const STATUS_CANCELLED = 'cancelled'; + + // 카테고리 + public const CATEGORY_GENERAL = 'general'; + + public const CATEGORY_PRODUCT = 'product'; + + public const CATEGORY_INFRASTRUCTURE = 'infrastructure'; + + public const CATEGORY_BUSINESS = 'business'; + + public const CATEGORY_HR = 'hr'; + + // 우선순위 + public const PRIORITY_LOW = 'low'; + + public const PRIORITY_MEDIUM = 'medium'; + + public const PRIORITY_HIGH = 'high'; + + public const PRIORITY_CRITICAL = 'critical'; + + // Phase + public const PHASE_1 = 'phase_1'; + + public const PHASE_2 = 'phase_2'; + + public const PHASE_3 = 'phase_3'; + + public const PHASE_4 = 'phase_4'; + + public static function getStatuses(): array + { + return [ + self::STATUS_PLANNED => '계획', + self::STATUS_IN_PROGRESS => '진행중', + self::STATUS_COMPLETED => '완료', + self::STATUS_DELAYED => '지연', + self::STATUS_CANCELLED => '취소', + ]; + } + + public static function getCategories(): array + { + return [ + self::CATEGORY_GENERAL => '일반', + self::CATEGORY_PRODUCT => '제품', + self::CATEGORY_INFRASTRUCTURE => '인프라', + self::CATEGORY_BUSINESS => '사업', + self::CATEGORY_HR => '인사', + ]; + } + + public static function getPriorities(): array + { + return [ + self::PRIORITY_LOW => '낮음', + self::PRIORITY_MEDIUM => '보통', + self::PRIORITY_HIGH => '높음', + self::PRIORITY_CRITICAL => '긴급', + ]; + } + + public static function getPhases(): array + { + return [ + self::PHASE_1 => 'Phase 1 — 코어 실증', + self::PHASE_2 => 'Phase 2 — 3~5사 확장', + self::PHASE_3 => 'Phase 3 — SaaS 전환', + self::PHASE_4 => 'Phase 4 — 스케일업', + ]; + } + + public function scopeStatus($query, string $status) + { + return $query->where('status', $status); + } + + public function scopeCategory($query, string $category) + { + return $query->where('category', $category); + } + + public function scopePhase($query, string $phase) + { + return $query->where('phase', $phase); + } + + public function milestones(): HasMany + { + return $this->hasMany(AdminRoadmapMilestone::class, 'plan_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 getStatusLabelAttribute(): string + { + return self::getStatuses()[$this->status] ?? $this->status; + } + + public function getStatusColorAttribute(): string + { + return match ($this->status) { + self::STATUS_PLANNED => 'bg-gray-100 text-gray-800', + self::STATUS_IN_PROGRESS => 'bg-blue-100 text-blue-800', + self::STATUS_COMPLETED => 'bg-green-100 text-green-800', + self::STATUS_DELAYED => 'bg-red-100 text-red-800', + self::STATUS_CANCELLED => 'bg-yellow-100 text-yellow-800', + default => 'bg-gray-100 text-gray-800', + }; + } + + public function getCategoryLabelAttribute(): string + { + return self::getCategories()[$this->category] ?? $this->category; + } + + public function getPriorityLabelAttribute(): string + { + return self::getPriorities()[$this->priority] ?? $this->priority; + } + + public function getPriorityColorAttribute(): string + { + return match ($this->priority) { + self::PRIORITY_LOW => 'bg-gray-100 text-gray-600', + self::PRIORITY_MEDIUM => 'bg-blue-100 text-blue-700', + self::PRIORITY_HIGH => 'bg-orange-100 text-orange-700', + self::PRIORITY_CRITICAL => 'bg-red-100 text-red-700', + default => 'bg-gray-100 text-gray-600', + }; + } + + public function getPhaseLabelAttribute(): string + { + return self::getPhases()[$this->phase] ?? $this->phase; + } + + public function getCalculatedProgressAttribute(): int + { + $total = $this->milestones()->count(); + if ($total === 0) { + return $this->progress; + } + $completed = $this->milestones()->where('status', AdminRoadmapMilestone::STATUS_COMPLETED)->count(); + + return (int) round(($completed / $total) * 100); + } + + public function getMilestoneStatsAttribute(): array + { + return [ + 'total' => $this->milestones()->count(), + 'pending' => $this->milestones()->where('status', AdminRoadmapMilestone::STATUS_PENDING)->count(), + 'completed' => $this->milestones()->where('status', AdminRoadmapMilestone::STATUS_COMPLETED)->count(), + ]; + } + + public function getPeriodAttribute(): string + { + if ($this->start_date && $this->end_date) { + return $this->start_date->format('Y.m').' ~ '.$this->end_date->format('Y.m'); + } + if ($this->start_date) { + return $this->start_date->format('Y.m').' ~'; + } + + return '-'; + } +} diff --git a/app/Services/Roadmap/RoadmapMilestoneService.php b/app/Services/Roadmap/RoadmapMilestoneService.php new file mode 100644 index 00000000..1aefe95a --- /dev/null +++ b/app/Services/Roadmap/RoadmapMilestoneService.php @@ -0,0 +1,78 @@ +where('plan_id', $planId) + ->with('assignee') + ->orderBy('sort_order') + ->orderBy('id') + ->get(); + } + + public function createMilestone(array $data): AdminRoadmapMilestone + { + $data['created_by'] = auth()->id(); + + $milestone = AdminRoadmapMilestone::create($data); + $this->planService->recalculateProgress($milestone->plan_id); + + return $milestone; + } + + public function updateMilestone(int $id, array $data): bool + { + $milestone = AdminRoadmapMilestone::findOrFail($id); + $data['updated_by'] = auth()->id(); + $result = $milestone->update($data); + + $this->planService->recalculateProgress($milestone->plan_id); + + return $result; + } + + public function deleteMilestone(int $id): bool + { + $milestone = AdminRoadmapMilestone::findOrFail($id); + $planId = $milestone->plan_id; + + $milestone->deleted_by = auth()->id(); + $milestone->save(); + $result = $milestone->delete(); + + $this->planService->recalculateProgress($planId); + + return $result; + } + + public function toggleStatus(int $id): AdminRoadmapMilestone + { + $milestone = AdminRoadmapMilestone::findOrFail($id); + + if ($milestone->status === AdminRoadmapMilestone::STATUS_COMPLETED) { + $milestone->status = AdminRoadmapMilestone::STATUS_PENDING; + $milestone->completed_at = null; + } else { + $milestone->status = AdminRoadmapMilestone::STATUS_COMPLETED; + $milestone->completed_at = now(); + } + + $milestone->updated_by = auth()->id(); + $milestone->save(); + + $this->planService->recalculateProgress($milestone->plan_id); + + return $milestone; + } +} diff --git a/app/Services/Roadmap/RoadmapPlanService.php b/app/Services/Roadmap/RoadmapPlanService.php new file mode 100644 index 00000000..ff00640f --- /dev/null +++ b/app/Services/Roadmap/RoadmapPlanService.php @@ -0,0 +1,185 @@ +withCount('milestones') + ->withTrashed(); + + 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['category'])) { + $query->where('category', $filters['category']); + } + + if (! empty($filters['priority'])) { + $query->where('priority', $filters['priority']); + } + + if (! empty($filters['phase'])) { + $query->where('phase', $filters['phase']); + } + + if (isset($filters['trashed'])) { + if ($filters['trashed'] === 'only') { + $query->onlyTrashed(); + } + } + + $sortBy = $filters['sort_by'] ?? 'sort_order'; + $sortDirection = $filters['sort_direction'] ?? 'asc'; + $query->orderBy($sortBy, $sortDirection)->orderBy('id', 'desc'); + + return $query->paginate($perPage); + } + + public function getTimelineData(?string $phase = null): array + { + $query = AdminRoadmapPlan::query() + ->whereIn('status', [ + AdminRoadmapPlan::STATUS_PLANNED, + AdminRoadmapPlan::STATUS_IN_PROGRESS, + AdminRoadmapPlan::STATUS_COMPLETED, + AdminRoadmapPlan::STATUS_DELAYED, + ]) + ->withCount('milestones') + ->orderBy('phase') + ->orderBy('sort_order') + ->orderBy('start_date'); + + if ($phase) { + $query->where('phase', $phase); + } + + $plans = $query->get(); + $grouped = []; + + foreach (AdminRoadmapPlan::getPhases() as $key => $label) { + $phasePlans = $plans->where('phase', $key); + if ($phase && $key !== $phase) { + continue; + } + $grouped[$key] = [ + 'label' => $label, + 'plans' => $phasePlans->values(), + ]; + } + + return $grouped; + } + + public function getStats(): array + { + return [ + 'total' => AdminRoadmapPlan::count(), + 'planned' => AdminRoadmapPlan::status(AdminRoadmapPlan::STATUS_PLANNED)->count(), + 'in_progress' => AdminRoadmapPlan::status(AdminRoadmapPlan::STATUS_IN_PROGRESS)->count(), + 'completed' => AdminRoadmapPlan::status(AdminRoadmapPlan::STATUS_COMPLETED)->count(), + 'delayed' => AdminRoadmapPlan::status(AdminRoadmapPlan::STATUS_DELAYED)->count(), + 'cancelled' => AdminRoadmapPlan::status(AdminRoadmapPlan::STATUS_CANCELLED)->count(), + ]; + } + + public function getDashboardSummary(): array + { + $stats = $this->getStats(); + $timeline = $this->getTimelineData(); + + return [ + 'stats' => $stats, + 'timeline' => $timeline, + ]; + } + + public function getPlanById(int $id, bool $withTrashed = false): ?AdminRoadmapPlan + { + $query = AdminRoadmapPlan::query() + ->with(['milestones' => function ($q) { + $q->orderBy('sort_order')->orderBy('id'); + }, 'milestones.assignee', 'creator', 'updater']) + ->withCount('milestones'); + + if ($withTrashed) { + $query->withTrashed(); + } + + return $query->find($id); + } + + public function createPlan(array $data): AdminRoadmapPlan + { + $data['created_by'] = auth()->id(); + + return AdminRoadmapPlan::create($data); + } + + public function updatePlan(int $id, array $data): bool + { + $plan = AdminRoadmapPlan::findOrFail($id); + $data['updated_by'] = auth()->id(); + + return $plan->update($data); + } + + public function deletePlan(int $id): bool + { + $plan = AdminRoadmapPlan::findOrFail($id); + $plan->deleted_by = auth()->id(); + $plan->save(); + + return $plan->delete(); + } + + public function restorePlan(int $id): bool + { + $plan = AdminRoadmapPlan::onlyTrashed()->findOrFail($id); + $plan->deleted_by = null; + + return $plan->restore(); + } + + public function changeStatus(int $id, string $status): AdminRoadmapPlan + { + $plan = AdminRoadmapPlan::findOrFail($id); + $plan->status = $status; + $plan->updated_by = auth()->id(); + $plan->save(); + + return $plan; + } + + public function recalculateProgress(int $planId): void + { + $plan = AdminRoadmapPlan::findOrFail($planId); + $total = $plan->milestones()->count(); + + if ($total === 0) { + return; + } + + $completed = $plan->milestones() + ->where('status', AdminRoadmapMilestone::STATUS_COMPLETED) + ->count(); + + $plan->progress = (int) round(($completed / $total) * 100); + $plan->save(); + } +} diff --git a/resources/views/roadmap/index.blade.php b/resources/views/roadmap/index.blade.php new file mode 100644 index 00000000..e85dc814 --- /dev/null +++ b/resources/views/roadmap/index.blade.php @@ -0,0 +1,174 @@ +@extends('layouts.app') + +@section('title', '중장기 계획 대시보드') + +@section('content') + +
+

+ + + + 중장기 계획 대시보드 +

+ +
+ + +
+ +
+
+
+

전체 계획

+

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

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

진행중

+

{{ $summary['stats']['in_progress'] }}

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

완료

+

{{ $summary['stats']['completed'] }}

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

지연

+

{{ $summary['stats']['delayed'] }}

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

로드맵 타임라인

+
+ + @foreach($phases as $key => $label) + + @endforeach +
+
+ +
+ @foreach($summary['timeline'] as $phaseKey => $phaseData) + @if($phaseData['plans']->count() > 0) + + @endif + @endforeach + + @if(collect($summary['timeline'])->every(fn($d) => $d['plans']->count() === 0)) +
+ + + +

등록된 계획이 없습니다.

+ + + 첫 번째 계획 등록하기 + +
+ @endif +
+
+@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/roadmap/plans/create.blade.php b/resources/views/roadmap/plans/create.blade.php new file mode 100644 index 00000000..e07d3d15 --- /dev/null +++ b/resources/views/roadmap/plans/create.blade.php @@ -0,0 +1,162 @@ +@extends('layouts.app') + +@section('title', '새 계획') + +@section('content') + +
+ + ← 계획 목록 + +

새 계획

+
+ + +
+
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + #3B82F6 +
+
+
+ + +
+ + + 취소 + +
+
+
+@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/roadmap/plans/edit.blade.php b/resources/views/roadmap/plans/edit.blade.php new file mode 100644 index 00000000..830ca6a8 --- /dev/null +++ b/resources/views/roadmap/plans/edit.blade.php @@ -0,0 +1,164 @@ +@extends('layouts.app') + +@section('title', '계획 수정') + +@section('content') + +
+ + ← 계획 상세 + +

계획 수정

+
+ + +
+
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + {{ $plan->color }} +
+
+
+ + +
+ + + 취소 + +
+
+
+@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/roadmap/plans/index.blade.php b/resources/views/roadmap/plans/index.blade.php new file mode 100644 index 00000000..a2435624 --- /dev/null +++ b/resources/views/roadmap/plans/index.blade.php @@ -0,0 +1,127 @@ +@extends('layouts.app') + +@section('title', '계획 목록') + +@section('content') + +
+

+ + + + 계획 목록 +

+ +
+ + + +
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + +
+
+ + +
+
+
+
+
+@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/roadmap/plans/partials/table.blade.php b/resources/views/roadmap/plans/partials/table.blade.php new file mode 100644 index 00000000..4b6faf58 --- /dev/null +++ b/resources/views/roadmap/plans/partials/table.blade.php @@ -0,0 +1,113 @@ +
+ + + + + + + + + + + + + + + + + @forelse($plans as $plan) + + + + + + + + + + + + @empty + + + + @endforelse + +
#계획명상태카테고리우선순위Phase진행률기간액션
+ {{ $loop->iteration + (($plans->currentPage() - 1) * $plans->perPage()) }} + +
+ +
+ + {{ $plan->title }} + + @if($plan->description) +

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

+ @endif +
+
+
+ + {{ $plan->status_label }} + + + {{ $plan->category_label }} + + {{ $plan->priority_label }} + + {{ Str::before($plan->phase_label, ' —') }} + +
+
+
+
+ {{ $plan->progress }}% +
+
+ {{ $plan->period }} + + @if($plan->deleted_at) +
+ +
+ @else +
+ + + + + + + + + + + + +
+ @endif +
+ 등록된 계획이 없습니다. +
+
+
+ +@include('partials.pagination', [ + 'paginator' => $plans, + 'target' => '#plan-table', + 'includeForm' => '#filterForm' +]) diff --git a/resources/views/roadmap/plans/show.blade.php b/resources/views/roadmap/plans/show.blade.php new file mode 100644 index 00000000..5e4d81ae --- /dev/null +++ b/resources/views/roadmap/plans/show.blade.php @@ -0,0 +1,281 @@ +@extends('layouts.app') + +@section('title', $plan->title) + +@section('content') + +
+
+ + ← 계획 목록 + +

+ + {{ $plan->title }} +

+
+
+ + 수정 + + +
+
+ + +
+ +
+
+ {{ $plan->status_label }} + {{ $plan->priority_label }} + {{ $plan->category_label }} + {{ $plan->phase_label }} +
+ + @if($plan->description) +

{{ $plan->description }}

+ @endif + + @if($plan->content) +
+

상세 내용

+
{{ $plan->content }}
+
+ @endif +
+ + +
+

계획 정보

+
+
+
기간
+
{{ $plan->period }}
+
+
+
진행률
+
+
+
+
+
+ {{ $plan->progress }}% +
+
+
+ @if($plan->creator) +
+
작성자
+
{{ $plan->creator->name }}
+
+ @endif +
+
생성일
+
{{ $plan->created_at->format('Y-m-d H:i') }}
+
+ @if($plan->updated_at && $plan->updated_at->ne($plan->created_at)) +
+
수정일
+
{{ $plan->updated_at->format('Y-m-d H:i') }}
+
+ @endif +
+ + +
+

상태 변경

+
+ @foreach($statuses as $value => $label) + + @endforeach +
+
+
+
+ + +
+
+

+ 마일스톤 + + ({{ $plan->milestones->where('status', 'completed')->count() }}/{{ $plan->milestones->count() }}) + +

+
+ + +
+ @forelse($plan->milestones as $milestone) +
+ +
+
+ {{ $milestone->title }} + @if($milestone->due_date) + @php $dueStatus = $milestone->due_status; @endphp + + {{ $milestone->due_date->format('m/d') }} + + @endif + @if($milestone->assignee) + {{ $milestone->assignee->name }} + @endif +
+ @if($milestone->description) +

{{ $milestone->description }}

+ @endif +
+ +
+ @empty +

마일스톤이 없습니다.

+ @endforelse +
+ + +
+
+ + + +
+
+
+@endsection + +@push('scripts') + +@endpush diff --git a/routes/api.php b/routes/api.php index 1d98994a..0a9f0721 100644 --- a/routes/api.php +++ b/routes/api.php @@ -18,6 +18,8 @@ use App\Http\Controllers\Api\Admin\ProjectManagement\IssueController as PmIssueController; use App\Http\Controllers\Api\Admin\ProjectManagement\ProjectController as PmProjectController; use App\Http\Controllers\Api\Admin\ProjectManagement\TaskController as PmTaskController; +use App\Http\Controllers\Api\Admin\Roadmap\RoadmapMilestoneController; +use App\Http\Controllers\Api\Admin\Roadmap\RoadmapPlanController; use App\Http\Controllers\Api\Admin\Quote\QuoteFormulaCategoryController; use App\Http\Controllers\Api\Admin\Quote\QuoteFormulaController; use App\Http\Controllers\Api\Admin\RoleController; @@ -569,6 +571,37 @@ }); }); + /* + |-------------------------------------------------------------------------- + | 중장기 계획 API + |-------------------------------------------------------------------------- + */ + Route::prefix('roadmap')->name('roadmap.')->group(function () { + // 계획 API + Route::prefix('plans')->name('plans.')->group(function () { + Route::get('/stats', [RoadmapPlanController::class, 'stats'])->name('stats'); + Route::get('/timeline', [RoadmapPlanController::class, 'timeline'])->name('timeline'); + + Route::get('/', [RoadmapPlanController::class, 'index'])->name('index'); + Route::post('/', [RoadmapPlanController::class, 'store'])->name('store'); + Route::get('/{id}', [RoadmapPlanController::class, 'show'])->name('show'); + Route::put('/{id}', [RoadmapPlanController::class, 'update'])->name('update'); + Route::delete('/{id}', [RoadmapPlanController::class, 'destroy'])->name('destroy'); + + Route::post('/{id}/restore', [RoadmapPlanController::class, 'restore'])->name('restore'); + Route::post('/{id}/status', [RoadmapPlanController::class, 'changeStatus'])->name('changeStatus'); + }); + + // 마일스톤 API + Route::prefix('milestones')->name('milestones.')->group(function () { + Route::get('/plan/{planId}', [RoadmapMilestoneController::class, 'byPlan'])->name('byPlan'); + Route::post('/', [RoadmapMilestoneController::class, 'store'])->name('store'); + Route::put('/{id}', [RoadmapMilestoneController::class, 'update'])->name('update'); + Route::delete('/{id}', [RoadmapMilestoneController::class, 'destroy'])->name('destroy'); + Route::post('/{id}/toggle', [RoadmapMilestoneController::class, 'toggle'])->name('toggle'); + }); + }); + /* |-------------------------------------------------------------------------- | 일일 스크럼 API diff --git a/routes/web.php b/routes/web.php index bfc29e16..e048401e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -48,6 +48,7 @@ use App\Http\Controllers\PostController; use App\Http\Controllers\ProfileController; use App\Http\Controllers\ProjectManagementController; +use App\Http\Controllers\RoadmapController; use App\Http\Controllers\QuoteFormulaController; use App\Http\Controllers\RoleController; use App\Http\Controllers\RolePermissionController; @@ -357,6 +358,15 @@ Route::get('/import', [ProjectManagementController::class, 'import'])->name('import'); }); + // 중장기 계획 (Blade 화면만) + Route::prefix('roadmap')->name('roadmap.')->group(function () { + Route::get('/', [RoadmapController::class, 'index'])->name('index'); + Route::get('/plans', [RoadmapController::class, 'plans'])->name('plans.index'); + Route::get('/plans/create', [RoadmapController::class, 'createPlan'])->name('plans.create'); + Route::get('/plans/{id}', [RoadmapController::class, 'showPlan'])->name('plans.show'); + Route::get('/plans/{id}/edit', [RoadmapController::class, 'editPlan'])->name('plans.edit'); + }); + // 일일 스크럼 (Blade 화면만) Route::prefix('daily-logs')->name('daily-logs.')->group(function () { Route::get('/', [DailyLogController::class, 'index'])->name('index');