diff --git a/app/Http/Controllers/Api/Admin/DailyLogController.php b/app/Http/Controllers/Api/Admin/DailyLogController.php index b2937048..16f3fbf8 100644 --- a/app/Http/Controllers/Api/Admin/DailyLogController.php +++ b/app/Http/Controllers/Api/Admin/DailyLogController.php @@ -178,6 +178,28 @@ public function addEntry(Request $request, int $logId): JsonResponse ]); } + /** + * 항목 수정 + */ + public function updateEntry(Request $request, int $entryId): JsonResponse + { + $validated = $request->validate([ + 'assignee_type' => 'sometimes|in:team,user', + 'assignee_id' => 'nullable|integer', + 'assignee_name' => 'sometimes|string|max:100', + 'content' => 'sometimes|string|max:2000', + 'status' => 'sometimes|in:todo,in_progress,done', + ]); + + $entry = $this->dailyLogService->updateEntry($entryId, $validated); + + return response()->json([ + 'success' => true, + 'message' => '항목이 수정되었습니다.', + 'data' => $entry, + ]); + } + /** * 항목 상태 변경 */ diff --git a/app/Models/Admin/AdminPmProject.php b/app/Models/Admin/AdminPmProject.php index bd9c9737..a57922a8 100644 --- a/app/Models/Admin/AdminPmProject.php +++ b/app/Models/Admin/AdminPmProject.php @@ -94,6 +94,25 @@ public function issues(): HasMany return $this->hasMany(AdminPmIssue::class, 'project_id'); } + /** + * 관계: 일일 로그 + */ + public function dailyLogs(): HasMany + { + return $this->hasMany(AdminPmDailyLog::class, 'project_id'); + } + + /** + * 오늘의 스크럼 항목 + */ + public function getTodayScrumAttribute(): ?AdminPmDailyLog + { + return $this->dailyLogs() + ->where('log_date', now()->format('Y-m-d')) + ->with('entries') + ->first(); + } + /** * 관계: 생성자 */ diff --git a/app/Services/ProjectManagement/DailyLogService.php b/app/Services/ProjectManagement/DailyLogService.php index 66ea27c1..c1c7453e 100644 --- a/app/Services/ProjectManagement/DailyLogService.php +++ b/app/Services/ProjectManagement/DailyLogService.php @@ -224,6 +224,17 @@ public function forceDeleteDailyLog(int $id): bool return $log->forceDelete(); } + /** + * 항목 수정 + */ + public function updateEntry(int $entryId, array $data): AdminPmDailyLogEntry + { + $entry = AdminPmDailyLogEntry::findOrFail($entryId); + $entry->update($data); + + return $entry->fresh(); + } + /** * 항목 상태 변경 */ diff --git a/app/Services/ProjectManagement/ProjectService.php b/app/Services/ProjectManagement/ProjectService.php index 52cfc9e2..55378d26 100644 --- a/app/Services/ProjectManagement/ProjectService.php +++ b/app/Services/ProjectManagement/ProjectService.php @@ -158,12 +158,16 @@ public function getProjectStats(): array */ public function getDashboardSummary(): array { + $today = now()->format('Y-m-d'); + $activeProjects = AdminPmProject::active() ->withCount(['tasks', 'issues']) ->with(['tasks' => function ($q) { $q->select('id', 'project_id', 'status'); }, 'issues' => function ($q) { $q->whereIn('status', [AdminPmIssue::STATUS_OPEN, AdminPmIssue::STATUS_IN_PROGRESS]); + }, 'dailyLogs' => function ($q) use ($today) { + $q->where('log_date', $today)->with('entries'); }]) ->get(); diff --git a/resources/views/project-management/index.blade.php b/resources/views/project-management/index.blade.php index 872ba24a..45b8754e 100644 --- a/resources/views/project-management/index.blade.php +++ b/resources/views/project-management/index.blade.php @@ -187,6 +187,204 @@ {{ $issueStats['resolved'] ?? 0 }} + @php + $todayScrum = $project->dailyLogs->first(); + $scrumEntries = $todayScrum?->entries ?? collect([]); + @endphp +
+ 스크럼: + + {{ $scrumEntries->count() }}개 + +
+ + + + @php + $todoEntries = $scrumEntries->where('status', 'todo'); + $inProgressEntries = $scrumEntries->where('status', 'in_progress'); + $doneEntries = $scrumEntries->where('status', 'done'); + @endphp +
+
+

+ + + + 오늘의 활동 + @if($scrumEntries->count() > 0) + ({{ $scrumEntries->count() }}) + @endif +

+
+ + @if($todayScrum) + + 더보기 → + + @endif +
+
+ + @if($scrumEntries->count() > 0) + +
+ +
+
+ + 예정 + ({{ $todoEntries->count() }}) +
+
+ @forelse($todoEntries->groupBy('assignee_name') 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(); + @endphp +
+

{{ $assigneeName }}

+
+ @foreach($groupedEntries as $entry) +
+ {{ Str::limit($entry->content, 25) }} +
+ + + +
+
+ @endforeach +
+
+ @empty +

-

+ @endforelse +
+
+ + +
+
+ + 진행중 + ({{ $inProgressEntries->count() }}) +
+
+ @forelse($inProgressEntries->groupBy('assignee_name') 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(); + @endphp +
+

{{ $assigneeName }}

+
+ @foreach($groupedEntries as $entry) +
+ {{ Str::limit($entry->content, 25) }} +
+ + + +
+
+ @endforeach +
+
+ @empty +

-

+ @endforelse +
+
+ + +
+
+ + + + 완료 + ({{ $doneEntries->count() }}) +
+
+ @forelse($doneEntries->groupBy('assignee_name') 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(); + @endphp +
+

{{ $assigneeName }}

+
+ @foreach($groupedEntries as $entry) +
+ {{ Str::limit($entry->content, 25) }} +
+ + + +
+
+ @endforeach +
+
+ @empty +

-

+ @endforelse +
+
+
+ @else + +
+

오늘의 활동이 없습니다

+ +
+ @endif
@empty @@ -244,6 +442,180 @@ class="p-6"> + + + + + + @endsection @push('scripts') @@ -358,5 +730,375 @@ function renderOpenIssues(container, issues) { container.innerHTML = html; } + + // 빠른 스크럼 모달 관련 함수 + function openQuickScrumModal(projectId, projectName, logId) { + document.getElementById('quickScrumProjectId').value = projectId; + document.getElementById('quickScrumProjectName').textContent = projectName; + document.getElementById('quickScrumLogId').value = logId || ''; + document.getElementById('quickScrumModal').classList.remove('hidden'); + + // 폼 초기화 + document.getElementById('quickScrumAssignee').value = ''; + document.getElementById('quickScrumContent').value = ''; + document.querySelector('input[name="status"][value="todo"]').checked = true; + + // 담당자 입력창에 포커스 + setTimeout(() => { + document.getElementById('quickScrumAssignee').focus(); + }, 100); + } + + function closeQuickScrumModal() { + document.getElementById('quickScrumModal').classList.add('hidden'); + } + + async function submitQuickScrum(event) { + event.preventDefault(); + + const form = document.getElementById('quickScrumForm'); + const submitBtn = document.getElementById('quickScrumSubmitBtn'); + const projectId = document.getElementById('quickScrumProjectId').value; + const logId = document.getElementById('quickScrumLogId').value; + + const formData = { + project_id: parseInt(projectId), + log_date: new Date().toISOString().split('T')[0], + assignee_name: document.getElementById('quickScrumAssignee').value, + content: document.getElementById('quickScrumContent').value, + status: document.querySelector('input[name="status"]:checked').value, + assignee_type: 'user' + }; + + submitBtn.disabled = true; + submitBtn.textContent = '추가 중...'; + + try { + let response; + + if (logId) { + // 기존 로그에 항목 추가 + response = await fetch(`/api/admin/pm/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(formData) + }); + } else { + // 새 로그 생성과 함께 항목 추가 + response = await fetch('/api/admin/pm/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: formData.project_id, + log_date: formData.log_date, + entries: [{ + assignee_type: formData.assignee_type, + assignee_name: formData.assignee_name, + content: formData.content, + status: formData.status + }] + }) + }); + } + + const data = await response.json(); + + if (response.ok && data.success) { + closeQuickScrumModal(); + // 페이지 새로고침으로 데이터 반영 + window.location.reload(); + } else { + alert(data.message || '스크럼 항목 추가에 실패했습니다.'); + } + } catch (error) { + console.error('Error:', error); + alert('오류가 발생했습니다. 다시 시도해주세요.'); + } finally { + submitBtn.disabled = false; + submitBtn.textContent = '추가'; + } + } + + // ESC 키로 모달 닫기 + document.addEventListener('keydown', function(e) { + if (e.key === 'Escape') { + if (!document.getElementById('quickScrumModal').classList.contains('hidden')) { + closeQuickScrumModal(); + } + if (!document.getElementById('editEntryModal').classList.contains('hidden')) { + closeEditEntryModal(); + } + } + }); + + // 스크럼 항목 수정 모달 관련 함수 (담당자별 그룹 편집) + let currentEditEntries = []; // 현재 편집 중인 항목들 + let entriesToDelete = []; // 삭제할 항목 ID 목록 + + 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('editEntryAssignee').value = assigneeName; + + // 항목 컨테이너 초기화 및 렌더링 + 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 = ` +
+ +
+
+ + + + +
+ + `; + + 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 container = document.getElementById('editEntriesContainer'); + const rows = container.querySelectorAll('.entry-row'); + + submitBtn.disabled = true; + submitBtn.textContent = '저장 중...'; + + try { + const promises = []; + + // 삭제 처리 + for (const entryId of entriesToDelete) { + promises.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, index) => { + 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) { + // 기존 항목 수정 + promises.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 { + // 새 항목 - 기존 항목의 daily_log_id를 사용 + const existingEntry = currentEditEntries.find(e => e.id); + if (existingEntry && existingEntry.daily_log_id) { + promises.push( + fetch(`/api/admin/pm/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(data) + }) + ); + } + } + }); + + await Promise.all(promises); + closeEditEntryModal(); + window.location.reload(); + + } catch (error) { + console.error('Error:', error); + alert('오류가 발생했습니다. 다시 시도해주세요.'); + } 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('오류가 발생했습니다.'); + } + } @endpush \ No newline at end of file diff --git a/routes/api.php b/routes/api.php index 1ec682d1..4105d12c 100644 --- a/routes/api.php +++ b/routes/api.php @@ -359,6 +359,7 @@ // 항목 개별 API (로그 ID 없이 직접 접근) Route::prefix('daily-logs/entries')->name('daily-logs.entries.')->group(function () { + Route::put('/{entryId}', [DailyLogController::class, 'updateEntry'])->name('update'); Route::put('/{entryId}/status', [DailyLogController::class, 'updateEntryStatus'])->name('updateStatus'); Route::delete('/{entryId}', [DailyLogController::class, 'deleteEntry'])->name('delete'); });