Files
sam-manage/resources/views/roadmap/plans/show.blade.php
김보곤 f3f1416004 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 기반 필터링/페이지네이션, 마일스톤 인라인 추가/토글
2026-03-02 15:50:20 +09:00

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