Files
sam-manage/resources/views/daily-logs/index.blade.php
hskwon c91dbe7c91 feat(daily-logs): 항목 추가 시 prompt 대신 모달 사용 및 datalist 자동완성
- prompt() 제거하고 모달에서 담당자 입력 처리
- datalist로 본사 사용자/부서 자동완성 지원
- 팀(부서) 먼저 표시되도록 순서 조정
- openQuickAddModal 함수 추가로 아코디언 항목 추가 연동
2025-12-04 23:13:17 +09:00

1226 lines
57 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

@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">📅 일일 스크럼</h1>
<div class="flex gap-2">
<a href="{{ route('daily-logs.today') }}" class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg transition">
오늘 작성
</a>
<button type="button"
onclick="openCreateModal()"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition">
+ 로그
</button>
</div>
</div>
<!-- 주간 타임라인 -->
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
<h2 class="text-sm font-medium text-gray-500 mb-3">최근 7</h2>
<div class="grid grid-cols-7 gap-2">
@foreach($weeklyTimeline as $index => $day)
<div class="relative group">
<button type="button"
onclick="{{ $day['log'] ? 'scrollToCard(' . $day['log']['id'] . ')' : 'openCreateModalWithDate(\'' . $day['date'] . '\')' }}"
data-log-id="{{ $day['log']['id'] ?? '' }}"
data-date="{{ $day['date'] }}"
class="day-card w-full text-left p-3 rounded-lg border-2 transition-all hover:shadow-md
{{ $day['is_today'] ? 'border-blue-500 bg-blue-50' : 'border-gray-200' }}
{{ $day['is_weekend'] && !$day['is_today'] ? 'bg-gray-50' : '' }}
{{ $day['log'] ? 'cursor-pointer' : 'cursor-pointer hover:border-blue-300' }}">
<!-- 요일 -->
<div class="text-xs font-medium {{ $day['is_weekend'] ? 'text-red-500' : 'text-gray-500' }} {{ $day['is_today'] ? 'text-blue-600' : '' }}">
{{ $day['day_name'] }}
</div>
<!-- 날짜 -->
<div class="text-lg font-bold {{ $day['is_today'] ? 'text-blue-600' : 'text-gray-800' }}">
{{ $day['day_number'] }}
</div>
@if($day['log'])
<!-- 로그 있음: 진행률 표시 -->
<div class="mt-2">
<div class="w-full bg-gray-200 rounded-full h-1.5">
<div class="bg-green-500 h-1.5 rounded-full transition-all" style="width: {{ $day['log']['completion_rate'] }}%"></div>
</div>
<div class="flex justify-between items-center mt-1">
<span class="text-xs text-gray-500">{{ $day['log']['entry_stats']['total'] }}</span>
<span class="text-xs font-medium {{ $day['log']['completion_rate'] >= 100 ? 'text-green-600' : 'text-blue-600' }}">
{{ $day['log']['completion_rate'] }}%
</span>
</div>
</div>
@else
<!-- 로그 없음 -->
<div class="mt-2 text-center">
<span class="text-xs text-gray-400">미작성</span>
</div>
@endif
<!-- 오늘 표시 -->
@if($day['is_today'])
<div class="absolute -top-1 -right-1 w-3 h-3 bg-blue-500 rounded-full animate-pulse"></div>
@endif
</button>
<!-- 툴팁 (호버 표시) -->
@if($day['log'] && $day['log']['entry_stats']['total'] > 0)
<div class="hidden group-hover:block absolute z-10 left-1/2 -translate-x-1/2 top-full mt-2 w-36 bg-gray-800 text-white text-xs rounded-lg p-2 shadow-lg pointer-events-none">
<div class="flex justify-between mb-1">
<span>예정</span>
<span>{{ $day['log']['entry_stats']['todo'] }}</span>
</div>
<div class="flex justify-between mb-1">
<span>진행중</span>
<span>{{ $day['log']['entry_stats']['in_progress'] }}</span>
</div>
<div class="flex justify-between">
<span>완료</span>
<span>{{ $day['log']['entry_stats']['done'] }}</span>
</div>
<div class="absolute -top-1 left-1/2 -translate-x-1/2 w-2 h-2 bg-gray-800 rotate-45"></div>
</div>
@endif
</div>
@endforeach
</div>
</div>
<!-- 통계 카드 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div class="bg-white rounded-lg shadow-sm p-4">
<div class="text-sm text-gray-500">전체 로그</div>
<div class="text-2xl font-bold text-gray-800">{{ $stats['total_logs'] ?? 0 }}</div>
</div>
<div class="bg-white rounded-lg shadow-sm p-4">
<div class="text-sm text-gray-500">최근 7</div>
<div class="text-2xl font-bold text-blue-600">{{ $stats['recent_logs'] ?? 0 }}</div>
</div>
<div class="bg-white rounded-lg shadow-sm p-4">
<div class="text-sm text-gray-500">진행중 항목</div>
<div class="text-2xl font-bold text-yellow-600">{{ $stats['entries']['in_progress'] ?? 0 }}</div>
</div>
<div class="bg-white rounded-lg shadow-sm p-4">
<div class="text-sm text-gray-500">완료 항목</div>
<div class="text-2xl font-bold text-green-600">{{ $stats['entries']['done'] ?? 0 }}</div>
</div>
</div>
<!-- 미완료 항목 (예정/진행중) - 담당자별 그룹핑 -->
@if(count($pendingEntries) > 0)
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
<div class="flex items-center justify-between mb-3">
<h2 class="text-sm font-medium text-gray-500 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>
미완료 항목
</h2>
@php $totalPending = collect($pendingEntries)->sum('total_count'); @endphp
<span class="text-xs text-gray-400">{{ count($pendingEntries) }} / {{ $totalPending }}</span>
</div>
<div class="flex flex-wrap gap-4" id="pendingEntriesGrid">
@foreach($pendingEntries as $group)
@php
$entriesJson = collect($group['entries'])->map(fn($e) => [
'id' => $e['id'],
'content' => $e['content'],
'status' => $e['status'],
'daily_log_id' => $e['daily_log_id'] ?? null,
])->toJson();
@endphp
<div class="pending-assignee-card bg-white rounded-lg p-3 border border-gray-200 hover:border-blue-300 transition-all shrink-0"
style="width: 300px;"
data-assignee="{{ $group['assignee_name'] }}">
<!-- 담당자 이름 + 수정 버튼 -->
<div class="flex items-center justify-between mb-2 pb-2 border-b border-gray-100">
<div class="flex items-center gap-2">
<span class="text-sm font-semibold text-gray-900">{{ $group['assignee_name'] }}</span>
<span class="text-xs text-gray-400">({{ $group['total_count'] }})</span>
</div>
<button onclick='openPendingEditModal({!! htmlspecialchars($entriesJson, ENT_QUOTES) !!}, "{{ addslashes($group['assignee_name']) }}")'
class="p-1 text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 rounded transition-colors" 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="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>
</button>
</div>
<!-- 항목 목록 -->
<div class="space-y-1.5">
@foreach($group['entries'] as $entry)
<div class="group" data-entry-id="{{ $entry['id'] }}">
<div class="flex items-center gap-2">
<!-- 상태 + 날짜 -->
<span class="px-1.5 py-0.5 text-[10px] rounded shrink-0 {{ $entry['status'] === 'in_progress' ? 'bg-yellow-100 text-yellow-700' : 'bg-gray-100 text-gray-600' }}">
{{ $entry['status'] === 'in_progress' ? '진행' : '예정' }}
</span>
<span class="text-[10px] text-gray-400 shrink-0">{{ \Carbon\Carbon::parse($entry['log_date'])->format('m/d') }}</span>
<!-- 내용 (클릭하면 펼침) -->
<span class="pending-entry-text flex-1 text-sm text-gray-700 truncate cursor-pointer hover:text-blue-600"
onclick="togglePendingEntry(this)"
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(); updatePendingStatus({{ $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(); updatePendingStatus({{ $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
<button onclick="event.stopPropagation(); updatePendingStatus({{ $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>
</div>
</div>
<!-- 펼쳐지는 전체 내용 (전체 너비) -->
<div class="pending-entry-full hidden mt-1.5 text-sm text-gray-600 bg-gray-50 rounded p-2 whitespace-pre-wrap">{{ $entry['content'] }}</div>
</div>
@endforeach
</div>
</div>
@endforeach
</div>
</div>
@endif
<!-- 필터 영역 -->
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
<form id="filterForm" class="flex gap-4 flex-wrap">
<!-- 검색 -->
<div class="flex-1 min-w-[200px]">
<input type="text"
name="search"
placeholder="요약, 담당자, 내용으로 검색..."
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<!-- 프로젝트 필터 -->
<div class="w-48">
<select name="project_id" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">전체 프로젝트</option>
@foreach($projects as $project)
<option value="{{ $project->id }}">{{ $project->name }}</option>
@endforeach
</select>
</div>
<!-- 날짜 범위 -->
<div class="w-40">
<input type="date"
name="start_date"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<span class="self-center text-gray-500">~</span>
<div class="w-40">
<input type="date"
name="end_date"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<!-- 삭제된 항목 포함 -->
<div class="w-36">
<select name="trashed" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">활성만</option>
<option value="with">삭제 포함</option>
<option value="only">삭제만</option>
</select>
</div>
<!-- 검색 버튼 -->
<button type="submit" class="bg-gray-600 hover:bg-gray-700 text-white px-6 py-2 rounded-lg transition">
검색
</button>
</form>
</div>
<!-- 테이블 영역 (HTMX로 로드) -->
<div id="log-table"
hx-get="/api/admin/daily-logs"
hx-trigger="load, filterSubmit from:body"
hx-include="#filterForm"
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
class="bg-white rounded-lg shadow-sm overflow-hidden">
<!-- 로딩 스피너 -->
<div class="flex justify-center items-center p-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
</div>
<!-- 생성/수정 모달 -->
@include('daily-logs.partials.modal-form', [
'projects' => $projects,
'assigneeTypes' => $assigneeTypes,
'entryStatuses' => $entryStatuses,
'assignees' => $assignees
])
<!-- 미완료 항목 수정 모달 -->
<div id="pendingEditModal" class="fixed inset-0 z-50 hidden overflow-y-auto" aria-labelledby="pending-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="closePendingEditModal()"></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="pending-edit-modal-title">
<svg 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>미완료 항목 수정</span>
</h3>
<button type="button" onclick="closePendingEditModal()" 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">
<label class="text-xs text-gray-500 block mb-1">담당자</label>
<!-- 기존 항목 수정 : 텍스트 표시 -->
<p id="pendingEditAssigneeNameText" class="font-medium text-gray-900 hidden"></p>
<!-- 항목 추가 : 입력 필드 (datalist로 자동완성) -->
<input type="text" id="pendingEditAssigneeNameInput"
list="assigneeDatalist"
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 hidden"
placeholder="담당자 이름 입력 또는 선택" autocomplete="off" required>
<datalist id="assigneeDatalist">
@foreach($assignees['teams'] ?? [] as $team)
<option value="{{ $team['name'] }}"></option>
@endforeach
@foreach($assignees['users'] ?? [] as $user)
<option value="{{ $user['name'] }}"></option>
@endforeach
</datalist>
</div>
<form id="pendingEditForm" onsubmit="submitPendingEditEntries(event)">
<!-- 업무 항목 목록 -->
<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="addPendingEntryRow()" 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="pendingEditEntriesContainer" class="space-y-2">
<!-- 동적으로 항목 행들이 추가됨 -->
</div>
</div>
<div class="flex gap-3 pt-4 border-t border-gray-200">
<button type="button" onclick="closePendingEditModal()"
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="pendingEditSubmitBtn"
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 src="https://unpkg.com/htmx.org@1.9.10"></script>
<script>
// 담당자 데이터
const assignees = @json($assignees);
// HTML 이스케이프 및 줄바꿈 처리 헬퍼
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function nl2br(text) {
if (!text) return '';
return escapeHtml(text).replace(/\n/g, '<br>');
}
// ISO 날짜를 YYYY-MM-DD 형식으로 변환
function formatDateForInput(dateString) {
if (!dateString) return '';
// ISO 형식 또는 일반 날짜 문자열에서 YYYY-MM-DD 추출
return dateString.substring(0, 10);
}
// 폼 제출 시 HTMX 이벤트 트리거
document.getElementById('filterForm').addEventListener('submit', function(e) {
e.preventDefault();
htmx.trigger('#log-table', 'filterSubmit');
});
// 모달 열기
function openCreateModal() {
document.getElementById('modalTitle').textContent = '새 일일 로그';
document.getElementById('logForm').reset();
document.getElementById('logId').value = '';
document.getElementById('logDate').value = new Date().toISOString().split('T')[0];
clearEntries();
document.getElementById('logModal').classList.remove('hidden');
}
// 특정 날짜로 모달 열기 (주간 타임라인에서 사용)
function openCreateModalWithDate(date) {
document.getElementById('modalTitle').textContent = '새 일일 로그';
document.getElementById('logForm').reset();
document.getElementById('logId').value = '';
document.getElementById('logDate').value = date;
clearEntries();
document.getElementById('logModal').classList.remove('hidden');
}
// 모달 닫기
function closeModal() {
document.getElementById('logModal').classList.add('hidden');
}
// 로그 수정 모달 열기
function editLog(id) {
fetch(`/api/admin/daily-logs/${id}`, {
headers: {
'Accept': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
})
.then(res => res.json())
.then(data => {
if (data.success) {
document.getElementById('modalTitle').textContent = '일일 로그 수정';
document.getElementById('logId').value = data.data.id;
document.getElementById('logDate').value = formatDateForInput(data.data.log_date);
document.getElementById('projectId').value = data.data.project_id || '';
document.getElementById('summary').value = data.data.summary || '';
// 항목들 로드
clearEntries();
if (data.data.entries && data.data.entries.length > 0) {
data.data.entries.forEach(entry => addEntry(entry));
}
document.getElementById('logModal').classList.remove('hidden');
}
});
}
// 항목 비우기
function clearEntries() {
document.getElementById('entriesContainer').innerHTML = '';
}
// 항목 추가
function addEntry(entry = null) {
const container = document.getElementById('entriesContainer');
const index = container.children.length;
const html = `
<div class="entry-item border rounded-lg p-4 bg-gray-50" data-index="${index}">
<div class="flex gap-4 mb-2">
<input type="hidden" name="entries[${index}][id]" value="${entry?.id || ''}">
<div class="w-24">
<select name="entries[${index}][assignee_type]" class="w-full px-2 py-1 border rounded text-sm" onchange="updateAssigneeOptions(this, ${index})">
<option value="user" ${(!entry || entry.assignee_type === 'user') ? 'selected' : ''}>개인</option>
<option value="team" ${entry?.assignee_type === 'team' ? 'selected' : ''}>팀</option>
</select>
</div>
<div class="flex-1">
<input type="text"
name="entries[${index}][assignee_name]"
value="${entry?.assignee_name || ''}"
placeholder="담당자 (직접입력 또는 선택)"
list="assigneeList${index}"
class="w-full px-2 py-1 border rounded text-sm"
required>
<datalist id="assigneeList${index}">
${getAssigneeOptions(entry?.assignee_type || 'user')}
</datalist>
<input type="hidden" name="entries[${index}][assignee_id]" value="${entry?.assignee_id || ''}">
</div>
<div class="w-24">
<select name="entries[${index}][status]" class="w-full px-2 py-1 border rounded text-sm">
<option value="todo" ${(!entry || entry.status === 'todo') ? 'selected' : ''}>예정</option>
<option value="in_progress" ${entry?.status === 'in_progress' ? 'selected' : ''}>진행중</option>
<option value="done" ${entry?.status === 'done' ? 'selected' : ''}>완료</option>
</select>
</div>
<button type="button" onclick="removeEntry(this)" class="text-red-500 hover:text-red-700">
<svg class="w-5 h-5" 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>
<textarea name="entries[${index}][content]"
placeholder="업무 내용"
rows="2"
class="w-full px-2 py-1 border rounded text-sm"
required>${entry?.content || ''}</textarea>
</div>
`;
container.insertAdjacentHTML('beforeend', html);
}
// 담당자 옵션 생성
function getAssigneeOptions(type) {
const list = type === 'team' ? assignees.teams : assignees.users;
return list.map(item => `<option value="${item.name}" data-id="${item.id}">`).join('');
}
// 담당자 타입 변경 시 옵션 업데이트
function updateAssigneeOptions(select, index) {
const datalist = document.getElementById(`assigneeList${index}`);
datalist.innerHTML = getAssigneeOptions(select.value);
}
// 항목 삭제
function removeEntry(btn) {
btn.closest('.entry-item').remove();
reindexEntries();
}
// 항목 인덱스 재정렬
function reindexEntries() {
const entries = document.querySelectorAll('.entry-item');
entries.forEach((entry, index) => {
entry.dataset.index = index;
entry.querySelectorAll('[name^="entries["]').forEach(input => {
input.name = input.name.replace(/entries\[\d+\]/, `entries[${index}]`);
});
const datalist = entry.querySelector('datalist');
if (datalist) {
datalist.id = `assigneeList${index}`;
}
const nameInput = entry.querySelector('input[list]');
if (nameInput) {
nameInput.setAttribute('list', `assigneeList${index}`);
}
});
}
// 폼 제출
document.getElementById('logForm').addEventListener('submit', function(e) {
e.preventDefault();
const logId = document.getElementById('logId').value;
const url = logId ? `/api/admin/daily-logs/${logId}` : '/api/admin/daily-logs';
const method = logId ? 'PUT' : 'POST';
const formData = new FormData(this);
const data = {};
// FormData를 객체로 변환
for (let [key, value] of formData.entries()) {
const match = key.match(/^entries\[(\d+)\]\[(.+)\]$/);
if (match) {
const idx = match[1];
const field = match[2];
if (!data.entries) data.entries = [];
if (!data.entries[idx]) data.entries[idx] = {};
data.entries[idx][field] = value;
} else {
data[key] = value;
}
}
// 빈 항목 제거
if (data.entries) {
data.entries = data.entries.filter(e => e && e.assignee_name && e.content);
}
fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify(data)
})
.then(res => res.json())
.then(result => {
if (result.success) {
closeModal();
htmx.trigger('#log-table', 'filterSubmit');
} else {
alert(result.message || '오류가 발생했습니다.');
}
})
.catch(err => {
console.error(err);
alert('오류가 발생했습니다.');
});
});
// 삭제 확인
function confirmDelete(id, date) {
if (confirm(`"${date}" 일일 로그를 삭제하시겠습니까?`)) {
fetch(`/api/admin/daily-logs/${id}`, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
})
.then(res => res.json())
.then(result => {
if (result.success) {
htmx.trigger('#log-table', 'filterSubmit');
}
});
}
}
// 복원 확인
function confirmRestore(id, date) {
if (confirm(`"${date}" 일일 로그를 복원하시겠습니까?`)) {
fetch(`/api/admin/daily-logs/${id}/restore`, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
})
.then(res => res.json())
.then(result => {
if (result.success) {
htmx.trigger('#log-table', 'filterSubmit');
}
});
}
}
// 영구삭제 확인
function confirmForceDelete(id, date) {
if (confirm(`"${date}" 일일 로그를 영구 삭제하시겠습니까?\n\n⚠ 이 작업은 되돌릴 수 없습니다!`)) {
fetch(`/api/admin/daily-logs/${id}/force`, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
})
.then(res => res.json())
.then(result => {
if (result.success) {
htmx.trigger('#log-table', 'filterSubmit');
}
});
}
}
// ========================================
// 주간 타임라인 → 테이블 행 스크롤 및 아코디언 열기
// ========================================
function scrollToTableRow(logId) {
// 테이블 행 찾기
const row = document.querySelector(`tr.log-row[data-log-id="${logId}"]`);
if (row) {
// 선택된 카드 하이라이트
document.querySelectorAll('.day-card').forEach(card => {
card.classList.remove('ring-2', 'ring-indigo-500');
});
const selectedCard = document.querySelector(`.day-card[data-log-id="${logId}"]`);
if (selectedCard) {
selectedCard.classList.add('ring-2', 'ring-indigo-500');
}
// 테이블 행으로 스크롤
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
// 스크롤 완료 후 아코디언 열기 (약간의 딜레이)
setTimeout(() => {
// 가상 이벤트 객체 생성
const fakeEvent = { target: row };
toggleTableAccordion(logId, fakeEvent);
}, 300);
} else {
// 테이블에 해당 로그가 없는 경우 (필터링 등으로 인해)
alert('해당 로그가 현재 목록에 표시되지 않습니다.\n필터를 확인해주세요.');
}
}
// ========================================
// 테이블 리스트 아코디언 기능
// ========================================
let tableOpenAccordionId = null;
// 테이블 아코디언 토글
function toggleTableAccordion(logId, event) {
// 클릭한 요소가 버튼이면 무시 (이벤트 버블링 방지)
if (event.target.closest('button') || event.target.closest('a')) {
return;
}
const row = document.querySelector(`tr.log-row[data-log-id="${logId}"]`);
const accordionRow = document.querySelector(`tr.accordion-row[data-accordion-for="${logId}"]`);
const chevron = row.querySelector('.accordion-chevron');
// 같은 행을 다시 클릭하면 닫기
if (tableOpenAccordionId === logId) {
accordionRow.classList.add('hidden');
chevron.classList.remove('rotate-90');
row.classList.remove('bg-blue-50');
tableOpenAccordionId = null;
return;
}
// 다른 열린 아코디언 닫기
if (tableOpenAccordionId !== null) {
const prevRow = document.querySelector(`tr.log-row[data-log-id="${tableOpenAccordionId}"]`);
const prevAccordion = document.querySelector(`tr.accordion-row[data-accordion-for="${tableOpenAccordionId}"]`);
if (prevRow && prevAccordion) {
prevAccordion.classList.add('hidden');
prevRow.querySelector('.accordion-chevron')?.classList.remove('rotate-90');
prevRow.classList.remove('bg-blue-50');
}
}
// 현재 아코디언 열기
accordionRow.classList.remove('hidden');
chevron.classList.add('rotate-90');
row.classList.add('bg-blue-50');
tableOpenAccordionId = logId;
// 데이터 로드
loadTableAccordionContent(logId);
}
// 테이블 아코디언 콘텐츠 로드
function loadTableAccordionContent(logId) {
const contentDiv = document.getElementById(`accordion-content-${logId}`);
contentDiv.innerHTML = '<div class="text-center py-4 text-gray-500">로딩 중...</div>';
fetch(`/api/admin/daily-logs/${logId}`, {
headers: {
'Accept': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
})
.then(res => res.json())
.then(data => {
if (data.success) {
renderTableAccordionContent(logId, data.data);
} else {
contentDiv.innerHTML = '<div class="text-center py-4 text-red-500">데이터 로드 실패</div>';
}
})
.catch(err => {
contentDiv.innerHTML = '<div class="text-center py-4 text-red-500">데이터 로드 실패</div>';
});
}
// 테이블 아코디언 콘텐츠 렌더링
function renderTableAccordionContent(logId, log) {
const contentDiv = document.getElementById(`accordion-content-${logId}`);
const statusColors = {
'todo': 'bg-gray-100 text-gray-700',
'in_progress': 'bg-yellow-100 text-yellow-700',
'done': 'bg-green-100 text-green-700'
};
const statusLabels = {
'todo': '예정',
'in_progress': '진행중',
'done': '완료'
};
let entriesHtml = '';
if (log.entries && log.entries.length > 0) {
entriesHtml = log.entries.map(entry => `
<div class="flex items-start gap-3 p-3 bg-white rounded-lg border" data-entry-id="${entry.id}">
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<span class="font-medium text-gray-900">${entry.assignee_name}</span>
<span class="px-2 py-0.5 text-xs rounded-full ${statusColors[entry.status]}">${statusLabels[entry.status]}</span>
</div>
<p class="text-sm text-gray-600">${nl2br(entry.content)}</p>
</div>
<div class="flex items-center gap-1">
${entry.status !== 'todo' ? `
<button onclick="updateTableEntryStatus(${logId}, ${entry.id}, 'todo')" class="p-1 text-gray-400 hover:text-gray-600" title="예정">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" stroke-width="2"/></svg>
</button>` : ''}
${entry.status !== 'in_progress' ? `
<button onclick="updateTableEntryStatus(${logId}, ${entry.id}, 'in_progress')" class="p-1 text-yellow-500 hover:text-yellow-700" title="진행중">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/></svg>
</button>` : ''}
${entry.status !== 'done' ? `
<button onclick="updateTableEntryStatus(${logId}, ${entry.id}, 'done')" class="p-1 text-green-500 hover:text-green-700" 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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
</button>` : ''}
<button onclick="deleteTableEntry(${logId}, ${entry.id})" class="p-1 text-red-400 hover:text-red-600" 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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</div>
</div>
`).join('');
} else {
entriesHtml = '<div class="text-center py-4 text-gray-400">등록된 항목이 없습니다.</div>';
}
contentDiv.innerHTML = `
<div class="space-y-3">
${log.summary ? `<div class="text-sm text-gray-700 mb-3 pb-3 border-b">${nl2br(log.summary)}</div>` : ''}
<div class="space-y-2">
${entriesHtml}
</div>
<div class="pt-3 border-t flex justify-between items-center">
<button onclick="openQuickAddTableEntry(${logId})" class="text-sm text-blue-600 hover:text-blue-800">
+ 항목 추가
</button>
<button onclick="editLog(${logId})" class="text-sm text-indigo-600 hover:text-indigo-800">
전체 수정
</button>
</div>
</div>
`;
}
// 테이블 항목 상태 업데이트
function updateTableEntryStatus(logId, entryId, status) {
fetch(`/api/admin/daily-logs/entries/${entryId}/status`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({ status })
})
.then(res => res.json())
.then(result => {
if (result.success) {
loadTableAccordionContent(logId);
}
});
}
// 테이블 항목 삭제
function deleteTableEntry(logId, entryId) {
if (confirm('이 항목을 삭제하시겠습니까?')) {
fetch(`/api/admin/daily-logs/entries/${entryId}`, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
})
.then(res => res.json())
.then(result => {
if (result.success) {
loadTableAccordionContent(logId);
}
});
}
}
// 테이블 빠른 항목 추가 - 미완료 항목 수정 모달 사용
function openQuickAddTableEntry(logId) {
openQuickAddModal(logId);
}
// HTMX 로드 완료 후 테이블 아코디언 상태 리셋
document.body.addEventListener('htmx:afterSwap', function(event) {
if (event.detail.target.id === 'log-table') {
tableOpenAccordionId = null;
}
});
// ========================================
// 미완료 항목 펼치기/접기
// ========================================
function togglePendingEntry(textEl) {
const entryDiv = textEl.closest('[data-entry-id]');
const fullDiv = entryDiv.querySelector('.pending-entry-full');
if (fullDiv.classList.contains('hidden')) {
// 펼치기 - 타이틀은 truncate 유지, 전체 내용 표시
fullDiv.classList.remove('hidden');
textEl.classList.add('text-blue-600');
} else {
// 접기
fullDiv.classList.add('hidden');
textEl.classList.remove('text-blue-600');
}
}
// ========================================
// 미완료 항목 상태 업데이트
// ========================================
function updatePendingStatus(entryId, status) {
fetch(`/api/admin/daily-logs/entries/${entryId}/status`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({ status })
})
.then(res => res.json())
.then(result => {
if (result.success) {
const item = document.querySelector(`[data-entry-id="${entryId}"]`);
if (item) {
if (status === 'done') {
// 완료 처리: 항목 제거 애니메이션
const parentCard = item.closest('.pending-assignee-card');
item.style.transition = 'opacity 0.3s, transform 0.3s';
item.style.opacity = '0';
item.style.transform = 'scale(0.95)';
setTimeout(() => {
item.remove();
// 담당자 카드에 항목이 없으면 카드도 제거
if (parentCard && parentCard.querySelectorAll('[data-entry-id]').length === 0) {
parentCard.style.transition = 'opacity 0.3s, transform 0.3s';
parentCard.style.opacity = '0';
parentCard.style.transform = 'scale(0.95)';
setTimeout(() => {
parentCard.remove();
// 전체 섹션 체크
const grid = document.getElementById('pendingEntriesGrid');
if (grid && grid.children.length === 0) {
grid.closest('.bg-white')?.remove();
}
}, 300);
}
}, 300);
} else {
// 예정/진행중 변경: 상태 뱃지만 업데이트
const badge = item.querySelector('span.px-1\\.5');
if (badge) {
if (status === 'in_progress') {
badge.className = 'px-1.5 py-0.5 text-[10px] rounded shrink-0 bg-yellow-100 text-yellow-700';
badge.textContent = '진행';
} else {
badge.className = 'px-1.5 py-0.5 text-[10px] rounded shrink-0 bg-gray-100 text-gray-600';
badge.textContent = '예정';
}
}
// 상태변경 버튼들도 업데이트
updatePendingEntryButtons(item, entryId, status);
}
}
// 로그 리스트도 새로고침
htmx.trigger('#log-table', 'filterSubmit');
}
});
}
// 미완료 항목 버튼 업데이트
function updatePendingEntryButtons(item, entryId, currentStatus) {
const buttonsDiv = item.querySelector('.flex.gap-0\\.5');
if (!buttonsDiv) return;
let buttonsHtml = '';
if (currentStatus !== 'todo') {
buttonsHtml += `
<button onclick="event.stopPropagation(); updatePendingStatus(${entryId}, '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>
`;
}
if (currentStatus !== 'in_progress') {
buttonsHtml += `
<button onclick="event.stopPropagation(); updatePendingStatus(${entryId}, '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>
`;
}
buttonsHtml += `
<button onclick="event.stopPropagation(); updatePendingStatus(${entryId}, '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>
`;
buttonsDiv.innerHTML = buttonsHtml;
}
// ========================================
// 미완료 항목 수정 모달 관련 함수
// ========================================
let pendingEditEntries = [];
let pendingEditAssigneeName = '';
let pendingEditFromAccordion = null; // 아코디언에서 열렸으면 logId 저장
// 모달 열기 (기존 항목 수정)
function openPendingEditModal(entriesJson, assigneeName) {
pendingEditEntries = entriesJson;
pendingEditAssigneeName = assigneeName;
pendingEditFromAccordion = null; // 기존 항목 수정 모드
// 담당자 텍스트 표시, 입력 필드 숨김
document.getElementById('pendingEditAssigneeNameText').textContent = assigneeName;
document.getElementById('pendingEditAssigneeNameText').classList.remove('hidden');
document.getElementById('pendingEditAssigneeNameInput').classList.add('hidden');
// 컨테이너 초기화 및 항목 추가
const container = document.getElementById('pendingEditEntriesContainer');
container.innerHTML = '';
entriesJson.forEach((entry, index) => {
addPendingEntryRow(entry, index);
});
document.getElementById('pendingEditModal').classList.remove('hidden');
}
// 모달 열기 (새 항목 추가 - 아코디언에서)
function openQuickAddModal(logId) {
pendingEditEntries = [{ id: '', content: '', status: 'todo', daily_log_id: logId }];
pendingEditAssigneeName = '';
pendingEditFromAccordion = logId;
// 담당자 입력 필드 표시, 텍스트 숨김
document.getElementById('pendingEditAssigneeNameText').classList.add('hidden');
const assigneeInput = document.getElementById('pendingEditAssigneeNameInput');
assigneeInput.value = '';
assigneeInput.classList.remove('hidden');
// 컨테이너 초기화 및 빈 항목 추가
const container = document.getElementById('pendingEditEntriesContainer');
container.innerHTML = '';
addPendingEntryRow({ id: '', content: '', status: 'todo', daily_log_id: logId }, 0);
document.getElementById('pendingEditModal').classList.remove('hidden');
// 담당자 입력 필드에 포커스
setTimeout(() => {
assigneeInput.focus();
}, 100);
}
// 모달 닫기
function closePendingEditModal() {
document.getElementById('pendingEditModal').classList.add('hidden');
pendingEditEntries = [];
pendingEditAssigneeName = '';
pendingEditFromAccordion = null;
}
// 항목 행 추가
function addPendingEntryRow(entry = null, index = null) {
const container = document.getElementById('pendingEditEntriesContainer');
if (index === null) {
index = container.children.length;
}
const currentStatus = entry?.status || 'todo';
// 새 항목인 경우 기존 항목의 daily_log_id 사용
let dailyLogId = entry?.daily_log_id || '';
if (!dailyLogId && pendingEditEntries.length > 0) {
dailyLogId = pendingEditEntries[0].daily_log_id || '';
}
const html = `
<div class="pending-edit-row flex gap-2 items-start p-2 bg-gray-50 rounded-lg" data-entry-id="${entry?.id || ''}" data-index="${index}">
<!-- 상태 버튼 그룹 -->
<div class="flex gap-1 shrink-0 pt-1">
<button type="button" onclick="togglePendingStatus(this, 'todo')"
class="status-btn p-1.5 rounded transition-all ${currentStatus === 'todo' ? 'bg-gray-200 ring-2 ring-gray-400' : 'hover:bg-gray-100'}"
data-status="todo" title="예정">
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" stroke-width="2"/></svg>
</button>
<button type="button" onclick="togglePendingStatus(this, 'in_progress')"
class="status-btn p-1.5 rounded transition-all ${currentStatus === 'in_progress' ? 'bg-yellow-100 ring-2 ring-yellow-400' : 'hover:bg-yellow-50'}"
data-status="in_progress" title="진행중">
<svg class="w-4 h-4 text-yellow-500" fill="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/></svg>
</button>
<button type="button" onclick="togglePendingStatus(this, 'done')"
class="status-btn p-1.5 rounded transition-all ${currentStatus === 'done' ? 'bg-green-100 ring-2 ring-green-400' : 'hover:bg-green-50'}"
data-status="done" title="완료">
<svg class="w-4 h-4 text-green-500" 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>
</div>
<input type="hidden" name="pending_entries[${index}][status]" value="${currentStatus}">
<div class="flex-1">
<textarea name="pending_entries[${index}][content]" rows="2" required
class="w-full px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 resize-none"
placeholder="업무 내용">${escapeHtml(entry?.content || '')}</textarea>
</div>
<input type="hidden" name="pending_entries[${index}][id]" value="${entry?.id || ''}">
<input type="hidden" name="pending_entries[${index}][daily_log_id]" value="${dailyLogId}">
<button type="button" onclick="removePendingEntryRow(this)" class="p-1.5 text-red-500 hover:bg-red-50 rounded shrink-0 pt-1" 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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
</div>
`;
container.insertAdjacentHTML('beforeend', html);
}
// 상태 버튼 토글
function togglePendingStatus(btn, status) {
const row = btn.closest('.pending-edit-row');
const statusInput = row.querySelector('input[name*="[status]"]');
const buttons = row.querySelectorAll('.status-btn');
// 모든 버튼 스타일 초기화
buttons.forEach(b => {
b.classList.remove('bg-gray-200', 'ring-2', 'ring-gray-400', 'bg-yellow-100', 'ring-yellow-400', 'bg-green-100', 'ring-green-400');
});
// 선택된 버튼 스타일 적용
if (status === 'todo') {
btn.classList.add('bg-gray-200', 'ring-2', 'ring-gray-400');
} else if (status === 'in_progress') {
btn.classList.add('bg-yellow-100', 'ring-2', 'ring-yellow-400');
} else if (status === 'done') {
btn.classList.add('bg-green-100', 'ring-2', 'ring-green-400');
}
// hidden input 값 업데이트
statusInput.value = status;
}
// 항목 행 삭제
function removePendingEntryRow(btn) {
const row = btn.closest('.pending-edit-row');
row.remove();
reindexPendingEntryRows();
}
// 인덱스 재정렬
function reindexPendingEntryRows() {
const rows = document.querySelectorAll('.pending-edit-row');
rows.forEach((row, index) => {
row.dataset.index = index;
row.querySelectorAll('[name^="pending_entries["]').forEach(input => {
input.name = input.name.replace(/pending_entries\[\d+\]/, `pending_entries[${index}]`);
});
});
}
// 폼 제출
function submitPendingEditEntries(event) {
event.preventDefault();
// 새 항목 추가 모드일 때 입력 필드에서 담당자 이름 가져오기
let assigneeName = pendingEditAssigneeName;
if (pendingEditFromAccordion) {
const inputField = document.getElementById('pendingEditAssigneeNameInput');
assigneeName = inputField.value.trim();
if (!assigneeName) {
alert('담당자 이름을 입력해주세요.');
inputField.focus();
return;
}
}
const submitBtn = document.getElementById('pendingEditSubmitBtn');
submitBtn.disabled = true;
submitBtn.textContent = '저장 중...';
const rows = document.querySelectorAll('.pending-edit-row');
const updatePromises = [];
const deletePromises = [];
// 기존 항목 ID 수집
const currentIds = Array.from(rows).map(row => row.dataset.entryId).filter(id => id);
const originalIds = pendingEditEntries.map(e => String(e.id));
// 삭제할 항목
const deletedIds = originalIds.filter(id => !currentIds.includes(id));
deletedIds.forEach(id => {
deletePromises.push(
fetch(`/api/admin/daily-logs/entries/${id}`, {
method: 'DELETE',
headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
})
);
});
// 수정/추가할 항목
rows.forEach(row => {
const entryId = row.dataset.entryId;
const status = row.querySelector('input[name*="[status]"]').value;
const content = row.querySelector('textarea').value;
const dailyLogId = row.querySelector('input[name*="[daily_log_id]"]').value;
if (!content.trim()) return;
if (entryId) {
// 기존 항목 수정
updatePromises.push(
fetch(`/api/admin/daily-logs/entries/${entryId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({ content, status })
})
);
} else if (dailyLogId) {
// 새 항목 추가
updatePromises.push(
fetch(`/api/admin/daily-logs/${dailyLogId}/entries`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({
assignee_type: 'user',
assignee_name: assigneeName,
content,
status
})
})
);
}
});
Promise.all([...deletePromises, ...updatePromises])
.then(() => {
closePendingEditModal();
// 페이지 새로고침으로 미완료 항목 갱신
location.reload();
})
.catch(err => {
console.error(err);
alert('저장 중 오류가 발생했습니다.');
})
.finally(() => {
submitBtn.disabled = false;
submitBtn.textContent = '저장';
});
}
// ESC 키로 모달 닫기
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
if (!document.getElementById('pendingEditModal').classList.contains('hidden')) {
closePendingEditModal();
}
}
});
</script>
@endpush