관리자가 개인 작업자 detail 페이지에서 수정요청(change_request) 내역을 확인하고 승인/거부할 수 있도록 UI 추가. admin 리스트에도 수정 내역 요약 표시. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
625 lines
26 KiB
JavaScript
625 lines
26 KiB
JavaScript
// controllers/monthlyComparisonController.js — 월간 비교·확인·정산
|
|
const Model = require('../models/monthlyComparisonModel');
|
|
const logger = require('../utils/logger');
|
|
|
|
const ADMIN_ROLES = ['support_team', 'admin', 'system'];
|
|
|
|
// 일별 비교 상태 판정
|
|
function determineStatus(report, attendance, isHoliday) {
|
|
const hasReport = report && report.total_hours > 0;
|
|
const hasAttendance = attendance && attendance.total_work_hours > 0;
|
|
const isVacation = attendance && attendance.vacation_type_id;
|
|
|
|
if (isHoliday && !hasReport && !hasAttendance) return 'holiday';
|
|
if (isVacation) return 'vacation';
|
|
if (!hasReport && !hasAttendance) return 'none';
|
|
if (hasReport && !hasAttendance) return 'report_only';
|
|
if (!hasReport && hasAttendance) return 'attend_only';
|
|
|
|
const diff = Math.abs(report.total_hours - attendance.total_work_hours);
|
|
return diff <= 0.5 ? 'match' : 'mismatch';
|
|
}
|
|
|
|
// 날짜별 비교 데이터 생성
|
|
async function buildComparisonData(userId, year, month) {
|
|
const [reports, attendances, confirmation, holidays] = await Promise.all([
|
|
Model.getWorkReports(userId, year, month),
|
|
Model.getAttendanceRecords(userId, year, month),
|
|
Model.getConfirmation(userId, year, month),
|
|
Model.getCompanyHolidays(year, month)
|
|
]);
|
|
|
|
// 날짜 맵 생성
|
|
const reportMap = {};
|
|
reports.forEach(r => {
|
|
const key = r.report_date instanceof Date
|
|
? r.report_date.toISOString().split('T')[0]
|
|
: String(r.report_date).split('T')[0];
|
|
reportMap[key] = r;
|
|
});
|
|
|
|
const attendMap = {};
|
|
attendances.forEach(a => {
|
|
const key = a.record_date instanceof Date
|
|
? a.record_date.toISOString().split('T')[0]
|
|
: String(a.record_date).split('T')[0];
|
|
attendMap[key] = a;
|
|
});
|
|
|
|
// 해당 월의 모든 날짜 생성
|
|
const daysInMonth = new Date(year, month, 0).getDate();
|
|
const DAYS_KR = ['일', '월', '화', '수', '목', '금', '토'];
|
|
const dailyRecords = [];
|
|
let totalWorkDays = 0, totalWorkHours = 0, totalOvertimeHours = 0;
|
|
let vacationDays = 0, mismatchCount = 0;
|
|
const mismatchDetails = { hours_diff: 0, missing_report: 0, missing_attendance: 0 };
|
|
|
|
for (let day = 1; day <= daysInMonth; day++) {
|
|
const date = new Date(year, month - 1, day);
|
|
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
|
const dayOfWeek = date.getDay();
|
|
const isHoliday = dayOfWeek === 0 || dayOfWeek === 6 || holidays.dateSet.has(dateStr);
|
|
|
|
const report = reportMap[dateStr] || null;
|
|
const attend = attendMap[dateStr] || null;
|
|
const status = determineStatus(report, attend, isHoliday);
|
|
|
|
let hoursDiff = 0;
|
|
if (report && attend && report.total_hours && attend.total_work_hours) {
|
|
hoursDiff = parseFloat((report.total_hours - attend.total_work_hours).toFixed(2));
|
|
}
|
|
|
|
// 통계
|
|
if (status === 'match' || status === 'mismatch') {
|
|
totalWorkDays++;
|
|
totalWorkHours += parseFloat(attend?.total_work_hours || report?.total_hours || 0);
|
|
}
|
|
if (status === 'report_only') { totalWorkDays++; totalWorkHours += parseFloat(report.total_hours || 0); }
|
|
if (status === 'attend_only') { totalWorkDays++; totalWorkHours += parseFloat(attend.total_work_hours || 0); }
|
|
if (status === 'vacation' && attend) { vacationDays += parseFloat(attend.vacation_days) || 1; }
|
|
if (status === 'mismatch') { mismatchCount++; mismatchDetails.hours_diff++; }
|
|
if (status === 'report_only') { mismatchCount++; mismatchDetails.missing_attendance++; }
|
|
if (status === 'attend_only') { mismatchCount++; mismatchDetails.missing_report++; }
|
|
|
|
// 연장근로: 8h 초과분
|
|
if (attend && attend.total_work_hours > 8) {
|
|
totalOvertimeHours += parseFloat(attend.total_work_hours) - 8;
|
|
}
|
|
|
|
dailyRecords.push({
|
|
date: dateStr,
|
|
day_of_week: DAYS_KR[dayOfWeek],
|
|
is_holiday: isHoliday,
|
|
holiday_name: holidays.nameMap[dateStr] || (dayOfWeek === 0 || dayOfWeek === 6 ? '주말' : null),
|
|
work_report: report ? {
|
|
total_hours: parseFloat(report.total_hours),
|
|
entries: [{ project_name: report.project_names || '', work_type: report.work_type_names || '', hours: parseFloat(report.total_hours) }]
|
|
} : null,
|
|
attendance: attend ? {
|
|
total_work_hours: parseFloat(attend.total_work_hours),
|
|
attendance_type: attend.attendance_type_name || '',
|
|
attendance_type_id: attend.attendance_type_id || null,
|
|
vacation_type: attend.vacation_type_name || null,
|
|
vacation_type_id: attend.vacation_type_id || null,
|
|
vacation_days: attend.vacation_days ? parseFloat(attend.vacation_days) : null
|
|
} : null,
|
|
status,
|
|
hours_diff: hoursDiff
|
|
});
|
|
}
|
|
|
|
return {
|
|
summary: {
|
|
total_work_days: totalWorkDays,
|
|
total_work_hours: parseFloat(totalWorkHours.toFixed(2)),
|
|
total_overtime_hours: parseFloat(totalOvertimeHours.toFixed(2)),
|
|
vacation_days: vacationDays,
|
|
mismatch_count: mismatchCount,
|
|
mismatch_details: mismatchDetails
|
|
},
|
|
confirmation: confirmation ? {
|
|
status: confirmation.status,
|
|
confirmed_at: confirmation.confirmed_at,
|
|
rejected_at: confirmation.rejected_at,
|
|
reject_reason: confirmation.reject_reason,
|
|
change_details: confirmation.change_details || null,
|
|
admin_checked: confirmation.admin_checked || 0
|
|
} : { status: 'pending', confirmed_at: null, reject_reason: null, change_details: null, admin_checked: 0 },
|
|
daily_records: dailyRecords
|
|
};
|
|
}
|
|
|
|
const MonthlyComparisonController = {
|
|
// GET /my-records
|
|
getMyRecords: async (req, res) => {
|
|
try {
|
|
const userId = req.user.user_id || req.user.id;
|
|
const { year, month } = req.query;
|
|
if (!year || !month) return res.status(400).json({ success: false, message: 'year, month 필수' });
|
|
|
|
const worker = await Model.getWorkerInfo(userId);
|
|
const data = await buildComparisonData(userId, parseInt(year), parseInt(month));
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
user: worker || { user_id: userId },
|
|
period: { year: parseInt(year), month: parseInt(month) },
|
|
...data
|
|
}
|
|
});
|
|
} catch (err) {
|
|
logger.error('monthlyComparison getMyRecords error:', err);
|
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
|
}
|
|
},
|
|
|
|
// GET /records (관리자용)
|
|
getRecords: async (req, res) => {
|
|
try {
|
|
const { year, month, user_id } = req.query;
|
|
if (!year || !month || !user_id) return res.status(400).json({ success: false, message: 'year, month, user_id 필수' });
|
|
|
|
const reqUserId = req.user.user_id || req.user.id;
|
|
const targetUserId = parseInt(user_id);
|
|
|
|
// 본인 아니면 support_team 이상 필요
|
|
if (targetUserId !== reqUserId && !ADMIN_ROLES.includes(req.user.role)) {
|
|
return res.status(403).json({ success: false, message: '접근 권한이 없습니다.' });
|
|
}
|
|
|
|
const worker = await Model.getWorkerInfo(targetUserId);
|
|
const data = await buildComparisonData(targetUserId, parseInt(year), parseInt(month));
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
user: worker || { user_id: targetUserId },
|
|
period: { year: parseInt(year), month: parseInt(month) },
|
|
...data
|
|
}
|
|
});
|
|
} catch (err) {
|
|
logger.error('monthlyComparison getRecords error:', err);
|
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
|
}
|
|
},
|
|
|
|
// POST /confirm
|
|
confirm: async (req, res) => {
|
|
try {
|
|
const userId = req.user.user_id || req.user.id;
|
|
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', '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 === '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}월 근무 내역 수정을 요청했습니다.`,
|
|
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: 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' ? '확인이 완료되었습니다.' : '수정요청이 접수되었습니다. 관리자에게 알림이 전달됩니다.';
|
|
res.json({ success: true, data: result, message: msg });
|
|
} catch (err) {
|
|
logger.error('monthlyComparison confirm error:', err);
|
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
|
}
|
|
},
|
|
|
|
// 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: '서버 오류가 발생했습니다.' });
|
|
}
|
|
},
|
|
|
|
// 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 {
|
|
const { year, month, department_id } = req.query;
|
|
if (!year || !month) return res.status(400).json({ success: false, message: 'year, month 필수' });
|
|
|
|
const workers = await Model.getAllStatus(parseInt(year), parseInt(month), department_id ? parseInt(department_id) : null);
|
|
|
|
let confirmed = 0, pending = 0, rejected = 0, review_sent = 0, change_request = 0;
|
|
workers.forEach(w => {
|
|
if (w.status === 'confirmed') confirmed++;
|
|
else if (w.status === 'rejected') rejected++;
|
|
else if (w.status === 'review_sent') review_sent++;
|
|
else if (w.status === 'change_request') change_request++;
|
|
else pending++;
|
|
});
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
period: { year: parseInt(year), month: parseInt(month) },
|
|
summary: { total_workers: workers.length, confirmed, pending, rejected, review_sent, change_request },
|
|
workers: workers.map(w => ({
|
|
user_id: w.user_id, worker_name: w.worker_name, job_type: w.job_type,
|
|
department_name: w.department_name, status: w.status || 'pending',
|
|
confirmed_at: w.confirmed_at, reject_reason: w.reject_reason,
|
|
change_details: w.change_details || null,
|
|
admin_checked: w.admin_checked || 0,
|
|
total_work_days: w.total_work_days || 0,
|
|
total_work_hours: parseFloat(w.total_work_hours || 0),
|
|
total_overtime_hours: parseFloat(w.total_overtime_hours || 0),
|
|
vacation_days: parseFloat(w.vacation_days || 0),
|
|
mismatch_count: w.mismatch_count || 0
|
|
}))
|
|
}
|
|
});
|
|
} catch (err) {
|
|
logger.error('monthlyComparison getAllStatus error:', err);
|
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
|
}
|
|
},
|
|
|
|
// GET /export (support_team+, 전원 confirmed일 때만)
|
|
// 출근부 양식 — 업로드된 템플릿 매칭
|
|
exportExcel: async (req, res) => {
|
|
try {
|
|
const { year, month } = req.query;
|
|
if (!year || !month) return res.status(400).json({ success: false, message: 'year, month 필수' });
|
|
|
|
const y = parseInt(year), m = parseInt(month);
|
|
|
|
// 전원 confirmed 체크
|
|
const statusList = await Model.getAllStatus(y, m);
|
|
const notConfirmed = statusList.filter(w => !w.status || w.status !== 'confirmed');
|
|
if (notConfirmed.length > 0) {
|
|
const pendingCount = notConfirmed.filter(w => !w.status || w.status === 'pending').length;
|
|
const rejectedCount = notConfirmed.filter(w => w.status === 'rejected').length;
|
|
const parts = [];
|
|
if (pendingCount > 0) parts.push(`${pendingCount}명 미확인`);
|
|
if (rejectedCount > 0) parts.push(`${rejectedCount}명 반려`);
|
|
return res.status(403).json({ success: false, message: `${parts.join(', ')} 상태입니다. 전원 확인 완료 후 다운로드 가능합니다.` });
|
|
}
|
|
|
|
// 데이터 조회
|
|
const { workers, attendance, vacations } = await Model.getExportData(y, m);
|
|
if (workers.length === 0) {
|
|
return res.status(404).json({ success: false, message: '해당 월 데이터가 없습니다.' });
|
|
}
|
|
|
|
// 근태 맵 구성: { user_id -> { day -> record } }
|
|
const attendMap = {};
|
|
attendance.forEach(a => {
|
|
const uid = a.user_id;
|
|
if (!attendMap[uid]) attendMap[uid] = {};
|
|
const d = a.record_date instanceof Date ? a.record_date : new Date(a.record_date);
|
|
const day = d.getDate();
|
|
attendMap[uid][day] = a;
|
|
});
|
|
|
|
// 연차 맵: { user_id -> { total, used, remaining } }
|
|
const vacMap = {};
|
|
vacations.forEach(v => { vacMap[v.user_id] = v; });
|
|
|
|
// 월 정보
|
|
const daysInMonth = new Date(y, m, 0).getDate();
|
|
const DAYS_KR = ['일', '월', '화', '수', '목', '금', '토'];
|
|
const yy = String(y).slice(-2);
|
|
const deptName = workers[0]?.department_name || '생산팀';
|
|
|
|
// 휴가 유형 → 출근부 텍스트 매핑
|
|
const VAC_TEXT = {
|
|
'ANNUAL': '연차', 'HALF_ANNUAL': '반차', 'ANNUAL_QUARTER': '반반차',
|
|
'EARLY_LEAVE': '조퇴', 'SICK': '병가', 'SPECIAL': '경조사'
|
|
};
|
|
|
|
const ExcelJS = require('exceljs');
|
|
const wb = new ExcelJS.Workbook();
|
|
const ws = wb.addWorksheet(`${yy}.${m}월 출근부`);
|
|
|
|
// ── 기본 스타일 ──
|
|
const FONT = { name: '맑은 고딕', size: 12 };
|
|
const CENTER = { horizontal: 'center', vertical: 'middle', wrapText: true };
|
|
const THIN_BORDER = {
|
|
top: { style: 'thin' }, bottom: { style: 'thin' },
|
|
left: { style: 'thin' }, right: { style: 'thin' }
|
|
};
|
|
const RED_FONT = { ...FONT, color: { argb: 'FFFF0000' } };
|
|
|
|
// ── 열 폭 설정 ──
|
|
// A=이름(10), B=담당(6), C~(daysInMonth)=날짜열(5), 총시간(8), 신규(8), 사용(8), 잔여(8), 비고(14)
|
|
const dayColStart = 3; // C열 = col 3
|
|
const dayColEnd = dayColStart + daysInMonth - 1;
|
|
const colTotal = dayColEnd + 1; // 총시간
|
|
const colNewVac = colTotal + 1; // N월 신규
|
|
const colUsedVac = colNewVac + 1; // N월 사용
|
|
const colRemVac = colUsedVac + 1; // N월 잔여
|
|
const colNote1 = colRemVac + 1; // 비고 (2열 병합)
|
|
const colNote2 = colNote1 + 1;
|
|
const lastCol = colNote2;
|
|
|
|
ws.getColumn(1).width = 10; // A 이름
|
|
ws.getColumn(2).width = 6; // B 담당
|
|
for (let c = dayColStart; c <= dayColEnd; c++) ws.getColumn(c).width = 5;
|
|
ws.getColumn(colTotal).width = 8;
|
|
ws.getColumn(colNewVac).width = 8;
|
|
ws.getColumn(colUsedVac).width = 8;
|
|
ws.getColumn(colRemVac).width = 8;
|
|
ws.getColumn(colNote1).width = 7;
|
|
ws.getColumn(colNote2).width = 7;
|
|
|
|
// ── Row 1: 빈 행 (여백) ──
|
|
ws.getRow(1).height = 10;
|
|
|
|
// ── Row 2: 부서명 ──
|
|
ws.getRow(2).height = 30;
|
|
ws.getCell(2, 1).value = '부서명';
|
|
ws.getCell(2, 1).font = { ...FONT, bold: true };
|
|
ws.getCell(2, 1).alignment = CENTER;
|
|
ws.mergeCells(2, 2, 2, 5);
|
|
ws.getCell(2, 2).value = deptName;
|
|
ws.getCell(2, 2).font = FONT;
|
|
ws.getCell(2, 2).alignment = { ...CENTER, horizontal: 'left' };
|
|
|
|
// ── Row 3: 근로기간 ──
|
|
ws.getRow(3).height = 30;
|
|
ws.getCell(3, 1).value = '근로기간';
|
|
ws.getCell(3, 1).font = { ...FONT, bold: true };
|
|
ws.getCell(3, 1).alignment = CENTER;
|
|
ws.mergeCells(3, 2, 3, 5);
|
|
ws.getCell(3, 2).value = `${y}년 ${m}월`;
|
|
ws.getCell(3, 2).font = FONT;
|
|
ws.getCell(3, 2).alignment = { ...CENTER, horizontal: 'left' };
|
|
|
|
// ── Row 4-5: 헤더 (병합) ──
|
|
ws.getRow(4).height = 40;
|
|
ws.getRow(5).height = 40;
|
|
|
|
// A4:A5 이름
|
|
ws.mergeCells(4, 1, 5, 1);
|
|
ws.getCell(4, 1).value = '이름';
|
|
ws.getCell(4, 1).font = { ...FONT, bold: true };
|
|
ws.getCell(4, 1).alignment = CENTER;
|
|
ws.getCell(4, 1).border = THIN_BORDER;
|
|
|
|
// B4:B5 담당
|
|
ws.mergeCells(4, 2, 5, 2);
|
|
ws.getCell(4, 2).value = '담당';
|
|
ws.getCell(4, 2).font = { ...FONT, bold: true };
|
|
ws.getCell(4, 2).alignment = CENTER;
|
|
ws.getCell(4, 2).border = THIN_BORDER;
|
|
|
|
// 날짜 헤더: Row4=일자, Row5=요일
|
|
for (let day = 1; day <= daysInMonth; day++) {
|
|
const col = dayColStart + day - 1;
|
|
const date = new Date(y, m - 1, day);
|
|
const dow = date.getDay();
|
|
const isWeekend = dow === 0 || dow === 6;
|
|
|
|
// Row 4: 날짜 숫자
|
|
const cell4 = ws.getCell(4, col);
|
|
cell4.value = day;
|
|
cell4.font = isWeekend ? { ...FONT, bold: true, color: { argb: 'FFFF0000' } } : { ...FONT, bold: true };
|
|
cell4.alignment = CENTER;
|
|
cell4.border = THIN_BORDER;
|
|
|
|
// Row 5: 요일
|
|
const cell5 = ws.getCell(5, col);
|
|
cell5.value = DAYS_KR[dow];
|
|
cell5.font = isWeekend ? RED_FONT : FONT;
|
|
cell5.alignment = CENTER;
|
|
cell5.border = THIN_BORDER;
|
|
}
|
|
|
|
// 총시간 헤더 (4:5 병합)
|
|
ws.mergeCells(4, colTotal, 5, colTotal);
|
|
ws.getCell(4, colTotal).value = '총시간';
|
|
ws.getCell(4, colTotal).font = { ...FONT, bold: true };
|
|
ws.getCell(4, colTotal).alignment = CENTER;
|
|
ws.getCell(4, colTotal).border = THIN_BORDER;
|
|
|
|
// 연차 헤더: Row4 = "N월" 병합, Row5 = 신규/사용/잔여
|
|
ws.mergeCells(4, colNewVac, 4, colRemVac);
|
|
ws.getCell(4, colNewVac).value = `${m}월`;
|
|
ws.getCell(4, colNewVac).font = { ...FONT, bold: true };
|
|
ws.getCell(4, colNewVac).alignment = CENTER;
|
|
ws.getCell(4, colNewVac).border = THIN_BORDER;
|
|
|
|
ws.getCell(5, colNewVac).value = '신규';
|
|
ws.getCell(5, colNewVac).font = { ...FONT, bold: true };
|
|
ws.getCell(5, colNewVac).alignment = CENTER;
|
|
ws.getCell(5, colNewVac).border = THIN_BORDER;
|
|
|
|
ws.getCell(5, colUsedVac).value = '사용';
|
|
ws.getCell(5, colUsedVac).font = { ...FONT, bold: true };
|
|
ws.getCell(5, colUsedVac).alignment = CENTER;
|
|
ws.getCell(5, colUsedVac).border = THIN_BORDER;
|
|
|
|
ws.getCell(5, colRemVac).value = '잔여';
|
|
ws.getCell(5, colRemVac).font = { ...FONT, bold: true };
|
|
ws.getCell(5, colRemVac).alignment = CENTER;
|
|
ws.getCell(5, colRemVac).border = THIN_BORDER;
|
|
|
|
// 비고 헤더 (4:5, 2열 병합)
|
|
ws.mergeCells(4, colNote1, 5, colNote2);
|
|
ws.getCell(4, colNote1).value = '비고';
|
|
ws.getCell(4, colNote1).font = { ...FONT, bold: true };
|
|
ws.getCell(4, colNote1).alignment = CENTER;
|
|
ws.getCell(4, colNote1).border = THIN_BORDER;
|
|
|
|
// ── 데이터 행 ──
|
|
const dataStartRow = 6;
|
|
workers.forEach((worker, idx) => {
|
|
const row = dataStartRow + idx;
|
|
ws.getRow(row).height = 60;
|
|
const userAttend = attendMap[worker.user_id] || {};
|
|
const userVac = vacMap[worker.user_id] || { total_days: 0, used_days: 0, remaining_days: 0 };
|
|
|
|
// A: 이름
|
|
const cellName = ws.getCell(row, 1);
|
|
cellName.value = worker.worker_name;
|
|
cellName.font = FONT;
|
|
cellName.alignment = CENTER;
|
|
cellName.border = THIN_BORDER;
|
|
|
|
// B: 직종
|
|
const cellJob = ws.getCell(row, 2);
|
|
cellJob.value = worker.job_type || '';
|
|
cellJob.font = FONT;
|
|
cellJob.alignment = CENTER;
|
|
cellJob.border = THIN_BORDER;
|
|
|
|
// C~: 일별 데이터
|
|
for (let day = 1; day <= daysInMonth; day++) {
|
|
const col = dayColStart + day - 1;
|
|
const cell = ws.getCell(row, col);
|
|
const date = new Date(y, m - 1, day);
|
|
const dow = date.getDay();
|
|
const isWeekend = dow === 0 || dow === 6;
|
|
const rec = userAttend[day];
|
|
|
|
if (rec && rec.vacation_type_code) {
|
|
// 휴가
|
|
cell.value = VAC_TEXT[rec.vacation_type_code] || rec.vacation_type_name || '휴가';
|
|
cell.font = FONT;
|
|
} else if (isWeekend && (!rec || !rec.total_work_hours || parseFloat(rec.total_work_hours) === 0)) {
|
|
// 주말 + 근무 없음 → 휴무
|
|
cell.value = '휴무';
|
|
cell.font = FONT;
|
|
} else if (rec && parseFloat(rec.total_work_hours) > 0) {
|
|
// 정상 출근 → 근무시간(숫자)
|
|
cell.value = parseFloat(rec.total_work_hours);
|
|
cell.numFmt = '0.00';
|
|
cell.font = FONT;
|
|
} else {
|
|
// 데이터 없음 (평일 미출근 등)
|
|
cell.value = '';
|
|
cell.font = FONT;
|
|
}
|
|
cell.alignment = CENTER;
|
|
cell.border = THIN_BORDER;
|
|
}
|
|
|
|
// 총시간 = SUM 수식 (C열~마지막 날짜열)
|
|
const firstDayCol = ws.getColumn(dayColStart).letter;
|
|
const lastDayCol = ws.getColumn(dayColEnd).letter;
|
|
const cellTotal = ws.getCell(row, colTotal);
|
|
cellTotal.value = { formula: `SUM(${firstDayCol}${row}:${lastDayCol}${row})` };
|
|
cellTotal.numFmt = '0.00';
|
|
cellTotal.font = { ...FONT, bold: true };
|
|
cellTotal.alignment = CENTER;
|
|
cellTotal.border = THIN_BORDER;
|
|
|
|
// 연차 신규
|
|
const cellNew = ws.getCell(row, colNewVac);
|
|
cellNew.value = parseFloat(userVac.total_days) || 0;
|
|
cellNew.numFmt = '0.0';
|
|
cellNew.font = FONT;
|
|
cellNew.alignment = CENTER;
|
|
cellNew.border = THIN_BORDER;
|
|
|
|
// 연차 사용
|
|
const cellUsed = ws.getCell(row, colUsedVac);
|
|
cellUsed.value = parseFloat(userVac.used_days) || 0;
|
|
cellUsed.numFmt = '0.0';
|
|
cellUsed.font = FONT;
|
|
cellUsed.alignment = CENTER;
|
|
cellUsed.border = THIN_BORDER;
|
|
|
|
// 연차 잔여 = 수식(신규 - 사용)
|
|
const newCol = ws.getColumn(colNewVac).letter;
|
|
const usedCol = ws.getColumn(colUsedVac).letter;
|
|
const cellRem = ws.getCell(row, colRemVac);
|
|
cellRem.value = { formula: `${newCol}${row}-${usedCol}${row}` };
|
|
cellRem.numFmt = '0.0';
|
|
cellRem.font = { ...FONT, bold: true };
|
|
cellRem.alignment = CENTER;
|
|
cellRem.border = THIN_BORDER;
|
|
|
|
// 비고 (병합)
|
|
ws.mergeCells(row, colNote1, row, colNote2);
|
|
const cellNote = ws.getCell(row, colNote1);
|
|
cellNote.value = '';
|
|
cellNote.font = FONT;
|
|
cellNote.alignment = CENTER;
|
|
cellNote.border = THIN_BORDER;
|
|
});
|
|
|
|
// ── 응답 ──
|
|
const filename = encodeURIComponent(`출근부_${y}.${String(m).padStart(2, '0')}_${deptName}.xlsx`);
|
|
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
|
await wb.xlsx.write(res);
|
|
res.end();
|
|
} catch (err) {
|
|
logger.error('monthlyComparison exportExcel error:', err);
|
|
res.status(500).json({ success: false, message: '엑셀 생성 실패' });
|
|
}
|
|
}
|
|
};
|
|
|
|
module.exports = MonthlyComparisonController;
|