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:
@@ -187,36 +187,38 @@ const MonthlyComparisonController = {
|
||||
const { year, month, status, reject_reason } = req.body;
|
||||
|
||||
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 (status === 'rejected' && (!reject_reason || !reject_reason.trim())) {
|
||||
return res.status(400).json({ success: false, message: '반려 사유를 입력해주세요.' });
|
||||
if (!['confirmed', 'change_request'].includes(status)) return res.status(400).json({ success: false, message: "status는 'confirmed' 또는 'change_request'만 허용" });
|
||||
const change_details = req.body.change_details || null;
|
||||
if (status === 'change_request' && !change_details) {
|
||||
return res.status(400).json({ success: false, message: '수정 내용을 입력해주세요.' });
|
||||
}
|
||||
|
||||
// 요약 통계 계산
|
||||
const compData = await buildComparisonData(userId, parseInt(year), parseInt(month));
|
||||
|
||||
let notificationData = null;
|
||||
if (status === 'rejected') {
|
||||
if (status === 'change_request') {
|
||||
const worker = await Model.getWorkerInfo(userId);
|
||||
const recipients = await Model.getSupportTeamUsers();
|
||||
notificationData = {
|
||||
recipients,
|
||||
title: '월간 근무 내역 이의 제기',
|
||||
message: `${worker?.worker_name || '작업자'}(${worker?.department_name || ''})님이 ${year}년 ${month}월 근무 내역에 이의를 제기했습니다. 사유: ${reject_reason}`,
|
||||
linkUrl: `/pages/attendance/monthly-comparison.html?user_id=${userId}&year=${year}&month=${month}`,
|
||||
title: '월간 근무 수정요청',
|
||||
message: `${worker?.worker_name || '작업자'}(${worker?.department_name || ''})님이 ${year}년 ${month}월 근무 내역 수정을 요청했습니다.`,
|
||||
linkUrl: `/pages/attendance/monthly-comparison.html?mode=detail&user_id=${userId}&year=${year}&month=${month}`,
|
||||
createdBy: userId
|
||||
};
|
||||
}
|
||||
|
||||
const result = await Model.upsertConfirmation({
|
||||
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
|
||||
}, notificationData);
|
||||
|
||||
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 });
|
||||
} catch (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+)
|
||||
getAllStatus: async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -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;
|
||||
@@ -48,7 +48,7 @@ async function runStartupMigrations() {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
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) {
|
||||
const sqlPath = path.join(__dirname, 'db', 'migrations', file);
|
||||
if (!fs.existsSync(sqlPath)) continue;
|
||||
|
||||
@@ -86,23 +86,37 @@ const MonthlyComparisonModel = {
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
|
||||
// 기존 상태 체크
|
||||
// 기존 상태 체크 + 전환 검증
|
||||
const [existing] = await conn.query(
|
||||
'SELECT id, status FROM monthly_work_confirmations WHERE user_id = ? AND year = ? AND 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();
|
||||
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
|
||||
const [result] = await conn.query(`
|
||||
INSERT INTO monthly_work_confirmations
|
||||
(user_id, year, month, status, total_work_days, total_work_hours,
|
||||
total_overtime_hours, vacation_days, mismatch_count, reject_reason,
|
||||
confirmed_at, rejected_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
confirmed_at, rejected_at, change_details)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
status = VALUES(status),
|
||||
total_work_days = VALUES(total_work_days),
|
||||
@@ -112,20 +126,22 @@ const MonthlyComparisonModel = {
|
||||
mismatch_count = VALUES(mismatch_count),
|
||||
reject_reason = VALUES(reject_reason),
|
||||
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.total_work_days || 0, data.total_work_hours || 0,
|
||||
data.total_overtime_hours || 0, data.vacation_days || 0,
|
||||
data.mismatch_count || 0, data.reject_reason || 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);
|
||||
|
||||
// 반려 시 알림 생성
|
||||
if (data.status === 'rejected' && notificationData && confirmationId) {
|
||||
// 알림 생성 (반려 또는 수정요청)
|
||||
if (notificationData && confirmationId) {
|
||||
const { recipients, title, message, linkUrl, createdBy } = notificationData;
|
||||
for (const recipientId of recipients) {
|
||||
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. 전체 작업자 확인 현황
|
||||
async getAllStatus(year, month, departmentId) {
|
||||
const db = await getDb();
|
||||
|
||||
@@ -23,6 +23,12 @@ router.get('/records', ctrl.getRecords);
|
||||
// 확인/반려
|
||||
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+)
|
||||
router.get('/all-status', requireSupportTeam, ctrl.getAllStatus);
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
/* 사용자 정보 */
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user