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

@@ -187,36 +187,38 @@ const MonthlyComparisonController = {
const { year, month, status, reject_reason } = req.body; const { year, month, status, reject_reason } = req.body;
if (!year || !month || !status) return res.status(400).json({ success: false, message: 'year, month, status 필수' }); if (!year || !month || !status) return res.status(400).json({ success: false, message: 'year, month, status 필수' });
if (!['confirmed', 'rejected'].includes(status)) return res.status(400).json({ success: false, message: "status는 'confirmed' 또는 'rejected'만 허용" }); if (!['confirmed', 'change_request'].includes(status)) return res.status(400).json({ success: false, message: "status는 'confirmed' 또는 'change_request'만 허용" });
if (status === 'rejected' && (!reject_reason || !reject_reason.trim())) { const change_details = req.body.change_details || null;
return res.status(400).json({ success: false, message: '반려 사유를 입력해주세요.' }); if (status === 'change_request' && !change_details) {
return res.status(400).json({ success: false, message: '수정 내용을 입력해주세요.' });
} }
// 요약 통계 계산 // 요약 통계 계산
const compData = await buildComparisonData(userId, parseInt(year), parseInt(month)); const compData = await buildComparisonData(userId, parseInt(year), parseInt(month));
let notificationData = null; let notificationData = null;
if (status === 'rejected') { if (status === 'change_request') {
const worker = await Model.getWorkerInfo(userId); const worker = await Model.getWorkerInfo(userId);
const recipients = await Model.getSupportTeamUsers(); const recipients = await Model.getSupportTeamUsers();
notificationData = { notificationData = {
recipients, recipients,
title: '월간 근무 내역 이의 제기', title: '월간 근무 수정요청',
message: `${worker?.worker_name || '작업자'}(${worker?.department_name || ''})님이 ${year}${month}월 근무 내역에 이의를 제기했습니다. 사유: ${reject_reason}`, message: `${worker?.worker_name || '작업자'}(${worker?.department_name || ''})님이 ${year}${month}월 근무 내역 수정을 요청했습니다.`,
linkUrl: `/pages/attendance/monthly-comparison.html?user_id=${userId}&year=${year}&month=${month}`, linkUrl: `/pages/attendance/monthly-comparison.html?mode=detail&user_id=${userId}&year=${year}&month=${month}`,
createdBy: userId createdBy: userId
}; };
} }
const result = await Model.upsertConfirmation({ const result = await Model.upsertConfirmation({
user_id: userId, year: parseInt(year), month: parseInt(month), status, user_id: userId, year: parseInt(year), month: parseInt(month), status,
reject_reason: reject_reason || null, reject_reason: null,
change_details: change_details ? JSON.stringify(change_details) : null,
...compData.summary ...compData.summary
}, notificationData); }, notificationData);
if (result.error) return res.status(409).json({ success: false, message: result.error }); if (result.error) return res.status(409).json({ success: false, message: result.error });
const msg = status === 'confirmed' ? '확인이 완료되었습니다.' : '이의가 접수되었습니다. 지원팀에 알림이 전달됩니다.'; const msg = status === 'confirmed' ? '확인이 완료되었습니다.' : '수정요청이 접수되었습니다. 관리자에게 알림이 전달됩니다.';
res.json({ success: true, data: result, message: msg }); res.json({ success: true, data: result, message: msg });
} catch (err) { } catch (err) {
logger.error('monthlyComparison confirm error:', err); logger.error('monthlyComparison confirm error:', err);
@@ -224,6 +226,42 @@ const MonthlyComparisonController = {
} }
}, },
// POST /review-send (관리자 → 확인요청 발송)
reviewSend: async (req, res) => {
try {
const { year, month, user_id } = req.body;
if (!year || !month) return res.status(400).json({ success: false, message: 'year, month 필수' });
const reviewedBy = req.user.user_id || req.user.id;
const userIds = user_id ? [parseInt(user_id)] : null;
const result = await Model.bulkReviewSend(parseInt(year), parseInt(month), userIds, reviewedBy);
if (result.error) return res.status(400).json({ success: false, message: result.error });
res.json({ success: true, data: result, message: `${result.count}명에게 확인요청을 발송했습니다.` });
} catch (err) {
logger.error('monthlyComparison reviewSend error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// POST /review-respond (관리자 → 수정요청 응답)
reviewRespond: async (req, res) => {
try {
const { user_id, year, month, action, reject_reason } = req.body;
if (!user_id || !year || !month || !action) return res.status(400).json({ success: false, message: 'user_id, year, month, action 필수' });
if (!['approve', 'reject'].includes(action)) return res.status(400).json({ success: false, message: "action은 'approve' 또는 'reject'" });
if (action === 'reject' && (!reject_reason || !reject_reason.trim())) {
return res.status(400).json({ success: false, message: '거부 사유를 입력해주세요.' });
}
const respondedBy = req.user.user_id || req.user.id;
const result = await Model.reviewRespond(parseInt(user_id), parseInt(year), parseInt(month), action, reject_reason, respondedBy);
if (result.error) return res.status(409).json({ success: false, message: result.error });
const msg = action === 'approve' ? '수정 승인 완료. 작업자에게 재확인 요청됩니다.' : '수정요청이 거부되었습니다.';
res.json({ success: true, data: result, message: msg });
} catch (err) {
logger.error('monthlyComparison reviewRespond error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// GET /all-status (support_team+) // GET /all-status (support_team+)
getAllStatus: async (req, res) => { getAllStatus: async (req, res) => {
try { try {

View File

@@ -0,0 +1,6 @@
-- 월간 확인 워크플로우 확장: pending → review_sent → confirmed/change_request/rejected
ALTER TABLE monthly_work_confirmations
MODIFY status ENUM('pending','review_sent','confirmed','change_request','rejected') DEFAULT 'pending';
ALTER TABLE monthly_work_confirmations ADD COLUMN IF NOT EXISTS reviewed_by INT NULL;
ALTER TABLE monthly_work_confirmations ADD COLUMN IF NOT EXISTS reviewed_at TIMESTAMP NULL;
ALTER TABLE monthly_work_confirmations ADD COLUMN IF NOT EXISTS change_details TEXT NULL;

View File

@@ -48,7 +48,7 @@ async function runStartupMigrations() {
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const db = await getDb(); const db = await getDb();
const migrationFiles = ['20260326_schedule_extensions.sql', '20260330_add_proxy_input_fields.sql', '20260330_create_monthly_work_confirmations.sql', '20260331_fix_deduct_days_precision.sql']; const migrationFiles = ['20260326_schedule_extensions.sql', '20260330_add_proxy_input_fields.sql', '20260330_create_monthly_work_confirmations.sql', '20260331_fix_deduct_days_precision.sql', '20260401_monthly_confirm_workflow.sql'];
for (const file of migrationFiles) { for (const file of migrationFiles) {
const sqlPath = path.join(__dirname, 'db', 'migrations', file); const sqlPath = path.join(__dirname, 'db', 'migrations', file);
if (!fs.existsSync(sqlPath)) continue; if (!fs.existsSync(sqlPath)) continue;

View File

@@ -86,23 +86,37 @@ const MonthlyComparisonModel = {
try { try {
await conn.beginTransaction(); await conn.beginTransaction();
// 기존 상태 체크 // 기존 상태 체크 + 전환 검증
const [existing] = await conn.query( const [existing] = await conn.query(
'SELECT id, status FROM monthly_work_confirmations WHERE user_id = ? AND year = ? AND month = ?', 'SELECT id, status FROM monthly_work_confirmations WHERE user_id = ? AND year = ? AND month = ?',
[data.user_id, data.year, data.month] [data.user_id, data.year, data.month]
); );
if (existing.length > 0 && existing[0].status === 'confirmed') { const currentStatus = existing.length > 0 ? existing[0].status : null;
if (currentStatus === 'confirmed') {
await conn.rollback(); await conn.rollback();
return { error: '이미 확인된 내역은 변경할 수 없습니다.' }; return { error: '이미 확인된 내역은 변경할 수 없습니다.' };
} }
// 작업자 확인: review_sent 또는 rejected 상태에서만 가능
if (data.status === 'confirmed' && currentStatus && currentStatus !== 'review_sent' && currentStatus !== 'rejected') {
await conn.rollback();
return { error: '관리자 확인요청 후에 확인할 수 있습니다.' };
}
// 작업자 수정요청: review_sent 상태에서만 가능
if (data.status === 'change_request' && currentStatus !== 'review_sent') {
await conn.rollback();
return { error: '확인요청 상태에서만 수정요청이 가능합니다.' };
}
// UPSERT // UPSERT
const [result] = await conn.query(` const [result] = await conn.query(`
INSERT INTO monthly_work_confirmations INSERT INTO monthly_work_confirmations
(user_id, year, month, status, total_work_days, total_work_hours, (user_id, year, month, status, total_work_days, total_work_hours,
total_overtime_hours, vacation_days, mismatch_count, reject_reason, total_overtime_hours, vacation_days, mismatch_count, reject_reason,
confirmed_at, rejected_at) confirmed_at, rejected_at, change_details)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
status = VALUES(status), status = VALUES(status),
total_work_days = VALUES(total_work_days), total_work_days = VALUES(total_work_days),
@@ -112,20 +126,22 @@ const MonthlyComparisonModel = {
mismatch_count = VALUES(mismatch_count), mismatch_count = VALUES(mismatch_count),
reject_reason = VALUES(reject_reason), reject_reason = VALUES(reject_reason),
confirmed_at = VALUES(confirmed_at), confirmed_at = VALUES(confirmed_at),
rejected_at = VALUES(rejected_at) rejected_at = VALUES(rejected_at),
change_details = VALUES(change_details)
`, [ `, [
data.user_id, data.year, data.month, data.status, data.user_id, data.year, data.month, data.status,
data.total_work_days || 0, data.total_work_hours || 0, data.total_work_days || 0, data.total_work_hours || 0,
data.total_overtime_hours || 0, data.vacation_days || 0, data.total_overtime_hours || 0, data.vacation_days || 0,
data.mismatch_count || 0, data.reject_reason || null, data.mismatch_count || 0, data.reject_reason || null,
data.status === 'confirmed' ? new Date() : null, data.status === 'confirmed' ? new Date() : null,
data.status === 'rejected' ? new Date() : null data.status === 'rejected' ? new Date() : null,
data.change_details || null
]); ]);
const confirmationId = result.insertId || (existing.length > 0 ? existing[0].id : null); const confirmationId = result.insertId || (existing.length > 0 ? existing[0].id : null);
// 반려 시 알림 생성 // 알림 생성 (반려 또는 수정요청)
if (data.status === 'rejected' && notificationData && confirmationId) { if (notificationData && confirmationId) {
const { recipients, title, message, linkUrl, createdBy } = notificationData; const { recipients, title, message, linkUrl, createdBy } = notificationData;
for (const recipientId of recipients) { for (const recipientId of recipients) {
await conn.query(` await conn.query(`
@@ -146,6 +162,100 @@ const MonthlyComparisonModel = {
} }
}, },
// 관리자: 확인요청 발송 (pending → review_sent)
async bulkReviewSend(year, month, userIds, reviewedBy) {
const db = await getDb();
const conn = await db.getConnection();
try {
await conn.beginTransaction();
// 대상 작업자 결정 (userIds 있으면 단건, 없으면 pending 전체)
let targetIds = userIds || [];
if (!targetIds.length) {
const [pendingRows] = await conn.query(
`SELECT DISTINCT w.user_id FROM workers w
LEFT JOIN monthly_work_confirmations mwc ON w.user_id = mwc.user_id AND mwc.year = ? AND mwc.month = ?
WHERE w.status = 'active' AND w.user_id IS NOT NULL
AND (mwc.status IS NULL OR mwc.status = 'pending')`,
[year, month]
);
targetIds = pendingRows.map(r => r.user_id);
}
if (!targetIds.length) {
await conn.rollback();
return { count: 0, message: '대상 작업자가 없습니다.' };
}
// 상태 전환 + 알림 생성
for (const uid of targetIds) {
await conn.query(
`INSERT INTO monthly_work_confirmations (user_id, year, month, status, reviewed_by, reviewed_at)
VALUES (?, ?, ?, 'review_sent', ?, NOW())
ON DUPLICATE KEY UPDATE status = 'review_sent', reviewed_by = ?, reviewed_at = NOW()`,
[uid, year, month, reviewedBy, reviewedBy]
);
await conn.query(
`INSERT INTO notifications (user_id, type, title, message, link_url, reference_type, is_read, created_by)
VALUES (?, 'system', '월간 근무 확인 요청', ?, '/pages/attendance/my-monthly-confirm.html?year=${year}&month=${month}', 'monthly_work_confirmation', 0, ?)`,
[uid, `${year}${month}월 근무 내역을 확인해주세요.`, reviewedBy]
);
}
await conn.commit();
return { count: targetIds.length };
} catch (err) {
await conn.rollback();
throw err;
} finally {
conn.release();
}
},
// 관리자: 수정요청 응답 (change_request → review_sent 또는 rejected)
async reviewRespond(userId, year, month, action, rejectReason, respondedBy) {
const db = await getDb();
const conn = await db.getConnection();
try {
await conn.beginTransaction();
const [existing] = await conn.query(
'SELECT id, status FROM monthly_work_confirmations WHERE user_id = ? AND year = ? AND month = ?',
[userId, year, month]
);
if (!existing.length || existing[0].status !== 'change_request') {
await conn.rollback();
return { error: '수정요청 상태가 아닙니다.' };
}
var newStatus = action === 'approve' ? 'review_sent' : 'rejected';
await conn.query(
`UPDATE monthly_work_confirmations SET status = ?, reviewed_by = ?, reviewed_at = NOW(),
reject_reason = ?, change_details = NULL WHERE id = ?`,
[newStatus, respondedBy, action === 'reject' ? rejectReason : null, existing[0].id]
);
// 작업자에게 알림
var title = action === 'approve' ? '수정요청 승인' : '수정요청 거부';
var message = action === 'approve'
? `${year}${month}월 근무 수정이 반영되었습니다. 다시 확인해주세요.`
: `${year}${month}월 근무 수정요청이 거부되었습니다. 사유: ${rejectReason || '-'}`;
await conn.query(
`INSERT INTO notifications (user_id, type, title, message, link_url, reference_type, reference_id, is_read, created_by)
VALUES (?, 'system', ?, ?, ?, 'monthly_work_confirmation', ?, 0, ?)`,
[userId, title, message, '/pages/attendance/my-monthly-confirm.html?year=' + year + '&month=' + month, existing[0].id, respondedBy]
);
await conn.commit();
return { status: newStatus };
} catch (err) {
await conn.rollback();
throw err;
} finally {
conn.release();
}
},
// 5. 전체 작업자 확인 현황 // 5. 전체 작업자 확인 현황
async getAllStatus(year, month, departmentId) { async getAllStatus(year, month, departmentId) {
const db = await getDb(); const db = await getDb();

View File

@@ -23,6 +23,12 @@ router.get('/records', ctrl.getRecords);
// 확인/반려 // 확인/반려
router.post('/confirm', ctrl.confirm); router.post('/confirm', ctrl.confirm);
// 관리자: 확인요청 발송 (pending → review_sent)
router.post('/review-send', requireSupportTeam, ctrl.reviewSend);
// 관리자: 수정요청 응답 (change_request → review_sent 또는 rejected)
router.post('/review-respond', requireSupportTeam, ctrl.reviewRespond);
// 전체 현황 (support_team+) // 전체 현황 (support_team+)
router.get('/all-status', requireSupportTeam, ctrl.getAllStatus); router.get('/all-status', requireSupportTeam, ctrl.getAllStatus);

View File

@@ -17,8 +17,10 @@ body { max-width: 480px; margin: 0 auto; }
position: absolute; right: 0; top: 50%; transform: translateY(-50%); position: absolute; right: 0; top: 50%; transform: translateY(-50%);
font-size: 0.7rem; font-weight: 600; padding: 3px 8px; border-radius: 12px; 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.confirmed { background: #dcfce7; color: #166534; }
.mmc-status-badge.change_request { background: #fef3c7; color: #92400e; }
.mmc-status-badge.rejected { background: #fef2f2; color: #991b1b; } .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('progressText').textContent = `확인 현황: ${s.confirmed || 0}/${total}명 완료`;
document.getElementById('statusCounts').innerHTML = document.getElementById('statusCounts').innerHTML =
`<span>✅ ${s.confirmed || 0} 확인</span>` + `<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>`; `<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) { 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 ===== // ===== View Toggle =====
function toggleViewMode() { function toggleViewMode() {
if (currentMode === 'admin') { if (currentMode === 'admin') {

View File

@@ -220,30 +220,62 @@ function renderConfirmStatus(conf) {
var actions = document.getElementById('bottomActions'); var actions = document.getElementById('bottomActions');
var statusEl = document.getElementById('confirmedStatus'); var statusEl = document.getElementById('confirmedStatus');
var badge = document.getElementById('statusBadge'); 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'); actions.classList.add('hidden');
statusEl.classList.add('hidden'); statusEl.classList.add('hidden');
badge.textContent = '미확인';
if (status === 'pending') {
badge.textContent = '검토대기';
badge.className = 'mmc-status-badge pending'; badge.className = 'mmc-status-badge pending';
return;
}
if (conf.status === 'confirmed') {
actions.classList.add('hidden');
statusEl.classList.remove('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.textContent = '확인완료';
badge.className = 'mmc-status-badge confirmed'; badge.className = 'mmc-status-badge confirmed';
statusEl.classList.remove('hidden');
var dt = conf.confirmed_at ? new Date(conf.confirmed_at).toLocaleDateString('ko') : ''; var dt = conf.confirmed_at ? new Date(conf.confirmed_at).toLocaleDateString('ko') : '';
document.getElementById('confirmedText').textContent = dt + ' 확인 완료'; document.getElementById('confirmedText').textContent = dt + ' 확인 완료';
} else if (conf.status === 'rejected') { } else if (status === 'change_request') {
actions.classList.remove('hidden'); badge.textContent = '수정요청';
statusEl.classList.add('hidden'); badge.className = 'mmc-status-badge change_request';
statusEl.classList.remove('hidden');
document.getElementById('confirmedText').textContent = '수정요청이 제출되었습니다. 관리자 확인 대기 중';
} else if (status === 'rejected') {
badge.textContent = '반려'; badge.textContent = '반려';
badge.className = 'mmc-status-badge rejected'; 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 ===== // ===== Actions =====
async function confirmMonth() { async function confirmMonth() {
if (isProcessing) return; if (isProcessing) return;
@@ -268,13 +300,14 @@ function closeRejectModal() { document.getElementById('rejectModal').classList.a
async function submitReject() { async function submitReject() {
if (isProcessing) return; if (isProcessing) return;
var reason = document.getElementById('rejectReason').value.trim(); var reason = document.getElementById('rejectReason').value.trim();
if (!reason) { showToast('반려 사유를 입력해주세요', 'error'); return; } if (!reason) { showToast('수정 내용을 입력해주세요', 'error'); return; }
isProcessing = true; isProcessing = true;
try { try {
var res = await window.apiCall('/monthly-comparison/confirm', 'POST', { 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'); } else { showToast(res && res.message || '처리 실패', 'error'); }
} catch (e) { showToast('네트워크 오류', 'error'); } } catch (e) { showToast('네트워크 오류', 'error'); }
finally { isProcessing = false; } 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-bar"><div class="mc-progress-fill" id="progressFill"></div></div>
<div class="mc-progress-text" id="progressText"></div> <div class="mc-progress-text" id="progressText"></div>
<div class="mc-status-counts" id="statusCounts"></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>
<div class="mc-filter-tabs"> <div class="mc-filter-tabs">
<button class="mc-tab active" data-filter="all" onclick="filterWorkers('all')">전체</button> <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="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> <button class="mc-tab" data-filter="rejected" onclick="filterWorkers('rejected')">반려</button>
</div> </div>
@@ -159,7 +162,7 @@
<script src="/static/js/tkfb-core.js?v=2026033108"></script> <script src="/static/js/tkfb-core.js?v=2026033108"></script>
<script src="/js/api-base.js?v=2026031701"></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> <script>initAuth();</script>
</body> </body>
</html> </html>

View File

@@ -8,7 +8,7 @@
<script>tailwind.config = { corePlugins: { preflight: false } }</script> <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="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="/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> </head>
<body class="bg-gray-50"> <body class="bg-gray-50">
<header class="bg-orange-700 text-white sticky top-0 z-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="/static/js/tkfb-core.js?v=2026033108"></script>
<script src="/js/api-base.js?v=2026031701"></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> <script>initAuth();</script>
</body> </body>
</html> </html>