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;
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 {