feat(sprint005): 월간 확인 워크플로우 — 관리자 확인요청 + 수정요청

- DB: status ENUM 확장 (review_sent, change_request) + reviewed_by/at, change_details
- API: POST /review-send (일괄 확인요청), POST /review-respond (수정 승인/거부)
- 작업자: pending=검토대기, review_sent=확인/수정요청, rejected=동의(재확인)
- 관리자: 필터 탭 확장 + 확인요청 일괄 발송 버튼
- confirm 상태 전환 검증: pending→confirmed 차단

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-01 08:09:36 +09:00
parent 1340918f8e
commit 65e5530a6a
10 changed files with 273 additions and 39 deletions

View File

@@ -17,8 +17,10 @@ body { max-width: 480px; margin: 0 auto; }
position: absolute; right: 0; top: 50%; transform: translateY(-50%);
font-size: 0.7rem; font-weight: 600; padding: 3px 8px; border-radius: 12px;
}
.mmc-status-badge.pending { background: #fef3c7; color: #92400e; }
.mmc-status-badge.pending { background: #f3f4f6; color: #6b7280; }
.mmc-status-badge.review_sent { background: #dbeafe; color: #1e40af; }
.mmc-status-badge.confirmed { background: #dcfce7; color: #166534; }
.mmc-status-badge.change_request { background: #fef3c7; color: #92400e; }
.mmc-status-badge.rejected { background: #fef2f2; color: #991b1b; }
/* 사용자 정보 */

View File

@@ -330,8 +330,22 @@ function renderAdminSummary(s) {
document.getElementById('progressText').textContent = `확인 현황: ${s.confirmed || 0}/${total}명 완료`;
document.getElementById('statusCounts').innerHTML =
`<span>✅ ${s.confirmed || 0} 확인</span>` +
`<span> ${s.pending || 0} 대기</span>` +
`<span>📩 ${s.review_sent || 0} 확인요청</span>` +
`<span>⏳ ${s.pending || 0} 미검토</span>` +
`<span>📝 ${s.change_request || 0} 수정요청</span>` +
`<span>❌ ${s.rejected || 0} 반려</span>`;
// 확인요청 일괄 발송 버튼
var reviewBtn = document.getElementById('reviewSendBtn');
if (reviewBtn) {
var pendingCount = (s.pending || 0);
if (pendingCount > 0) {
reviewBtn.classList.remove('hidden');
reviewBtn.textContent = `미검토 ${pendingCount}명 확인요청 발송`;
} else {
reviewBtn.classList.add('hidden');
}
}
}
function renderWorkerList(workers) {
@@ -497,6 +511,28 @@ async function downloadExcel() {
}
}
// ===== Review Send (확인요청 일괄 발송) =====
async function sendReviewAll() {
if (isProcessing) return;
if (!confirm(currentYear + '년 ' + currentMonth + '월 미검토 작업자 전체에게 확인요청을 발송하시겠습니까?')) return;
isProcessing = true;
try {
var res = await window.apiCall('/monthly-comparison/review-send', 'POST', {
year: currentYear, month: currentMonth
});
if (res && res.success) {
showToast(res.message || '확인요청 발송 완료', 'success');
loadAdminStatus();
} else {
showToast(res && res.message || '발송 실패', 'error');
}
} catch (e) {
showToast('네트워크 오류', 'error');
} finally {
isProcessing = false;
}
}
// ===== View Toggle =====
function toggleViewMode() {
if (currentMode === 'admin') {

View File

@@ -220,30 +220,62 @@ function renderConfirmStatus(conf) {
var actions = document.getElementById('bottomActions');
var statusEl = document.getElementById('confirmedStatus');
var badge = document.getElementById('statusBadge');
var confirmBtn = document.getElementById('confirmBtn');
var rejectBtn = document.getElementById('rejectBtn');
var status = conf ? conf.status : 'pending';
if (!conf || conf.status === 'pending') {
actions.classList.remove('hidden');
statusEl.classList.add('hidden');
badge.textContent = '미확인';
// 기본: 버튼 숨김 + 상태 숨김
actions.classList.add('hidden');
statusEl.classList.add('hidden');
if (status === 'pending') {
badge.textContent = '검토대기';
badge.className = 'mmc-status-badge pending';
return;
}
if (conf.status === 'confirmed') {
actions.classList.add('hidden');
statusEl.classList.remove('hidden');
document.getElementById('confirmedText').textContent = '관리자 검토 대기 중입니다';
} else if (status === 'review_sent') {
badge.textContent = '확인요청';
badge.className = 'mmc-status-badge review_sent';
actions.classList.remove('hidden');
confirmBtn.innerHTML = '<i class="fas fa-check-circle mr-2"></i>확인 완료';
rejectBtn.innerHTML = '<i class="fas fa-edit mr-2"></i>수정요청';
rejectBtn.onclick = function() { openChangeRequestModal(); };
} else if (status === 'confirmed') {
badge.textContent = '확인완료';
badge.className = 'mmc-status-badge confirmed';
statusEl.classList.remove('hidden');
var dt = conf.confirmed_at ? new Date(conf.confirmed_at).toLocaleDateString('ko') : '';
document.getElementById('confirmedText').textContent = dt + ' 확인 완료';
} else if (conf.status === 'rejected') {
actions.classList.remove('hidden');
statusEl.classList.add('hidden');
} else if (status === 'change_request') {
badge.textContent = '수정요청';
badge.className = 'mmc-status-badge change_request';
statusEl.classList.remove('hidden');
document.getElementById('confirmedText').textContent = '수정요청이 제출되었습니다. 관리자 확인 대기 중';
} else if (status === 'rejected') {
badge.textContent = '반려';
badge.className = 'mmc-status-badge rejected';
actions.classList.remove('hidden');
confirmBtn.innerHTML = '<i class="fas fa-check-circle mr-2"></i>동의(재확인)';
rejectBtn.classList.add('hidden');
statusEl.classList.remove('hidden');
document.getElementById('confirmedText').textContent = '반려 사유: ' + (conf.reject_reason || '-') + '\n반려 사유를 확인하고 동의하시면 확인 완료 버튼을 눌러주세요.';
}
}
function openChangeRequestModal() {
document.getElementById('rejectReason').value = '';
document.getElementById('rejectModal').classList.remove('hidden');
// 모달 제목/버튼 수정요청용으로 변경
var header = document.querySelector('.mmc-modal-header span');
if (header) header.innerHTML = '<i class="fas fa-edit text-blue-500 mr-2"></i>수정요청';
var submitBtn = document.querySelector('.mmc-modal-submit');
if (submitBtn) submitBtn.textContent = '수정요청 제출';
var desc = document.querySelector('.mmc-modal-desc');
if (desc) desc.textContent = '수정이 필요한 내용을 입력해주세요:';
var note = document.querySelector('.mmc-modal-note');
if (note) note.innerHTML = '<i class="fas fa-info-circle text-blue-400 mr-1"></i>수정요청 시 관리자에게 알림이 전달됩니다.';
}
// ===== Actions =====
async function confirmMonth() {
if (isProcessing) return;
@@ -268,13 +300,14 @@ function closeRejectModal() { document.getElementById('rejectModal').classList.a
async function submitReject() {
if (isProcessing) return;
var reason = document.getElementById('rejectReason').value.trim();
if (!reason) { showToast('반려 사유를 입력해주세요', 'error'); return; }
if (!reason) { showToast('수정 내용을 입력해주세요', 'error'); return; }
isProcessing = true;
try {
var res = await window.apiCall('/monthly-comparison/confirm', 'POST', {
year: currentYear, month: currentMonth, status: 'rejected', reject_reason: reason
year: currentYear, month: currentMonth, status: 'change_request',
change_details: { description: reason }
});
if (res && res.success) { showToast(res.message || '반려 제출 완료', 'success'); closeRejectModal(); loadData(); }
if (res && res.success) { showToast(res.message || '수정요청 완료', 'success'); closeRejectModal(); loadData(); }
else { showToast(res && res.message || '처리 실패', 'error'); }
} catch (e) { showToast('네트워크 오류', 'error'); }
finally { isProcessing = false; }

View File

@@ -94,12 +94,15 @@
<div class="mc-progress-bar"><div class="mc-progress-fill" id="progressFill"></div></div>
<div class="mc-progress-text" id="progressText"></div>
<div class="mc-status-counts" id="statusCounts"></div>
<button type="button" class="mc-review-send-btn hidden" id="reviewSendBtn" onclick="sendReviewAll()" style="margin-top:8px;width:100%;padding:10px;background:#2563eb;color:white;border:none;border-radius:8px;font-size:0.8rem;font-weight:600;cursor:pointer;">확인요청 발송</button>
</div>
<div class="mc-filter-tabs">
<button class="mc-tab active" data-filter="all" onclick="filterWorkers('all')">전체</button>
<button class="mc-tab" data-filter="confirmed" onclick="filterWorkers('confirmed')">확인</button>
<button class="mc-tab" data-filter="pending" onclick="filterWorkers('pending')">대기</button>
<button class="mc-tab" data-filter="review_sent" onclick="filterWorkers('review_sent')">확인요청</button>
<button class="mc-tab" data-filter="pending" onclick="filterWorkers('pending')">미검토</button>
<button class="mc-tab" data-filter="change_request" onclick="filterWorkers('change_request')">수정요청</button>
<button class="mc-tab" data-filter="rejected" onclick="filterWorkers('rejected')">반려</button>
</div>
@@ -159,7 +162,7 @@
<script src="/static/js/tkfb-core.js?v=2026033108"></script>
<script src="/js/api-base.js?v=2026031701"></script>
<script src="/js/monthly-comparison.js?v=2026033107"></script>
<script src="/js/monthly-comparison.js?v=2026040101"></script>
<script>initAuth();</script>
</body>
</html>

View File

@@ -8,7 +8,7 @@
<script>tailwind.config = { corePlugins: { preflight: false } }</script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026033108">
<link rel="stylesheet" href="/css/my-monthly-confirm.css?v=2026040103">
<link rel="stylesheet" href="/css/my-monthly-confirm.css?v=2026040104">
</head>
<body class="bg-gray-50">
<header class="bg-orange-700 text-white sticky top-0 z-50">
@@ -106,7 +106,7 @@
<script src="/static/js/tkfb-core.js?v=2026033108"></script>
<script src="/js/api-base.js?v=2026031701"></script>
<script src="/js/my-monthly-confirm.js?v=2026040103"></script>
<script src="/js/my-monthly-confirm.js?v=2026040104"></script>
<script>initAuth();</script>
</body>
</html>