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:
김보곤
2026-03-02 15:50:20 +09:00
parent 458e5f890a
commit f3f1416004
19 changed files with 2161 additions and 0 deletions

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Http\Controllers\Api\Admin\Roadmap;
use App\Http\Controllers\Controller;
use App\Http\Requests\Roadmap\StoreMilestoneRequest;
use App\Http\Requests\Roadmap\UpdateMilestoneRequest;
use App\Services\Roadmap\RoadmapMilestoneService;
use Illuminate\Http\JsonResponse;
class RoadmapMilestoneController extends Controller
{
public function __construct(
private readonly RoadmapMilestoneService $milestoneService
) {}
public function byPlan(int $planId): JsonResponse
{
$milestones = $this->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,
]);
}
}

View File

@@ -0,0 +1,130 @@
<?php
namespace App\Http\Controllers\Api\Admin\Roadmap;
use App\Http\Controllers\Controller;
use App\Http\Requests\Roadmap\StorePlanRequest;
use App\Http\Requests\Roadmap\UpdatePlanRequest;
use App\Services\Roadmap\RoadmapPlanService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class RoadmapPlanController extends Controller
{
public function __construct(
private readonly RoadmapPlanService $planService
) {}
public function index(Request $request): View|JsonResponse
{
$filters = $request->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,
]);
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Http\Controllers;
use App\Models\Admin\AdminRoadmapPlan;
use App\Services\Roadmap\RoadmapPlanService;
use Illuminate\View\View;
class RoadmapController extends Controller
{
public function __construct(
private readonly RoadmapPlanService $planService
) {}
public function index(): View
{
$summary = $this->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'
));
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Http\Requests\Roadmap;
use Illuminate\Foundation\Http\FormRequest;
class StoreMilestoneRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'plan_id' => '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자까지 입력 가능합니다.',
];
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Http\Requests\Roadmap;
use App\Models\Admin\AdminRoadmapPlan;
use Illuminate\Foundation\Http\FormRequest;
class StorePlanRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'title' => '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]);
}
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Http\Requests\Roadmap;
use Illuminate\Foundation\Http\FormRequest;
class UpdateMilestoneRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'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 [
'title' => '마일스톤 제목',
'description' => '설명',
'due_date' => '예정일',
'assignee_id' => '담당자',
];
}
public function messages(): array
{
return [
'title.required' => '마일스톤 제목은 필수입니다.',
'title.max' => '마일스톤 제목은 최대 255자까지 입력 가능합니다.',
];
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Http\Requests\Roadmap;
use App\Models\Admin\AdminRoadmapPlan;
use Illuminate\Foundation\Http\FormRequest;
class UpdatePlanRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'title' => '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 이하여야 합니다.',
];
}
}

View File

@@ -0,0 +1,102 @@
<?php
namespace App\Models\Admin;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class AdminRoadmapMilestone extends Model
{
use SoftDeletes;
protected $table = 'admin_roadmap_milestones';
protected $fillable = [
'plan_id',
'title',
'description',
'status',
'due_date',
'completed_at',
'assignee_id',
'sort_order',
'created_by',
'updated_by',
'deleted_by',
];
protected $casts = [
'plan_id' => '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';
}
}

View File

@@ -0,0 +1,231 @@
<?php
namespace App\Models\Admin;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class AdminRoadmapPlan extends Model
{
use SoftDeletes;
protected $table = 'admin_roadmap_plans';
protected $fillable = [
'title',
'description',
'content',
'category',
'status',
'priority',
'phase',
'start_date',
'end_date',
'progress',
'color',
'sort_order',
'created_by',
'updated_by',
'deleted_by',
];
protected $casts = [
'start_date' => '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 '-';
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Services\Roadmap;
use App\Models\Admin\AdminRoadmapMilestone;
use Illuminate\Database\Eloquent\Collection;
class RoadmapMilestoneService
{
public function __construct(
private readonly RoadmapPlanService $planService
) {}
public function getMilestonesByPlan(int $planId): Collection
{
return AdminRoadmapMilestone::query()
->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;
}
}

View 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();
}
}

View File

@@ -0,0 +1,174 @@
@extends('layouts.app')
@section('title', '중장기 계획 대시보드')
@section('content')
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800 flex items-center gap-2">
<svg class="w-6 h-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
중장기 계획 대시보드
</h1>
<div class="flex gap-2">
<a href="{{ route('roadmap.plans.index') }}" class="bg-white hover:bg-gray-100 text-gray-700 px-4 py-2 rounded-lg border transition">
목록 보기
</a>
<a href="{{ route('roadmap.plans.create') }}" class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg transition">
+ 계획
</a>
</div>
</div>
<!-- 통계 카드 -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<!-- 전체 -->
<div class="bg-white rounded-lg shadow-sm p-5">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 mb-1">전체 계획</p>
<p class="text-3xl font-bold text-gray-800">{{ $summary['stats']['total'] }}</p>
</div>
<div class="w-11 h-11 bg-indigo-100 rounded-full flex items-center justify-center text-indigo-600">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
</div>
</div>
<!-- 진행중 -->
<div class="bg-white rounded-lg shadow-sm p-5">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 mb-1">진행중</p>
<p class="text-3xl font-bold text-blue-600">{{ $summary['stats']['in_progress'] }}</p>
</div>
<div class="w-11 h-11 bg-blue-100 rounded-full flex items-center justify-center text-blue-600">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
</div>
</div>
<!-- 완료 -->
<div class="bg-white rounded-lg shadow-sm p-5">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 mb-1">완료</p>
<p class="text-3xl font-bold text-green-600">{{ $summary['stats']['completed'] }}</p>
</div>
<div class="w-11 h-11 bg-green-100 rounded-full flex items-center justify-center text-green-600">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
</div>
<!-- 지연 -->
<div class="bg-white rounded-lg shadow-sm p-5">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 mb-1">지연</p>
<p class="text-3xl font-bold text-red-600">{{ $summary['stats']['delayed'] }}</p>
</div>
<div class="w-11 h-11 bg-red-100 rounded-full flex items-center justify-center text-red-600">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
</div>
</div>
<!-- 로드맵 타임라인 -->
<div class="bg-white rounded-lg shadow-sm p-6">
<div class="flex justify-between items-center mb-6">
<h2 class="text-lg font-bold text-gray-800">로드맵 타임라인</h2>
<div class="flex gap-1" id="phaseTabs">
<button onclick="filterPhase(null)" class="phase-tab px-3 py-1.5 text-sm rounded-lg bg-indigo-600 text-white transition" data-phase="all">전체</button>
@foreach($phases as $key => $label)
<button onclick="filterPhase('{{ $key }}')" class="phase-tab px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-700 transition" data-phase="{{ $key }}">{{ Str::before($label, ' —') }}</button>
@endforeach
</div>
</div>
<div id="timelineContainer">
@foreach($summary['timeline'] as $phaseKey => $phaseData)
@if($phaseData['plans']->count() > 0)
<div class="phase-group mb-6" data-phase="{{ $phaseKey }}">
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-3">{{ $phaseData['label'] }}</h3>
<div class="space-y-3">
@foreach($phaseData['plans'] as $plan)
<a href="{{ route('roadmap.plans.show', $plan->id) }}" class="block border border-gray-200 rounded-lg p-4 hover:border-indigo-300 hover:shadow-sm transition">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-full" style="background-color: {{ $plan->color }}"></span>
<span class="font-medium text-gray-900">{{ $plan->title }}</span>
<span class="px-2 py-0.5 text-xs rounded-full {{ $plan->status_color }}">{{ $plan->status_label }}</span>
<span class="px-2 py-0.5 text-xs rounded-full {{ $plan->priority_color }}">{{ $plan->priority_label }}</span>
</div>
<span class="text-sm text-gray-500">{{ $plan->period }}</span>
</div>
@if($plan->description)
<p class="text-sm text-gray-500 mb-2">{{ Str::limit($plan->description, 80) }}</p>
@endif
<div class="flex items-center gap-3">
<div class="flex-1 bg-gray-200 rounded-full h-2.5 overflow-hidden">
<div class="h-2.5 rounded-full transition-all" style="width: {{ $plan->progress }}%; background-color: {{ $plan->color }}"></div>
</div>
<span class="text-sm font-medium text-gray-700 whitespace-nowrap" style="min-width: 40px; text-align: right;">{{ $plan->progress }}%</span>
</div>
</a>
@endforeach
</div>
</div>
@endif
@endforeach
@if(collect($summary['timeline'])->every(fn($d) => $d['plans']->count() === 0))
<div class="text-center py-12 text-gray-400">
<svg class="w-16 h-16 mx-auto mb-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<p>등록된 계획이 없습니다.</p>
<a href="{{ route('roadmap.plans.create') }}" class="inline-block mt-4 text-indigo-600 hover:text-indigo-700 font-medium">
+ 번째 계획 등록하기
</a>
</div>
@endif
</div>
</div>
@endsection
@push('scripts')
<script>
function filterPhase(phase) {
// 탭 활성 상태
document.querySelectorAll('.phase-tab').forEach(tab => {
tab.classList.remove('bg-indigo-600', 'text-white');
tab.classList.add('bg-gray-100', 'text-gray-700');
});
const activeTab = phase
? document.querySelector(`.phase-tab[data-phase="${phase}"]`)
: document.querySelector('.phase-tab[data-phase="all"]');
if (activeTab) {
activeTab.classList.remove('bg-gray-100', 'text-gray-700');
activeTab.classList.add('bg-indigo-600', 'text-white');
}
// Phase 그룹 필터링
document.querySelectorAll('.phase-group').forEach(group => {
if (!phase || group.dataset.phase === phase) {
group.style.display = '';
} else {
group.style.display = 'none';
}
});
}
</script>
@endpush

View File

@@ -0,0 +1,162 @@
@extends('layouts.app')
@section('title', '새 계획')
@section('content')
<!-- 페이지 헤더 -->
<div class="flex items-center gap-4 mb-6">
<a href="{{ route('roadmap.plans.index') }}" class="text-gray-500 hover:text-gray-700">
계획 목록
</a>
<h1 class="text-2xl font-bold text-gray-800"> 계획</h1>
</div>
<!-- -->
<div class="bg-white rounded-lg shadow-sm p-6 max-w-3xl">
<form id="planForm">
<!-- 제목 -->
<div class="mb-6">
<label for="title" class="block text-sm font-medium text-gray-700 mb-2">
계획 제목 <span class="text-red-500">*</span>
</label>
<input type="text" id="title" name="title" required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="계획 제목을 입력하세요">
</div>
<!-- 설명 -->
<div class="mb-6">
<label for="description" class="block text-sm font-medium text-gray-700 mb-2">설명</label>
<textarea id="description" name="description" rows="3"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="짧은 설명을 입력하세요"></textarea>
</div>
<!-- 상세 내용 -->
<div class="mb-6">
<label for="content" class="block text-sm font-medium text-gray-700 mb-2">상세 내용</label>
<textarea id="content" name="content" rows="8"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="상세 계획 내용을 입력하세요"></textarea>
</div>
<!-- 2 레이아웃 -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
<!-- 카테고리 -->
<div>
<label for="category" class="block text-sm font-medium text-gray-700 mb-2">카테고리</label>
<select id="category" name="category"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500">
@foreach($categories as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
</div>
<!-- 상태 -->
<div>
<label for="status" class="block text-sm font-medium text-gray-700 mb-2">상태</label>
<select id="status" name="status"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500">
@foreach($statuses as $value => $label)
<option value="{{ $value }}" {{ $value === 'planned' ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
</select>
</div>
<!-- 우선순위 -->
<div>
<label for="priority" class="block text-sm font-medium text-gray-700 mb-2">우선순위</label>
<select id="priority" name="priority"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500">
@foreach($priorities as $value => $label)
<option value="{{ $value }}" {{ $value === 'medium' ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
</select>
</div>
<!-- Phase -->
<div>
<label for="phase" class="block text-sm font-medium text-gray-700 mb-2">Phase</label>
<select id="phase" name="phase"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500">
@foreach($phases as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
</div>
</div>
<!-- 기간 + 색상 -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
<div>
<label for="start_date" class="block text-sm font-medium text-gray-700 mb-2">시작일</label>
<input type="date" id="start_date" name="start_date"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500">
</div>
<div>
<label for="end_date" class="block text-sm font-medium text-gray-700 mb-2">종료일</label>
<input type="date" id="end_date" name="end_date"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500">
</div>
<div>
<label for="color" class="block text-sm font-medium text-gray-700 mb-2">타임라인 색상</label>
<div class="flex items-center gap-2">
<input type="color" id="color" name="color" value="#3B82F6"
class="w-10 h-10 border border-gray-300 rounded cursor-pointer">
<span id="colorHex" class="text-sm text-gray-500">#3B82F6</span>
</div>
</div>
</div>
<!-- 버튼 -->
<div class="flex gap-4">
<button type="submit" class="bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-2 rounded-lg transition">
저장
</button>
<a href="{{ route('roadmap.plans.index') }}" class="bg-gray-300 hover:bg-gray-400 text-gray-700 px-6 py-2 rounded-lg transition">
취소
</a>
</div>
</form>
</div>
@endsection
@push('scripts')
<script>
document.getElementById('color').addEventListener('input', function() {
document.getElementById('colorHex').textContent = this.value;
});
document.getElementById('planForm').addEventListener('submit', async function(e) {
e.preventDefault();
const formData = new FormData(this);
const data = Object.fromEntries(formData.entries());
try {
const response = await fetch('/api/admin/roadmap/plans', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json'
},
body: JSON.stringify(data)
});
const result = await response.json();
if (result.success) {
showToast(result.message, 'success');
window.location.href = '{{ route('roadmap.plans.index') }}';
} else {
showToast(result.message || '저장에 실패했습니다.', 'error');
}
} catch (error) {
console.error('Error:', error);
showToast('저장 중 오류가 발생했습니다.', 'error');
}
});
</script>
@endpush

View File

@@ -0,0 +1,164 @@
@extends('layouts.app')
@section('title', '계획 수정')
@section('content')
<!-- 페이지 헤더 -->
<div class="flex items-center gap-4 mb-6">
<a href="{{ route('roadmap.plans.show', $plan->id) }}" class="text-gray-500 hover:text-gray-700">
계획 상세
</a>
<h1 class="text-2xl font-bold text-gray-800">계획 수정</h1>
</div>
<!-- -->
<div class="bg-white rounded-lg shadow-sm p-6 max-w-3xl">
<form id="planForm">
<!-- 제목 -->
<div class="mb-6">
<label for="title" class="block text-sm font-medium text-gray-700 mb-2">
계획 제목 <span class="text-red-500">*</span>
</label>
<input type="text" id="title" name="title" required
value="{{ $plan->title }}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500">
</div>
<!-- 설명 -->
<div class="mb-6">
<label for="description" class="block text-sm font-medium text-gray-700 mb-2">설명</label>
<textarea id="description" name="description" rows="3"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500">{{ $plan->description }}</textarea>
</div>
<!-- 상세 내용 -->
<div class="mb-6">
<label for="content" class="block text-sm font-medium text-gray-700 mb-2">상세 내용</label>
<textarea id="content" name="content" rows="8"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500">{{ $plan->content }}</textarea>
</div>
<!-- 2 레이아웃 -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
<div>
<label for="category" class="block text-sm font-medium text-gray-700 mb-2">카테고리</label>
<select id="category" name="category"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500">
@foreach($categories as $value => $label)
<option value="{{ $value }}" {{ $plan->category === $value ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
</select>
</div>
<div>
<label for="status" class="block text-sm font-medium text-gray-700 mb-2">상태</label>
<select id="status" name="status"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500">
@foreach($statuses as $value => $label)
<option value="{{ $value }}" {{ $plan->status === $value ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
</select>
</div>
<div>
<label for="priority" class="block text-sm font-medium text-gray-700 mb-2">우선순위</label>
<select id="priority" name="priority"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500">
@foreach($priorities as $value => $label)
<option value="{{ $value }}" {{ $plan->priority === $value ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
</select>
</div>
<div>
<label for="phase" class="block text-sm font-medium text-gray-700 mb-2">Phase</label>
<select id="phase" name="phase"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500">
@foreach($phases as $value => $label)
<option value="{{ $value }}" {{ $plan->phase === $value ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
</select>
</div>
</div>
<!-- 기간 + 진행률 + 색상 -->
<div class="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6">
<div>
<label for="start_date" class="block text-sm font-medium text-gray-700 mb-2">시작일</label>
<input type="date" id="start_date" name="start_date"
value="{{ $plan->start_date?->format('Y-m-d') }}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500">
</div>
<div>
<label for="end_date" class="block text-sm font-medium text-gray-700 mb-2">종료일</label>
<input type="date" id="end_date" name="end_date"
value="{{ $plan->end_date?->format('Y-m-d') }}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500">
</div>
<div>
<label for="progress" class="block text-sm font-medium text-gray-700 mb-2">진행률 (%)</label>
<input type="number" id="progress" name="progress" min="0" max="100"
value="{{ $plan->progress }}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500">
</div>
<div>
<label for="color" class="block text-sm font-medium text-gray-700 mb-2">색상</label>
<div class="flex items-center gap-2">
<input type="color" id="color" name="color" value="{{ $plan->color }}"
class="w-10 h-10 border border-gray-300 rounded cursor-pointer">
<span id="colorHex" class="text-sm text-gray-500">{{ $plan->color }}</span>
</div>
</div>
</div>
<!-- 버튼 -->
<div class="flex gap-4">
<button type="submit" class="bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-2 rounded-lg transition">
수정
</button>
<a href="{{ route('roadmap.plans.show', $plan->id) }}" class="bg-gray-300 hover:bg-gray-400 text-gray-700 px-6 py-2 rounded-lg transition">
취소
</a>
</div>
</form>
</div>
@endsection
@push('scripts')
<script>
document.getElementById('color').addEventListener('input', function() {
document.getElementById('colorHex').textContent = this.value;
});
document.getElementById('planForm').addEventListener('submit', async function(e) {
e.preventDefault();
const formData = new FormData(this);
const data = Object.fromEntries(formData.entries());
try {
const response = await fetch('/api/admin/roadmap/plans/{{ $plan->id }}', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json'
},
body: JSON.stringify(data)
});
const result = await response.json();
if (result.success) {
showToast(result.message, 'success');
window.location.href = '{{ route('roadmap.plans.show', $plan->id) }}';
} else {
showToast(result.message || '수정에 실패했습니다.', 'error');
}
} catch (error) {
console.error('Error:', error);
showToast('수정 중 오류가 발생했습니다.', 'error');
}
});
</script>
@endpush

View File

@@ -0,0 +1,127 @@
@extends('layouts.app')
@section('title', '계획 목록')
@section('content')
<!-- 페이지 헤더 -->
<div class="flex flex-col lg:flex-row lg:justify-between lg:items-center gap-4 mb-6">
<h1 class="text-2xl font-bold text-gray-800 flex items-center gap-2">
<svg class="w-6 h-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
계획 목록
</h1>
<div class="flex flex-wrap items-center gap-2 sm:gap-3">
<a href="{{ route('roadmap.index') }}" class="flex-1 sm:flex-none inline-flex items-center justify-center gap-1 px-3 py-1.5 text-sm bg-white hover:bg-gray-300 text-gray-700 rounded-lg transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
대시보드
</a>
<a href="{{ route('roadmap.plans.create') }}" class="w-full sm:w-auto bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg transition text-center">
+ 계획
</a>
</div>
</div>
<!-- 필터 영역 -->
<x-filter-collapsible id="filterForm">
<form id="filterForm" class="flex flex-wrap gap-2 sm:gap-4">
<!-- 검색 -->
<div class="flex-1 min-w-0 w-full sm:w-auto">
<input type="text" name="search" placeholder="계획명, 설명으로 검색..."
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500">
</div>
<!-- 상태 -->
<div class="w-full sm:w-36">
<select name="status" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500">
<option value="">전체 상태</option>
@foreach($statuses as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
</div>
<!-- 카테고리 -->
<div class="w-full sm:w-36">
<select name="category" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500">
<option value="">전체 카테고리</option>
@foreach($categories as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
</div>
<!-- 우선순위 -->
<div class="w-full sm:w-36">
<select name="priority" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500">
<option value="">전체 우선순위</option>
@foreach($priorities as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
</div>
<!-- Phase -->
<div class="w-full sm:w-44">
<select name="phase" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500">
<option value="">전체 Phase</option>
@foreach($phases as $value => $label)
<option value="{{ $value }}">{{ Str::before($label, ' —') }}</option>
@endforeach
</select>
</div>
<!-- 검색 버튼 -->
<button type="submit" class="bg-gray-600 hover:bg-gray-700 text-white px-6 py-2 rounded-lg transition w-full sm:w-auto">
검색
</button>
</form>
</x-filter-collapsible>
<!-- 테이블 영역 (HTMX) -->
<div id="plan-table"
hx-get="/api/admin/roadmap/plans"
hx-trigger="load, filterSubmit from:body"
hx-include="#filterForm"
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
class="bg-white rounded-lg shadow-sm overflow-hidden">
<div class="flex justify-center items-center p-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
</div>
</div>
@endsection
@push('scripts')
<script>
document.getElementById('filterForm').addEventListener('submit', function(e) {
e.preventDefault();
htmx.trigger('#plan-table', 'filterSubmit');
});
window.confirmDelete = function(id, name) {
showDeleteConfirm(`"${name}" 계획`, () => {
htmx.ajax('DELETE', `/api/admin/roadmap/plans/${id}`, {
target: '#plan-table',
swap: 'none',
headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
}).then(() => {
htmx.trigger('#plan-table', 'filterSubmit');
});
});
};
window.confirmRestore = function(id, name) {
showConfirm(`"${name}" 계획을 복원하시겠습니까?`, () => {
htmx.ajax('POST', `/api/admin/roadmap/plans/${id}/restore`, {
target: '#plan-table',
swap: 'none',
headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
}).then(() => {
htmx.trigger('#plan-table', 'filterSubmit');
});
}, { title: '복원 확인', icon: 'question' });
};
</script>
@endpush

View File

@@ -0,0 +1,113 @@
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
<x-table-swipe>
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider w-16">#</th>
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">계획명</th>
<th class="px-4 py-3 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider">상태</th>
<th class="px-4 py-3 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider">카테고리</th>
<th class="px-4 py-3 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider">우선순위</th>
<th class="px-4 py-3 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider">Phase</th>
<th class="px-4 py-3 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider">진행률</th>
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">기간</th>
<th class="px-4 py-3 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider w-28">액션</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@forelse($plans as $plan)
<tr class="{{ $plan->deleted_at ? 'bg-gray-100' : '' }} hover:bg-gray-50 cursor-pointer"
onclick="window.location.href='{{ route('roadmap.plans.show', $plan->id) }}'">
<td class="px-4 py-4 whitespace-nowrap text-sm text-center text-gray-500">
{{ $loop->iteration + (($plans->currentPage() - 1) * $plans->perPage()) }}
</td>
<td class="px-4 py-4 whitespace-nowrap">
<div class="flex items-center gap-2">
<span class="w-2.5 h-2.5 rounded-full shrink-0" style="background-color: {{ $plan->color }}"></span>
<div>
<a href="{{ route('roadmap.plans.show', $plan->id) }}" class="text-sm font-medium text-gray-900 hover:text-indigo-600">
{{ $plan->title }}
</a>
@if($plan->description)
<p class="text-xs text-gray-500 mt-0.5 truncate max-w-xs">{{ Str::limit($plan->description, 50) }}</p>
@endif
</div>
</div>
</td>
<td class="px-4 py-4 whitespace-nowrap text-center">
<span class="px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full {{ $plan->status_color }}">
{{ $plan->status_label }}
</span>
</td>
<td class="px-4 py-4 whitespace-nowrap text-center">
<span class="text-xs text-gray-600">{{ $plan->category_label }}</span>
</td>
<td class="px-4 py-4 whitespace-nowrap text-center">
<span class="px-2 py-0.5 text-xs rounded-full {{ $plan->priority_color }}">{{ $plan->priority_label }}</span>
</td>
<td class="px-4 py-4 whitespace-nowrap text-center">
<span class="text-xs text-gray-600">{{ Str::before($plan->phase_label, ' —') }}</span>
</td>
<td class="px-4 py-4 whitespace-nowrap">
<div class="flex items-center gap-2">
<div class="w-20 bg-gray-200 rounded-full h-2 overflow-hidden">
<div class="h-2 rounded-full" style="width: {{ $plan->progress }}%; background-color: {{ $plan->color }}"></div>
</div>
<span class="text-xs font-medium text-gray-700">{{ $plan->progress }}%</span>
</div>
</td>
<td class="px-4 py-4 whitespace-nowrap text-sm text-gray-500">
{{ $plan->period }}
</td>
<td class="px-4 py-4 whitespace-nowrap text-center" onclick="event.stopPropagation()">
@if($plan->deleted_at)
<div class="flex justify-center gap-1">
<button onclick="confirmRestore({{ $plan->id }}, '{{ $plan->title }}')"
class="p-1.5 text-green-600 hover:text-green-900 hover:bg-green-50 rounded" title="복원">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
</button>
</div>
@else
<div class="flex justify-center gap-1">
<a href="{{ route('roadmap.plans.show', $plan->id) }}"
class="p-1.5 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded" title="보기">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
</a>
<a href="{{ route('roadmap.plans.edit', $plan->id) }}"
class="p-1.5 text-blue-600 hover:text-blue-900 hover:bg-blue-50 rounded" title="수정">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</a>
<button onclick="confirmDelete({{ $plan->id }}, '{{ $plan->title }}')"
class="p-1.5 text-red-600 hover:text-red-900 hover:bg-red-50 rounded" title="삭제">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
</div>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="9" class="px-6 py-12 text-center text-gray-500">
등록된 계획이 없습니다.
</td>
</tr>
@endforelse
</tbody>
</table>
</x-table-swipe>
</div>
@include('partials.pagination', [
'paginator' => $plans,
'target' => '#plan-table',
'includeForm' => '#filterForm'
])

View File

@@ -0,0 +1,281 @@
@extends('layouts.app')
@section('title', $plan->title)
@section('content')
<!-- 페이지 헤더 -->
<div class="flex flex-col lg:flex-row lg:justify-between lg:items-center gap-4 mb-6">
<div class="flex items-center gap-4">
<a href="{{ route('roadmap.plans.index') }}" class="text-gray-500 hover:text-gray-700">
계획 목록
</a>
<h1 class="text-2xl font-bold text-gray-800 flex items-center gap-2">
<span class="w-4 h-4 rounded-full" style="background-color: {{ $plan->color }}"></span>
{{ $plan->title }}
</h1>
</div>
<div class="flex gap-2">
<a href="{{ route('roadmap.plans.edit', $plan->id) }}" class="bg-white hover:bg-gray-100 text-gray-700 px-4 py-2 rounded-lg border transition">
수정
</a>
<button onclick="confirmDelete({{ $plan->id }}, '{{ $plan->title }}')" class="bg-red-50 hover:bg-red-100 text-red-600 px-4 py-2 rounded-lg border border-red-200 transition">
삭제
</button>
</div>
</div>
<!-- 계획 정보 카드 -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
<!-- 메인 정보 -->
<div class="lg:col-span-2 bg-white rounded-lg shadow-sm p-6">
<div class="flex flex-wrap gap-2 mb-4">
<span class="px-3 py-1 text-sm rounded-full {{ $plan->status_color }}">{{ $plan->status_label }}</span>
<span class="px-3 py-1 text-sm rounded-full {{ $plan->priority_color }}">{{ $plan->priority_label }}</span>
<span class="px-3 py-1 text-sm rounded-full bg-indigo-100 text-indigo-700">{{ $plan->category_label }}</span>
<span class="px-3 py-1 text-sm rounded-full bg-gray-100 text-gray-700">{{ $plan->phase_label }}</span>
</div>
@if($plan->description)
<p class="text-gray-600 mb-4">{{ $plan->description }}</p>
@endif
@if($plan->content)
<div class="border-t pt-4">
<h3 class="text-sm font-semibold text-gray-500 uppercase mb-2">상세 내용</h3>
<div class="prose prose-sm max-w-none text-gray-700 whitespace-pre-wrap">{{ $plan->content }}</div>
</div>
@endif
</div>
<!-- 사이드 정보 -->
<div class="bg-white rounded-lg shadow-sm p-6">
<h3 class="text-sm font-semibold text-gray-500 uppercase mb-4">계획 정보</h3>
<dl class="space-y-3">
<div>
<dt class="text-xs text-gray-400">기간</dt>
<dd class="text-sm font-medium text-gray-800">{{ $plan->period }}</dd>
</div>
<div>
<dt class="text-xs text-gray-400">진행률</dt>
<dd>
<div class="flex items-center gap-2 mt-1">
<div class="flex-1 bg-gray-200 rounded-full h-3 overflow-hidden">
<div class="h-3 rounded-full transition-all" style="width: {{ $plan->progress }}%; background-color: {{ $plan->color }}" id="progressBar"></div>
</div>
<span class="text-sm font-bold" id="progressText">{{ $plan->progress }}%</span>
</div>
</dd>
</div>
@if($plan->creator)
<div>
<dt class="text-xs text-gray-400">작성자</dt>
<dd class="text-sm text-gray-800">{{ $plan->creator->name }}</dd>
</div>
@endif
<div>
<dt class="text-xs text-gray-400">생성일</dt>
<dd class="text-sm text-gray-800">{{ $plan->created_at->format('Y-m-d H:i') }}</dd>
</div>
@if($plan->updated_at && $plan->updated_at->ne($plan->created_at))
<div>
<dt class="text-xs text-gray-400">수정일</dt>
<dd class="text-sm text-gray-800">{{ $plan->updated_at->format('Y-m-d H:i') }}</dd>
</div>
@endif
</dl>
<!-- 상태 빠른 변경 -->
<div class="border-t mt-4 pt-4">
<h4 class="text-xs text-gray-400 mb-2">상태 변경</h4>
<div class="flex flex-wrap gap-1">
@foreach($statuses as $value => $label)
<button onclick="changeStatus('{{ $value }}')"
class="px-2 py-1 text-xs rounded {{ $plan->status === $value ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200' }} transition">
{{ $label }}
</button>
@endforeach
</div>
</div>
</div>
</div>
<!-- 마일스톤 섹션 -->
<div class="bg-white rounded-lg shadow-sm p-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-bold text-gray-800">
마일스톤
<span class="text-sm font-normal text-gray-500" id="milestoneCount">
({{ $plan->milestones->where('status', 'completed')->count() }}/{{ $plan->milestones->count() }})
</span>
</h2>
</div>
<!-- 마일스톤 목록 -->
<div id="milestoneList" class="space-y-2 mb-4">
@forelse($plan->milestones as $milestone)
<div class="milestone-item flex items-start gap-3 p-3 rounded-lg border {{ $milestone->status === 'completed' ? 'bg-green-50 border-green-200' : 'bg-white border-gray-200' }} group" data-id="{{ $milestone->id }}">
<button onclick="toggleMilestone({{ $milestone->id }})" class="mt-0.5 shrink-0">
@if($milestone->status === 'completed')
<svg class="w-5 h-5 text-green-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
@else
<svg class="w-5 h-5 text-gray-300 hover:text-green-400 transition" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke-width="2" />
</svg>
@endif
</button>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-sm font-medium {{ $milestone->status === 'completed' ? 'text-gray-500 line-through' : 'text-gray-900' }}">{{ $milestone->title }}</span>
@if($milestone->due_date)
@php $dueStatus = $milestone->due_status; @endphp
<span class="text-xs {{ $dueStatus === 'overdue' ? 'text-red-500' : ($dueStatus === 'due_soon' ? 'text-orange-500' : 'text-gray-400') }}">
{{ $milestone->due_date->format('m/d') }}
</span>
@endif
@if($milestone->assignee)
<span class="text-xs bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded">{{ $milestone->assignee->name }}</span>
@endif
</div>
@if($milestone->description)
<p class="text-xs text-gray-500 mt-1">{{ $milestone->description }}</p>
@endif
</div>
<button onclick="deleteMilestone({{ $milestone->id }}, '{{ $milestone->title }}')"
class="shrink-0 p-1 text-gray-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
@empty
<p class="text-sm text-gray-400 py-4 text-center" id="emptyMilestone">마일스톤이 없습니다.</p>
@endforelse
</div>
<!-- 마일스톤 추가 -->
<div class="border-t pt-4">
<form id="milestoneForm" class="flex gap-2">
<input type="text" name="title" placeholder="새 마일스톤 추가..." required
class="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500">
<input type="date" name="due_date"
class="px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500">
<button type="submit" class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 text-sm rounded-lg transition whitespace-nowrap">
추가
</button>
</form>
</div>
</div>
@endsection
@push('scripts')
<script>
const planId = {{ $plan->id }};
const csrfToken = '{{ csrf_token() }}';
async function toggleMilestone(id) {
try {
const response = await fetch(`/api/admin/roadmap/milestones/${id}/toggle`, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': csrfToken, 'Accept': 'application/json' }
});
const result = await response.json();
if (result.success) {
location.reload();
}
} catch (error) {
showToast('변경 중 오류가 발생했습니다.', 'error');
}
}
function deleteMilestone(id, title) {
showDeleteConfirm(`"${title}" 마일스톤`, async () => {
try {
const response = await fetch(`/api/admin/roadmap/milestones/${id}`, {
method: 'DELETE',
headers: { 'X-CSRF-TOKEN': csrfToken, 'Accept': 'application/json' }
});
const result = await response.json();
if (result.success) {
showToast(result.message, 'success');
location.reload();
}
} catch (error) {
showToast('삭제 중 오류가 발생했습니다.', 'error');
}
});
}
document.getElementById('milestoneForm').addEventListener('submit', async function(e) {
e.preventDefault();
const formData = new FormData(this);
const data = {
plan_id: planId,
title: formData.get('title'),
due_date: formData.get('due_date') || null,
};
try {
const response = await fetch('/api/admin/roadmap/milestones', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken,
'Accept': 'application/json'
},
body: JSON.stringify(data)
});
const result = await response.json();
if (result.success) {
showToast(result.message, 'success');
location.reload();
} else {
showToast(result.message || '추가에 실패했습니다.', 'error');
}
} catch (error) {
showToast('추가 중 오류가 발생했습니다.', 'error');
}
});
async function changeStatus(status) {
try {
const response = await fetch(`/api/admin/roadmap/plans/${planId}/status`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken,
'Accept': 'application/json'
},
body: JSON.stringify({ status })
});
const result = await response.json();
if (result.success) {
showToast(result.message, 'success');
location.reload();
}
} catch (error) {
showToast('상태 변경 중 오류가 발생했습니다.', 'error');
}
}
window.confirmDelete = function(id, title) {
showDeleteConfirm(`"${title}" 계획`, async () => {
try {
const response = await fetch(`/api/admin/roadmap/plans/${id}`, {
method: 'DELETE',
headers: { 'X-CSRF-TOKEN': csrfToken, 'Accept': 'application/json' }
});
const result = await response.json();
if (result.success) {
showToast(result.message, 'success');
window.location.href = '{{ route('roadmap.plans.index') }}';
}
} catch (error) {
showToast('삭제 중 오류가 발생했습니다.', 'error');
}
});
};
</script>
@endpush

View File

@@ -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

View File

@@ -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');