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