- 대시보드 페이지: 통계 카드, 섹션 제목, JS 동적 렌더링 이모지 → Heroicons SVG - 프로젝트 목록 페이지: 페이지 제목 이모지 → folder SVG - mng 시스템 기존 아이콘 스타일과 일관성 유지 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
362 lines
20 KiB
PHP
362 lines
20 KiB
PHP
@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" 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>
|
|
<a href="{{ route('pm.projects.create') }}" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition">
|
|
+ 새 프로젝트
|
|
</a>
|
|
</div>
|
|
|
|
<!-- 통계 카드 -->
|
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
|
<!-- 프로젝트 통계 -->
|
|
<div class="bg-white rounded-lg shadow-sm p-6">
|
|
<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['project_stats']['total'] }}</p>
|
|
</div>
|
|
<div class="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center text-blue-600">
|
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
<div class="mt-4 flex gap-2 text-xs">
|
|
<span class="px-2 py-1 bg-green-100 text-green-700 rounded">활성 {{ $summary['project_stats']['active'] }}</span>
|
|
<span class="px-2 py-1 bg-gray-100 text-gray-700 rounded">완료 {{ $summary['project_stats']['completed'] }}</span>
|
|
<span class="px-2 py-1 bg-yellow-100 text-yellow-700 rounded">보류 {{ $summary['project_stats']['on_hold'] }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 작업 통계 -->
|
|
<div class="bg-white rounded-lg shadow-sm p-6">
|
|
<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['task_stats']['total'] }}</p>
|
|
</div>
|
|
<div class="w-12 h-12 bg-purple-100 rounded-full flex items-center justify-center text-purple-600">
|
|
<svg class="w-6 h-6" 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 class="mt-4">
|
|
@php
|
|
$taskTotal = $summary['task_stats']['total'] ?: 1;
|
|
$taskDone = $summary['task_stats']['done'] ?? 0;
|
|
$taskInProgress = $summary['task_stats']['in_progress'] ?? 0;
|
|
$donePercent = round(($taskDone / $taskTotal) * 100);
|
|
$inProgressPercent = round((($taskDone + $taskInProgress) / $taskTotal) * 100);
|
|
@endphp
|
|
<div class="w-full bg-gray-200 rounded-full h-2 relative overflow-hidden">
|
|
<div class="absolute h-2 rounded-full bg-blue-400" style="width: {{ $inProgressPercent }}%"></div>
|
|
<div class="absolute h-2 rounded-full bg-green-500" style="width: {{ $donePercent }}%"></div>
|
|
</div>
|
|
<div class="flex justify-between text-xs mt-1">
|
|
<span class="text-gray-500">진행률 {{ $inProgressPercent }}%</span>
|
|
<span class="text-gray-400">(완료 {{ $donePercent }}% + 진행 {{ $inProgressPercent - $donePercent }}%)</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 마감 임박 작업 -->
|
|
<div class="bg-white rounded-lg shadow-sm p-6">
|
|
<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-orange-600">{{ $summary['task_stats']['due_soon'] }}</p>
|
|
</div>
|
|
<div class="w-12 h-12 bg-orange-100 rounded-full flex items-center justify-center text-orange-600">
|
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
<div class="mt-4">
|
|
<p class="text-sm text-red-600 font-medium">
|
|
지연됨: {{ $summary['task_stats']['overdue'] }}개
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 이슈 통계 -->
|
|
<div class="bg-white rounded-lg shadow-sm p-6">
|
|
<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['issue_stats']['open'] + $summary['issue_stats']['in_progress'] }}</p>
|
|
</div>
|
|
<div class="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center text-red-600">
|
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
<div class="mt-4 flex gap-2 text-xs">
|
|
<span class="px-2 py-1 bg-red-100 text-red-700 rounded">Open {{ $summary['issue_stats']['open'] }}</span>
|
|
<span class="px-2 py-1 bg-yellow-100 text-yellow-700 rounded">진행 {{ $summary['issue_stats']['in_progress'] }}</span>
|
|
<span class="px-2 py-1 bg-green-100 text-green-700 rounded">해결 {{ $summary['issue_stats']['resolved'] }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 활성 프로젝트 목록 -->
|
|
<div class="bg-white rounded-lg shadow-sm mb-8">
|
|
<div class="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
|
|
<h2 class="text-lg font-semibold text-gray-800 flex items-center gap-2">
|
|
<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
|
</svg>
|
|
활성 프로젝트
|
|
</h2>
|
|
<a href="{{ route('pm.projects.index') }}" class="text-blue-600 hover:text-blue-800 text-sm">전체보기 →</a>
|
|
</div>
|
|
<div class="divide-y divide-gray-200">
|
|
@forelse($summary['projects'] as $project)
|
|
<div class="p-6 hover:bg-gray-50">
|
|
<div class="flex justify-between items-start mb-4">
|
|
<div>
|
|
<a href="{{ route('pm.projects.show', $project->id) }}" class="text-lg font-medium text-gray-900 hover:text-blue-600">
|
|
{{ $project->name }}
|
|
</a>
|
|
@if($project->description)
|
|
<p class="text-sm text-gray-500 mt-1">{{ Str::limit($project->description, 100) }}</p>
|
|
@endif
|
|
</div>
|
|
<span class="px-3 py-1 text-xs rounded-full {{ $project->status_color }}">
|
|
{{ $project->status_label }}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- 진행률 바 -->
|
|
@php
|
|
$projTaskStats = $project->task_stats;
|
|
$projTaskTotal = $projTaskStats['total'] ?? 0;
|
|
$projTaskDone = $projTaskStats['done'] ?? 0;
|
|
$projTaskInProgress = $projTaskStats['in_progress'] ?? 0;
|
|
$projDonePct = $projTaskTotal > 0 ? round(($projTaskDone / $projTaskTotal) * 100) : 0;
|
|
$projInProgressPct = $projTaskTotal > 0 ? round((($projTaskDone + $projTaskInProgress) / $projTaskTotal) * 100) : 0;
|
|
@endphp
|
|
<div class="mb-4">
|
|
<div class="flex justify-between text-sm text-gray-600 mb-1">
|
|
<span>진행률</span>
|
|
<span>{{ $projInProgressPct }}%</span>
|
|
</div>
|
|
<div class="w-full bg-gray-200 rounded-full h-2 relative overflow-hidden">
|
|
<div class="absolute h-2 rounded-full bg-blue-400 transition-all duration-300" style="width: {{ $projInProgressPct }}%"></div>
|
|
<div class="absolute h-2 rounded-full bg-green-500 transition-all duration-300" style="width: {{ $projDonePct }}%"></div>
|
|
</div>
|
|
<div class="flex justify-between text-xs text-gray-400 mt-1">
|
|
<span>완료 {{ $projDonePct }}% + 진행 {{ $projInProgressPct - $projDonePct }}%</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 통계 -->
|
|
<div class="flex gap-6 text-sm">
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-gray-500">작업:</span>
|
|
<span class="font-medium">{{ $projTaskTotal }}개</span>
|
|
<div class="flex gap-1 text-xs">
|
|
<span class="px-1.5 py-0.5 bg-blue-100 text-blue-700 rounded">{{ $projTaskInProgress }}</span>
|
|
<span class="px-1.5 py-0.5 bg-green-100 text-green-700 rounded">{{ $projTaskDone }}</span>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-gray-500">이슈:</span>
|
|
@php
|
|
$issueStats = $project->issue_stats;
|
|
$openIssues = ($issueStats['open'] ?? 0) + ($issueStats['in_progress'] ?? 0);
|
|
@endphp
|
|
<span class="font-medium {{ $openIssues > 0 ? 'text-red-600' : 'text-gray-900' }}">
|
|
{{ $openIssues }}개 열림
|
|
</span>
|
|
<div class="flex gap-1 text-xs">
|
|
<span class="px-1.5 py-0.5 bg-red-100 text-red-700 rounded">{{ $issueStats['open'] ?? 0 }}</span>
|
|
<span class="px-1.5 py-0.5 bg-yellow-100 text-yellow-700 rounded">{{ $issueStats['in_progress'] ?? 0 }}</span>
|
|
<span class="px-1.5 py-0.5 bg-green-100 text-green-700 rounded">{{ $issueStats['resolved'] ?? 0 }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@empty
|
|
<div class="p-12 text-center text-gray-500">
|
|
<p>활성 프로젝트가 없습니다.</p>
|
|
<a href="{{ route('pm.projects.create') }}" class="text-blue-600 hover:text-blue-800 mt-2 inline-block">
|
|
+ 첫 프로젝트 만들기
|
|
</a>
|
|
</div>
|
|
@endforelse
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 하단 2컬럼 레이아웃 -->
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
|
<!-- 마감 임박/지연 작업 -->
|
|
<div class="bg-white rounded-lg shadow-sm">
|
|
<div class="px-6 py-4 border-b border-gray-200">
|
|
<h2 class="text-lg font-semibold text-gray-800 flex items-center gap-2">
|
|
<svg class="w-5 h-5 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
긴급 작업
|
|
</h2>
|
|
</div>
|
|
<div id="urgent-tasks"
|
|
hx-get="/api/admin/pm/tasks/urgent"
|
|
hx-trigger="load"
|
|
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
|
|
class="p-6">
|
|
<div class="flex justify-center items-center p-6">
|
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 최근 이슈 -->
|
|
<div class="bg-white rounded-lg shadow-sm">
|
|
<div class="px-6 py-4 border-b border-gray-200">
|
|
<h2 class="text-lg font-semibold text-gray-800 flex items-center gap-2">
|
|
<svg class="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
|
</svg>
|
|
열린 이슈
|
|
</h2>
|
|
</div>
|
|
<div id="open-issues"
|
|
hx-get="/api/admin/pm/issues/open?limit=5"
|
|
hx-trigger="load"
|
|
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
|
|
class="p-6">
|
|
<div class="flex justify-center items-center p-6">
|
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endsection
|
|
|
|
@push('scripts')
|
|
<script>
|
|
// HTMX 응답 처리
|
|
document.body.addEventListener('htmx:afterSwap', function(event) {
|
|
const targetId = event.detail.target.id;
|
|
|
|
if (targetId === 'urgent-tasks' || targetId === 'open-issues') {
|
|
try {
|
|
const response = JSON.parse(event.detail.xhr.response);
|
|
if (response.success && response.data) {
|
|
if (targetId === 'urgent-tasks') {
|
|
renderUrgentTasks(event.detail.target, response.data);
|
|
} else {
|
|
renderOpenIssues(event.detail.target, response.data);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// HTML 응답인 경우 그대로 사용
|
|
}
|
|
}
|
|
});
|
|
|
|
// SVG 아이콘 정의
|
|
const icons = {
|
|
exclamation: '<svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>',
|
|
clock: '<svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>',
|
|
check: '<svg class="w-4 h-4 inline-block mr-1" 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>',
|
|
bug: '<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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>',
|
|
sparkle: '<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="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" /></svg>',
|
|
lightbulb: '<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="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" /></svg>',
|
|
bookmark: '<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="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" /></svg>'
|
|
};
|
|
|
|
function renderUrgentTasks(container, data) {
|
|
let html = '';
|
|
|
|
if (data.overdue && data.overdue.length > 0) {
|
|
html += `<div class="mb-4"><h3 class="text-sm font-semibold text-red-600 mb-2 flex items-center">${icons.exclamation}지연된 작업</h3>`;
|
|
html += '<div class="space-y-2">';
|
|
data.overdue.forEach(task => {
|
|
html += `<div class="p-3 bg-red-50 rounded-lg border border-red-100">
|
|
<div class="flex justify-between items-start">
|
|
<div>
|
|
<p class="font-medium text-gray-900">${task.title}</p>
|
|
<p class="text-xs text-gray-500">${task.project?.name || ''}</p>
|
|
</div>
|
|
<span class="text-xs text-red-600 font-medium">${task.d_day_text}</span>
|
|
</div>
|
|
</div>`;
|
|
});
|
|
html += '</div></div>';
|
|
}
|
|
|
|
if (data.due_soon && data.due_soon.length > 0) {
|
|
html += `<div><h3 class="text-sm font-semibold text-orange-600 mb-2 flex items-center">${icons.clock}곧 마감</h3>`;
|
|
html += '<div class="space-y-2">';
|
|
data.due_soon.forEach(task => {
|
|
html += `<div class="p-3 bg-orange-50 rounded-lg border border-orange-100">
|
|
<div class="flex justify-between items-start">
|
|
<div>
|
|
<p class="font-medium text-gray-900">${task.title}</p>
|
|
<p class="text-xs text-gray-500">${task.project?.name || ''}</p>
|
|
</div>
|
|
<span class="text-xs text-orange-600 font-medium">${task.d_day_text}</span>
|
|
</div>
|
|
</div>`;
|
|
});
|
|
html += '</div></div>';
|
|
}
|
|
|
|
if (!html) {
|
|
html = `<p class="text-gray-500 text-center py-4 flex items-center justify-center">${icons.check}긴급 작업이 없습니다</p>`;
|
|
}
|
|
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
function renderOpenIssues(container, issues) {
|
|
if (!issues || issues.length === 0) {
|
|
container.innerHTML = `<p class="text-gray-500 text-center py-4 flex items-center justify-center">${icons.check}열린 이슈가 없습니다</p>`;
|
|
return;
|
|
}
|
|
|
|
const typeIcons = {
|
|
bug: icons.bug,
|
|
feature: icons.sparkle,
|
|
improvement: icons.lightbulb
|
|
};
|
|
const statusColors = {
|
|
open: 'bg-red-100 text-red-700',
|
|
in_progress: 'bg-yellow-100 text-yellow-700'
|
|
};
|
|
|
|
let html = '<div class="space-y-3">';
|
|
issues.forEach(issue => {
|
|
html += `<div class="p-3 border border-gray-200 rounded-lg hover:bg-gray-50">
|
|
<div class="flex justify-between items-start">
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-gray-600">${typeIcons[issue.type] || icons.bookmark}</span>
|
|
<span class="font-medium text-gray-900">${issue.title}</span>
|
|
</div>
|
|
<span class="px-2 py-1 text-xs rounded-full ${statusColors[issue.status] || 'bg-gray-100 text-gray-700'}">
|
|
${issue.status_label || issue.status}
|
|
</span>
|
|
</div>
|
|
<p class="text-xs text-gray-500 mt-1">${issue.project?.name || ''}</p>
|
|
</div>`;
|
|
});
|
|
html += '</div>';
|
|
|
|
container.innerHTML = html;
|
|
}
|
|
</script>
|
|
@endpush |