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:
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
130
app/Http/Controllers/Api/Admin/Roadmap/RoadmapPlanController.php
Normal file
130
app/Http/Controllers/Api/Admin/Roadmap/RoadmapPlanController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
79
app/Http/Controllers/RoadmapController.php
Normal file
79
app/Http/Controllers/RoadmapController.php
Normal 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'
|
||||
));
|
||||
}
|
||||
}
|
||||
46
app/Http/Requests/Roadmap/StoreMilestoneRequest.php
Normal file
46
app/Http/Requests/Roadmap/StoreMilestoneRequest.php
Normal 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자까지 입력 가능합니다.',
|
||||
];
|
||||
}
|
||||
}
|
||||
76
app/Http/Requests/Roadmap/StorePlanRequest.php
Normal file
76
app/Http/Requests/Roadmap/StorePlanRequest.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
42
app/Http/Requests/Roadmap/UpdateMilestoneRequest.php
Normal file
42
app/Http/Requests/Roadmap/UpdateMilestoneRequest.php
Normal 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자까지 입력 가능합니다.',
|
||||
];
|
||||
}
|
||||
}
|
||||
60
app/Http/Requests/Roadmap/UpdatePlanRequest.php
Normal file
60
app/Http/Requests/Roadmap/UpdatePlanRequest.php
Normal 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 이하여야 합니다.',
|
||||
];
|
||||
}
|
||||
}
|
||||
102
app/Models/Admin/AdminRoadmapMilestone.php
Normal file
102
app/Models/Admin/AdminRoadmapMilestone.php
Normal 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';
|
||||
}
|
||||
}
|
||||
231
app/Models/Admin/AdminRoadmapPlan.php
Normal file
231
app/Models/Admin/AdminRoadmapPlan.php
Normal 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 '-';
|
||||
}
|
||||
}
|
||||
78
app/Services/Roadmap/RoadmapMilestoneService.php
Normal file
78
app/Services/Roadmap/RoadmapMilestoneService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
174
resources/views/roadmap/index.blade.php
Normal file
174
resources/views/roadmap/index.blade.php
Normal 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
|
||||
162
resources/views/roadmap/plans/create.blade.php
Normal file
162
resources/views/roadmap/plans/create.blade.php
Normal 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
|
||||
164
resources/views/roadmap/plans/edit.blade.php
Normal file
164
resources/views/roadmap/plans/edit.blade.php
Normal 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
|
||||
127
resources/views/roadmap/plans/index.blade.php
Normal file
127
resources/views/roadmap/plans/index.blade.php
Normal 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
|
||||
113
resources/views/roadmap/plans/partials/table.blade.php
Normal file
113
resources/views/roadmap/plans/partials/table.blade.php
Normal 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'
|
||||
])
|
||||
281
resources/views/roadmap/plans/show.blade.php
Normal file
281
resources/views/roadmap/plans/show.blade.php
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user