fix(monthly-comparison): 0일 0h 수정 + 관리자 검토 태깅

- getAllStatus: daily_attendance_records JOIN으로 실제 근무일/시간 집계
- vacation_days: vacation_types.deduct_days SUM (반차 0.5 정확 반영)
- admin_checked 컬럼 + POST /admin-check API (upsert 패턴)
- 상태 뱃지 라벨: 미검토/확인요청/수정요청/반려/확인완료

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

View File

@@ -262,6 +262,20 @@ const MonthlyComparisonController = {
}
},
// POST /admin-check (관리자 개별 검토 태깅)
adminCheck: async (req, res) => {
try {
const { user_id, year, month, checked } = req.body;
if (!user_id || !year || !month) return res.status(400).json({ success: false, message: 'user_id, year, month 필수' });
const checkedBy = req.user.user_id || req.user.id;
const result = await Model.adminCheck(parseInt(user_id), parseInt(year), parseInt(month), !!checked, checkedBy);
res.json({ success: true, data: result, message: checked ? '검토완료 표시' : '검토 해제' });
} catch (err) {
logger.error('monthlyComparison adminCheck error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// GET /all-status (support_team+)
getAllStatus: async (req, res) => {
try {

View File

@@ -4,3 +4,4 @@ ALTER TABLE monthly_work_confirmations
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;
ALTER TABLE monthly_work_confirmations ADD COLUMN IF NOT EXISTS admin_checked TINYINT(1) DEFAULT 0;

View File

@@ -256,7 +256,7 @@ const MonthlyComparisonModel = {
}
},
// 5. 전체 작업자 확인 현황
// 5. 전체 작업자 확인 현황 (실제 근태 데이터 집계 포함)
async getAllStatus(year, month, departmentId) {
const db = await getDb();
let sql = `
@@ -265,15 +265,29 @@ const MonthlyComparisonModel = {
COALESCE(d.department_name, '미배정') AS department_name,
COALESCE(mwc.status, 'pending') AS status,
mwc.confirmed_at, mwc.rejected_at, mwc.reject_reason,
mwc.total_work_days, mwc.total_work_hours,
mwc.total_overtime_hours, mwc.vacation_days, mwc.mismatch_count
mwc.change_details, COALESCE(mwc.admin_checked, 0) AS admin_checked,
COALESCE(att.work_days, 0) AS total_work_days,
COALESCE(att.work_hours, 0) AS total_work_hours,
COALESCE(att.overtime_hours, 0) AS total_overtime_hours,
COALESCE(att.vac_days, 0) AS vacation_days
FROM workers w
LEFT JOIN departments d ON w.department_id = d.department_id
LEFT JOIN monthly_work_confirmations mwc
ON w.user_id = mwc.user_id AND mwc.year = ? AND mwc.month = ?
WHERE w.status = 'active'
LEFT JOIN (
SELECT dar.user_id,
COUNT(CASE WHEN dar.total_work_hours > 0 THEN 1 END) AS work_days,
COALESCE(SUM(dar.total_work_hours), 0) AS work_hours,
COALESCE(SUM(CASE WHEN dar.total_work_hours > 8 THEN dar.total_work_hours - 8 ELSE 0 END), 0) AS overtime_hours,
COALESCE(SUM(CASE WHEN vt.deduct_days IS NOT NULL THEN vt.deduct_days ELSE 0 END), 0) AS vac_days
FROM daily_attendance_records dar
LEFT JOIN vacation_types vt ON dar.vacation_type_id = vt.id
WHERE YEAR(dar.record_date) = ? AND MONTH(dar.record_date) = ?
GROUP BY dar.user_id
) att ON w.user_id = att.user_id
WHERE w.status = 'active' AND w.user_id IS NOT NULL
`;
const params = [year, month];
const params = [year, month, year, month];
if (departmentId) {
sql += ' AND w.department_id = ?';
params.push(departmentId);
@@ -284,6 +298,17 @@ const MonthlyComparisonModel = {
return rows;
},
// 5b. 관리자 개별 검토 태깅
async adminCheck(userId, year, month, checked, checkedBy) {
const db = await getDb();
await db.query(`
INSERT INTO monthly_work_confirmations (user_id, year, month, status, admin_checked)
VALUES (?, ?, ?, 'pending', ?)
ON DUPLICATE KEY UPDATE admin_checked = ?
`, [userId, year, month, checked ? 1 : 0, checked ? 1 : 0]);
return { admin_checked: checked };
},
// 6. 지원팀 사용자 목록 (알림 수신자)
async getSupportTeamUsers() {
const db = await getDb();

View File

@@ -29,6 +29,9 @@ router.post('/review-send', requireSupportTeam, ctrl.reviewSend);
// 관리자: 수정요청 응답 (change_request → review_sent 또는 rejected)
router.post('/review-respond', requireSupportTeam, ctrl.reviewRespond);
// 관리자: 개별 검토 태깅
router.post('/admin-check', requireSupportTeam, ctrl.adminCheck);
// 전체 현황 (support_team+)
router.get('/all-status', requireSupportTeam, ctrl.getAllStatus);

View File

@@ -361,9 +361,9 @@ function renderWorkerList(workers) {
}
el.innerHTML = filtered.map(w => {
const statusBadge = `<span class="mc-worker-status-badge ${w.status}">${
{ confirmed: '확인완료', pending: '미확인', rejected: '반려' }[w.status] || ''
}</span>`;
const statusLabels = { confirmed: '확인완료', pending: '미검토', review_sent: '확인요청', change_request: '수정요청', rejected: '반려' };
const statusBadge = `<span class="mc-worker-status-badge ${w.status}">${statusLabels[w.status] || ''}</span>`;
const checkedBadge = w.admin_checked ? ' <span style="color:#10b981;font-size:0.7rem;">✓검토</span>' : '';
const mismatchBadge = w.mismatch_count > 0
? `<span class="mc-worker-mismatch">⚠️ 불일치${w.mismatch_count}</span>` : '';
const rejectReason = w.status === 'rejected' && w.reject_reason
@@ -374,7 +374,7 @@ function renderWorkerList(workers) {
<div class="mc-worker-card" onclick="viewWorkerDetail(${w.user_id})">
<div style="display:flex;justify-content:space-between;align-items:center">
<div>
<div class="mc-worker-name">${escHtml(w.worker_name)} ${mismatchBadge}</div>
<div class="mc-worker-name">${escHtml(w.worker_name)}${checkedBadge} ${mismatchBadge}</div>
<div class="mc-worker-dept">${escHtml(w.department_name)} · ${escHtml(w.job_type)}</div>
</div>
<i class="fas fa-chevron-right text-gray-300"></i>

View File

@@ -162,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=2026040101"></script>
<script src="/js/monthly-comparison.js?v=2026040102"></script>
<script>initAuth();</script>
</body>
</html>