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'] }}
+{{ Str::limit($plan->description, 80) }}
+ @endif +등록된 계획이 없습니다.
+ + + 첫 번째 계획 등록하기 + +| # | +계획명 | +상태 | +카테고리 | +우선순위 | +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
+ |
+
| + 등록된 계획이 없습니다. + | +||||||||
{{ $plan->description }}
+ @endif + + @if($plan->content) +{{ $milestone->description }}
+ @endif +마일스톤이 없습니다.
+ @endforelse +