- 모델: AdminRoadmapPlan, AdminRoadmapMilestone - 서비스: RoadmapPlanService, RoadmapMilestoneService - FormRequest: Store/Update Plan/Milestone 4개 - 컨트롤러: Blade(RoadmapController), API(Plan/Milestone) 3개 - 라우트: web.php, api.php에 roadmap 라우트 추가 - Blade 뷰: 대시보드, 목록, 생성, 수정, 상세, 파셜 테이블 6개 - HTMX 기반 필터링/페이지네이션, 마일스톤 인라인 추가/토글
186 lines
5.3 KiB
PHP
186 lines
5.3 KiB
PHP
<?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();
|
|
}
|
|
}
|