From 65e5530a6a10ac5f65edd91bace90bfb0bd01155 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Wed, 1 Apr 2026 08:09:36 +0900 Subject: [PATCH] =?UTF-8?q?feat(sprint005):=20=EC=9B=94=EA=B0=84=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8=20=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C?= =?UTF-8?q?=EC=9A=B0=20=E2=80=94=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=EC=9A=94=EC=B2=AD=20+=20=EC=88=98=EC=A0=95=EC=9A=94?= =?UTF-8?q?=EC=B2=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../monthlyComparisonController.js | 56 ++++++-- .../20260401_monthly_confirm_workflow.sql | 6 + system1-factory/api/index.js | 2 +- .../api/models/monthlyComparisonModel.js | 126 ++++++++++++++++-- .../api/routes/monthlyComparisonRoutes.js | 6 + .../web/css/my-monthly-confirm.css | 4 +- system1-factory/web/js/monthly-comparison.js | 38 +++++- system1-factory/web/js/my-monthly-confirm.js | 63 ++++++--- .../pages/attendance/monthly-comparison.html | 7 +- .../pages/attendance/my-monthly-confirm.html | 4 +- 10 files changed, 273 insertions(+), 39 deletions(-) create mode 100644 system1-factory/api/db/migrations/20260401_monthly_confirm_workflow.sql diff --git a/system1-factory/api/controllers/monthlyComparisonController.js b/system1-factory/api/controllers/monthlyComparisonController.js index aeb10f7..8f75b95 100644 --- a/system1-factory/api/controllers/monthlyComparisonController.js +++ b/system1-factory/api/controllers/monthlyComparisonController.js @@ -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 { diff --git a/system1-factory/api/db/migrations/20260401_monthly_confirm_workflow.sql b/system1-factory/api/db/migrations/20260401_monthly_confirm_workflow.sql new file mode 100644 index 0000000..9786cbf --- /dev/null +++ b/system1-factory/api/db/migrations/20260401_monthly_confirm_workflow.sql @@ -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; diff --git a/system1-factory/api/index.js b/system1-factory/api/index.js index 6e0ee3e..c8594ee 100644 --- a/system1-factory/api/index.js +++ b/system1-factory/api/index.js @@ -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; diff --git a/system1-factory/api/models/monthlyComparisonModel.js b/system1-factory/api/models/monthlyComparisonModel.js index 6799f0d..7a6eae9 100644 --- a/system1-factory/api/models/monthlyComparisonModel.js +++ b/system1-factory/api/models/monthlyComparisonModel.js @@ -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(); diff --git a/system1-factory/api/routes/monthlyComparisonRoutes.js b/system1-factory/api/routes/monthlyComparisonRoutes.js index 4d50780..c6f5d87 100644 --- a/system1-factory/api/routes/monthlyComparisonRoutes.js +++ b/system1-factory/api/routes/monthlyComparisonRoutes.js @@ -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); diff --git a/system1-factory/web/css/my-monthly-confirm.css b/system1-factory/web/css/my-monthly-confirm.css index 8621deb..20037c2 100644 --- a/system1-factory/web/css/my-monthly-confirm.css +++ b/system1-factory/web/css/my-monthly-confirm.css @@ -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; } /* 사용자 정보 */ diff --git a/system1-factory/web/js/monthly-comparison.js b/system1-factory/web/js/monthly-comparison.js index 187b24f..b017970 100644 --- a/system1-factory/web/js/monthly-comparison.js +++ b/system1-factory/web/js/monthly-comparison.js @@ -330,8 +330,22 @@ function renderAdminSummary(s) { document.getElementById('progressText').textContent = `확인 현황: ${s.confirmed || 0}/${total}명 완료`; document.getElementById('statusCounts').innerHTML = `✅ ${s.confirmed || 0} 확인` + - `⏳ ${s.pending || 0} 대기` + + `📩 ${s.review_sent || 0} 확인요청` + + `⏳ ${s.pending || 0} 미검토` + + `📝 ${s.change_request || 0} 수정요청` + `❌ ${s.rejected || 0} 반려`; + + // 확인요청 일괄 발송 버튼 + 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') { diff --git a/system1-factory/web/js/my-monthly-confirm.js b/system1-factory/web/js/my-monthly-confirm.js index c9c4938..0261b9b 100644 --- a/system1-factory/web/js/my-monthly-confirm.js +++ b/system1-factory/web/js/my-monthly-confirm.js @@ -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 = '확인 완료'; + rejectBtn.innerHTML = '수정요청'; + 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 = '동의(재확인)'; + 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 = '수정요청'; + 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 = '수정요청 시 관리자에게 알림이 전달됩니다.'; +} + // ===== 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; } diff --git a/system1-factory/web/pages/attendance/monthly-comparison.html b/system1-factory/web/pages/attendance/monthly-comparison.html index 846a929..a5b4a97 100644 --- a/system1-factory/web/pages/attendance/monthly-comparison.html +++ b/system1-factory/web/pages/attendance/monthly-comparison.html @@ -94,12 +94,15 @@
+
- + + +
@@ -159,7 +162,7 @@ - + diff --git a/system1-factory/web/pages/attendance/my-monthly-confirm.html b/system1-factory/web/pages/attendance/my-monthly-confirm.html index 50f6663..3f2ab71 100644 --- a/system1-factory/web/pages/attendance/my-monthly-confirm.html +++ b/system1-factory/web/pages/attendance/my-monthly-confirm.html @@ -8,7 +8,7 @@ - +
@@ -106,7 +106,7 @@ - +