feat: [roadmap] 중장기 계획 메뉴 및 전용 페이지 개발
- 모델: AdminRoadmapPlan, AdminRoadmapMilestone - 서비스: RoadmapPlanService, RoadmapMilestoneService - FormRequest: Store/Update Plan/Milestone 4개 - 컨트롤러: Blade(RoadmapController), API(Plan/Milestone) 3개 - 라우트: web.php, api.php에 roadmap 라우트 추가 - Blade 뷰: 대시보드, 목록, 생성, 수정, 상세, 파셜 테이블 6개 - HTMX 기반 필터링/페이지네이션, 마일스톤 인라인 추가/토글
This commit is contained in:
185
app/Services/Roadmap/RoadmapPlanService.php
Normal file
185
app/Services/Roadmap/RoadmapPlanService.php
Normal file
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Roadmap;
|
||||
|
||||
use App\Models\Admin\AdminRoadmapMilestone;
|
||||
use App\Models\Admin\AdminRoadmapPlan;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
|
||||
class RoadmapPlanService
|
||||
{
|
||||
public function getPlans(array $filters = [], int $perPage = 15): LengthAwarePaginator
|
||||
{
|
||||
$query = AdminRoadmapPlan::query()
|
||||
->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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user