Files
sam-manage/resources/views/project-management/index.blade.php
hskwon 1930c2ef9f feat(daily-logs, pm): 스크럼 UI/UX 개선
Daily Logs 페이지:
- 미완료 항목 상태 변경 시 카드 유지 (done만 제거)
- 카드 정렬을 날짜 오래된 순으로 변경
- 요약 내용 nl2br 적용 및 접힘 시 2줄 제한
- 아코디언 항목 담당자별 그룹핑으로 통합

Project Management 페이지:
- 오늘의 활동을 칸반(3열) → 담당자 카드 스타일로 변경
- 완료 항목도 함께 표시 (취소선, 초록 배지)
- 미완료/완료 건수 헤더에 표시
2025-12-04 22:25:50 +09:00

930 lines
49 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>
@php
$todayScrum = $project->dailyLogs->first();
$scrumEntries = $todayScrum?->entries ?? collect([]);
@endphp
<div class="flex items-center gap-2">
<span class="text-gray-500">스크럼:</span>
<span class="font-medium {{ $scrumEntries->count() > 0 ? 'text-indigo-600' : 'text-gray-400' }}">
{{ $scrumEntries->count() }}
</span>
</div>
</div>
<!-- 오늘의 스크럼 (담당자별 카드) -->
@php
$allGrouped = $scrumEntries->groupBy('assignee_name');
$pendingCount = $scrumEntries->whereIn('status', ['todo', 'in_progress'])->count();
$doneCount = $scrumEntries->where('status', 'done')->count();
@endphp
<div class="mt-4 pt-4 border-t border-gray-100">
<div class="flex items-center justify-between mb-3">
<h4 class="text-sm font-medium text-gray-700 flex items-center gap-2">
<svg class="w-4 h-4 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
오늘의 활동
@if($pendingCount > 0)
<span class="text-xs px-1.5 py-0.5 rounded-full bg-orange-100 text-orange-700">{{ $pendingCount }}</span>
@endif
@if($doneCount > 0)
<span class="text-xs px-1.5 py-0.5 rounded-full bg-green-100 text-green-700">{{ $doneCount }}</span>
@endif
</h4>
<div class="flex items-center gap-2">
<button type="button"
onclick="openAddEntryModal({{ $project->id }}, '{{ $project->name }}', {{ $todayScrum?->id ?? 'null' }})"
class="text-xs text-indigo-600 hover:text-indigo-800 flex items-center gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
추가
</button>
@if($todayScrum)
<a href="{{ route('daily-logs.index', ['highlight' => $todayScrum->id]) }}"
class="text-xs text-gray-500 hover:text-gray-700">
더보기
</a>
@endif
</div>
</div>
@if($scrumEntries->count() > 0)
<!-- 담당자별 카드 (가로 스크롤) -->
<div class="flex gap-3 overflow-x-auto pb-2 -mx-1 px-1">
@foreach($allGrouped as $assigneeName => $groupedEntries)
@php
$entriesJson = $groupedEntries->map(fn($e) => [
'id' => $e->id,
'daily_log_id' => $e->daily_log_id,
'content' => $e->content,
'status' => $e->status
])->values()->toJson();
$todoCount = $groupedEntries->where('status', 'todo')->count();
$inProgressCount = $groupedEntries->where('status', 'in_progress')->count();
$assigneeDoneCount = $groupedEntries->where('status', 'done')->count();
@endphp
<div class="bg-white rounded-lg p-3 border border-gray-200 hover:border-blue-300 transition-all shrink-0" style="width: 280px;">
<!-- 담당자 헤더 -->
<div class="flex items-center justify-between mb-2 pb-2 border-b border-gray-100 cursor-pointer hover:text-indigo-600"
onclick='openEditEntryModal(@json($entriesJson), "{{ addslashes($assigneeName) }}", {{ $project->id }}, "{{ addslashes($project->name) }}")'>
<span class="text-sm font-semibold text-gray-900">{{ $assigneeName }}</span>
<div class="flex items-center gap-1">
@if($todoCount > 0)
<span class="px-1.5 py-0.5 text-[10px] rounded bg-gray-100 text-gray-600">{{ $todoCount }}</span>
@endif
@if($inProgressCount > 0)
<span class="px-1.5 py-0.5 text-[10px] rounded bg-yellow-100 text-yellow-700">{{ $inProgressCount }}</span>
@endif
@if($assigneeDoneCount > 0)
<span class="px-1.5 py-0.5 text-[10px] rounded bg-green-100 text-green-700">{{ $assigneeDoneCount }}</span>
@endif
</div>
</div>
<!-- 항목 목록 -->
<div class="space-y-1.5 max-h-40 overflow-y-auto">
@foreach($groupedEntries->sortBy(fn($e) => $e->status === 'done' ? 1 : 0) as $entry)
<div class="group" data-entry-id="{{ $entry->id }}">
<div class="flex items-center gap-2">
<!-- 상태 뱃지 -->
@if($entry->status === 'done')
<span class="px-1.5 py-0.5 text-[10px] rounded shrink-0 bg-green-100 text-green-700">완료</span>
@elseif($entry->status === 'in_progress')
<span class="px-1.5 py-0.5 text-[10px] rounded shrink-0 bg-yellow-100 text-yellow-700">진행</span>
@else
<span class="px-1.5 py-0.5 text-[10px] rounded shrink-0 bg-gray-100 text-gray-600">예정</span>
@endif
<!-- 내용 -->
<span class="flex-1 text-xs truncate {{ $entry->status === 'done' ? 'text-gray-400 line-through' : 'text-gray-700' }}" title="{{ $entry->content }}">{{ $entry->content }}</span>
<!-- 상태변경 버튼 -->
<div class="flex gap-0.5 opacity-30 group-hover:opacity-100 transition-opacity shrink-0">
@if($entry->status !== 'todo')
<button onclick="event.stopPropagation(); changeEntryStatus({{ $entry->id }}, 'todo')"
class="p-0.5 text-gray-400 hover:bg-gray-100 rounded" title="예정">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" stroke-width="2"/></svg>
</button>
@endif
@if($entry->status !== 'in_progress')
<button onclick="event.stopPropagation(); changeEntryStatus({{ $entry->id }}, 'in_progress')"
class="p-0.5 text-yellow-500 hover:bg-yellow-50 rounded" title="진행중">
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/></svg>
</button>
@endif
@if($entry->status !== 'done')
<button onclick="event.stopPropagation(); changeEntryStatus({{ $entry->id }}, 'done')"
class="p-0.5 text-green-500 hover:bg-green-50 rounded" title="완료">
<svg class="w-3.5 h-3.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>
</button>
@endif
</div>
</div>
</div>
@endforeach
</div>
</div>
@endforeach
</div>
@else
<!-- 활동 없음 -->
<div class="text-center py-4 bg-gray-50 rounded-lg">
<p class="text-sm text-gray-400">오늘의 활동이 없습니다</p>
<button type="button"
onclick="openAddEntryModal({{ $project->id }}, '{{ $project->name }}', null)"
class="mt-2 text-xs text-indigo-600 hover:text-indigo-800">
+ 활동 추가하기
</button>
</div>
@endif
</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>
<!-- 스크럼 항목 수정 모달 (담당자별 그룹 편집) -->
<div id="editEntryModal" class="fixed inset-0 z-50 hidden overflow-y-auto" aria-labelledby="edit-modal-title" role="dialog" aria-modal="true">
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" onclick="closeEditEntryModal()"></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen">&#8203;</span>
<div class="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-2xl sm:w-full sm:p-6">
<div>
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-medium text-gray-900 flex items-center gap-2" id="edit-modal-title">
<svg id="editModalIcon" class="w-5 h-5 text-indigo-500" 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>
<span id="editModalTitleText">스크럼 항목 수정</span>
</h3>
<button type="button" onclick="closeEditEntryModal()" class="text-gray-400 hover:text-gray-500">
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="mb-4 p-3 bg-gray-50 rounded-lg">
<span class="text-xs text-gray-500">프로젝트</span>
<p id="editEntryProjectName" class="font-medium text-gray-900"></p>
</div>
<form id="editEntryForm" onsubmit="submitEditEntries(event)">
<input type="hidden" id="editEntryProjectId" name="project_id">
<input type="hidden" id="editEntryLogId" name="log_id">
<input type="hidden" id="editEntryIsAddMode" value="false">
<!-- 담당자 (공통) -->
<div class="mb-4">
<label for="editEntryAssignee" class="block text-sm font-medium text-gray-700 mb-1">담당자</label>
<input type="text" id="editEntryAssignee" name="assignee_name" required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
</div>
<!-- 업무 항목 목록 -->
<div class="mb-4">
<div class="flex items-center justify-between mb-2">
<label class="block text-sm font-medium text-gray-700">업무 내용</label>
<button type="button" onclick="addNewEntryRow()" class="text-xs text-indigo-600 hover:text-indigo-800 flex items-center gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
항목 추가
</button>
</div>
<div id="editEntriesContainer" class="space-y-2">
<!-- 동적으로 항목 행들이 추가됨 -->
</div>
</div>
<div class="flex gap-3 pt-4 border-t border-gray-200">
<button type="button" onclick="closeEditEntryModal()"
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50">
취소
</button>
<button type="submit" id="editEntrySubmitBtn"
class="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50">
저장
</button>
</div>
</form>
</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;
}
// ESC 키로 모달 닫기
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
if (!document.getElementById('editEntryModal').classList.contains('hidden')) {
closeEditEntryModal();
}
}
});
// 스크럼 항목 추가/수정 모달 관련 함수 (담당자별 그룹 편집)
let currentEditEntries = []; // 현재 편집 중인 항목들
let entriesToDelete = []; // 삭제할 항목 ID 목록
// 스크럼 항목 추가 모달 열기 (새 항목)
function openAddEntryModal(projectId, projectName, logId) {
currentEditEntries = [];
entriesToDelete = [];
document.getElementById('editEntryProjectId').value = projectId;
document.getElementById('editEntryProjectName').textContent = projectName;
document.getElementById('editEntryLogId').value = logId || '';
document.getElementById('editEntryIsAddMode').value = 'true';
document.getElementById('editEntryAssignee').value = '';
// 모달 제목 변경 (추가 모드)
document.getElementById('editModalTitleText').textContent = '스크럼 항목 추가';
const icon = document.getElementById('editModalIcon');
icon.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />';
// 빈 항목 1개로 시작
renderEntryRows([{ id: '', content: '', status: 'todo' }]);
document.getElementById('editEntryModal').classList.remove('hidden');
// 담당자 입력창에 포커스
setTimeout(() => {
document.getElementById('editEntryAssignee').focus();
}, 100);
}
// 스크럼 항목 수정 모달 열기 (기존 항목)
function openEditEntryModal(entriesJson, assigneeName, projectId, projectName) {
const entries = JSON.parse(entriesJson);
currentEditEntries = entries;
entriesToDelete = [];
document.getElementById('editEntryProjectId').value = projectId;
document.getElementById('editEntryProjectName').textContent = projectName;
document.getElementById('editEntryLogId').value = entries[0]?.daily_log_id || '';
document.getElementById('editEntryIsAddMode').value = 'false';
document.getElementById('editEntryAssignee').value = assigneeName;
// 모달 제목 변경 (수정 모드)
document.getElementById('editModalTitleText').textContent = '스크럼 항목 수정';
const icon = document.getElementById('editModalIcon');
icon.innerHTML = '<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" />';
// 항목 컨테이너 초기화 및 렌더링
renderEntryRows(entries);
document.getElementById('editEntryModal').classList.remove('hidden');
}
function renderEntryRows(entries) {
const container = document.getElementById('editEntriesContainer');
container.innerHTML = '';
entries.forEach((entry, index) => {
container.appendChild(createEntryRow(entry, index));
});
}
function createEntryRow(entry, index) {
const row = document.createElement('div');
row.className = 'entry-row flex items-start gap-2 p-2 bg-gray-50 rounded-lg';
row.dataset.entryId = entry.id || '';
row.dataset.index = index;
row.innerHTML = `
<div class="flex-1">
<textarea name="content_${index}" rows="1" required
class="w-full px-2 py-1.5 text-sm border border-gray-300 rounded focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 resize-none"
placeholder="업무 내용">${entry.content || ''}</textarea>
</div>
<div class="flex items-center gap-1 shrink-0">
<button type="button" onclick="setEntryStatus(this, 'todo')"
class="status-btn p-1.5 rounded ${entry.status === 'todo' ? 'bg-gray-200' : 'hover:bg-gray-100'}"
data-status="todo" title="예정">
<span class="w-2.5 h-2.5 bg-gray-400 rounded-full block"></span>
</button>
<button type="button" onclick="setEntryStatus(this, 'in_progress')"
class="status-btn p-1.5 rounded ${entry.status === 'in_progress' ? 'bg-blue-100' : 'hover:bg-gray-100'}"
data-status="in_progress" title="진행중">
<span class="w-2.5 h-2.5 bg-blue-500 rounded-full block"></span>
</button>
<button type="button" onclick="setEntryStatus(this, 'done')"
class="status-btn p-1.5 rounded ${entry.status === 'done' ? 'bg-green-100' : 'hover:bg-gray-100'}"
data-status="done" title="완료">
<svg class="w-3 h-3 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>
</button>
<button type="button" onclick="removeEntryRow(this)"
class="p-1.5 text-red-400 hover:text-red-600 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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<input type="hidden" name="status_${index}" value="${entry.status || 'todo'}">
`;
return row;
}
function setEntryStatus(btn, status) {
const row = btn.closest('.entry-row');
const statusInput = row.querySelector('input[type="hidden"]');
statusInput.value = status;
// 버튼 스타일 업데이트
row.querySelectorAll('.status-btn').forEach(b => {
b.classList.remove('bg-gray-200', 'bg-blue-100', 'bg-green-100');
b.classList.add('hover:bg-gray-100');
});
btn.classList.remove('hover:bg-gray-100');
if (status === 'todo') btn.classList.add('bg-gray-200');
else if (status === 'in_progress') btn.classList.add('bg-blue-100');
else if (status === 'done') btn.classList.add('bg-green-100');
}
function addNewEntryRow() {
const container = document.getElementById('editEntriesContainer');
const index = container.children.length;
container.appendChild(createEntryRow({ id: '', content: '', status: 'todo' }, index));
// 새로 추가된 행의 textarea에 포커스
const newRow = container.lastChild;
newRow.querySelector('textarea').focus();
}
function removeEntryRow(btn) {
const row = btn.closest('.entry-row');
const entryId = row.dataset.entryId;
// 기존 항목이면 삭제 목록에 추가
if (entryId) {
entriesToDelete.push(parseInt(entryId));
}
row.remove();
// 마지막 항목은 삭제 불가 (최소 1개 유지)
const container = document.getElementById('editEntriesContainer');
if (container.children.length === 0) {
addNewEntryRow();
}
}
function closeEditEntryModal() {
document.getElementById('editEntryModal').classList.add('hidden');
currentEditEntries = [];
entriesToDelete = [];
}
async function submitEditEntries(event) {
event.preventDefault();
const submitBtn = document.getElementById('editEntrySubmitBtn');
const assigneeName = document.getElementById('editEntryAssignee').value;
const projectId = document.getElementById('editEntryProjectId').value;
const logId = document.getElementById('editEntryLogId').value;
const isAddMode = document.getElementById('editEntryIsAddMode').value === 'true';
const container = document.getElementById('editEntriesContainer');
const rows = container.querySelectorAll('.entry-row');
submitBtn.disabled = true;
submitBtn.textContent = '저장 중...';
try {
// 항목 데이터 수집
const newEntries = [];
const updatePromises = [];
// 삭제 처리 (수정 모드에서만)
if (!isAddMode) {
for (const entryId of entriesToDelete) {
updatePromises.push(
fetch(`/api/admin/daily-logs/entries/${entryId}`, {
method: 'DELETE',
headers: {
'Accept': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
}
})
);
}
}
// 항목 처리
rows.forEach((row) => {
const entryId = row.dataset.entryId;
const content = row.querySelector('textarea').value.trim();
const status = row.querySelector('input[type="hidden"]').value;
if (!content) return; // 빈 내용은 스킵
const data = {
assignee_name: assigneeName,
assignee_type: 'user',
content: content,
status: status
};
if (entryId) {
// 기존 항목 수정
updatePromises.push(
fetch(`/api/admin/daily-logs/entries/${entryId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify(data)
})
);
} else {
// 새 항목
newEntries.push(data);
}
});
// 새 항목 처리
if (newEntries.length > 0) {
if (logId) {
// 기존 로그에 항목 추가
for (const entry of newEntries) {
updatePromises.push(
fetch(`/api/admin/daily-logs/${logId}/entries`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify(entry)
})
);
}
} else if (isAddMode) {
// 새 로그 생성과 함께 항목 추가
const response = await fetch('/api/admin/daily-logs', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({
project_id: parseInt(projectId),
log_date: new Date().toISOString().split('T')[0],
entries: newEntries
})
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.message || '스크럼 항목 추가에 실패했습니다.');
}
} else {
// 수정 모드에서 새 항목 추가 - 기존 항목의 daily_log_id 사용
const existingEntry = currentEditEntries.find(e => e.id);
if (existingEntry && existingEntry.daily_log_id) {
for (const entry of newEntries) {
updatePromises.push(
fetch(`/api/admin/daily-logs/${existingEntry.daily_log_id}/entries`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify(entry)
})
);
}
}
}
}
await Promise.all(updatePromises);
closeEditEntryModal();
window.location.reload();
} catch (error) {
console.error('Error:', error);
alert(error.message || '오류가 발생했습니다. 다시 시도해주세요.');
} finally {
submitBtn.disabled = false;
submitBtn.textContent = '저장';
}
}
// 인라인 상태 변경 (카드에서 바로 상태 변경)
async function changeEntryStatus(entryId, status) {
try {
const response = await fetch(`/api/admin/daily-logs/entries/${entryId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({ status })
});
const data = await response.json();
if (response.ok && data.success) {
window.location.reload();
} else {
alert(data.message || '상태 변경에 실패했습니다.');
}
} catch (error) {
console.error('Error:', error);
alert('오류가 발생했습니다.');
}
}
// 빠른 삭제 (모달 없이 바로 삭제)
async function quickDeleteEntry(entryId) {
if (!confirm('이 항목을 삭제하시겠습니까?')) {
return;
}
try {
const response = await fetch(`/api/admin/daily-logs/entries/${entryId}`, {
method: 'DELETE',
headers: {
'Accept': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
}
});
const data = await response.json();
if (response.ok && data.success) {
window.location.reload();
} else {
alert(data.message || '항목 삭제에 실패했습니다.');
}
} catch (error) {
console.error('Error:', error);
alert('오류가 발생했습니다.');
}
}
</script>
@endpush