fix(sprint004): 코드 리뷰 반영 — vacation_days 소수 + 이중제출 방지 + deprecated 테이블 전환

- monthlyComparisonModel: vacation_types.deduct_days AS vacation_days 추가
- monthlyComparisonController: vacationDays++ → parseFloat(attend.vacation_days) 소수 지원
- monthly-comparison.js: confirmMonth/submitReject 이중 제출 방지 (isProcessing 플래그)
- vacationBalanceModel: create/update/delete/bulkCreate → sp_vacation_balances + balance_type 매핑

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-03-31 10:42:12 +09:00
parent 1980c83377
commit f3b7f1a34f
4 changed files with 368 additions and 108 deletions

View File

@@ -75,7 +75,7 @@ async function buildComparisonData(userId, year, month) {
}
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') { vacationDays++; }
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++; }
@@ -259,7 +259,8 @@ const MonthlyComparisonController = {
}
},
// GET /export (support_team+, pending 0명일 때만)
// GET /export (support_team+, 전원 confirmed일 때만)
// 출근부 양식 — 업로드된 템플릿 매칭
exportExcel: async (req, res) => {
try {
const { year, month } = req.query;
@@ -267,87 +268,287 @@ const MonthlyComparisonController = {
const y = parseInt(year), m = parseInt(month);
// pending 체크
const workers = await Model.getAllStatus(y, m);
const pendingCount = workers.filter(w => !w.status || w.status === 'pending').length;
if (pendingCount > 0) {
return res.status(403).json({ success: false, message: `${pendingCount}명이 미확인 상태입니다. 전원 확인 후 다운로드 가능합니다.` });
// 전원 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 ExcelJS = require('exceljs');
const workbook = new ExcelJS.Workbook();
// 데이터 조회
const { workers, attendance, vacations } = await Model.getExportData(y, m);
if (workers.length === 0) {
return res.status(404).json({ success: false, message: '해당 월 데이터가 없습니다.' });
}
// Sheet 1: 월간 근무 현황
const sheet1 = workbook.addWorksheet('월간 근무 현황');
const headerStyle = { fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF4472C4' } }, font: { color: { argb: 'FFFFFFFF' }, bold: true }, alignment: { horizontal: 'center' } };
sheet1.columns = [
{ header: 'No', key: 'no', width: 6 },
{ header: '이름', key: 'name', width: 12 },
{ header: '부서', key: 'dept', width: 12 },
{ header: '직종', key: 'job', width: 10 },
{ header: '총근무일', key: 'days', width: 10 },
{ header: '총근무시간', key: 'hours', width: 12 },
{ header: '연장근로(h)', key: 'overtime', width: 12 },
{ header: '휴가(일)', key: 'vacation', width: 10 },
{ header: '확인상태', key: 'status', width: 10 },
{ header: '확인일시', key: 'confirmed', width: 18 },
];
sheet1.getRow(1).eachCell(cell => { Object.assign(cell, headerStyle); });
workers.forEach((w, i) => {
sheet1.addRow({
no: i + 1, name: w.worker_name, dept: w.department_name, job: w.job_type,
days: w.total_work_days || 0, hours: parseFloat(w.total_work_hours || 0),
overtime: parseFloat(w.total_overtime_hours || 0),
vacation: parseFloat(w.vacation_days || 0),
status: { confirmed: '확인', rejected: '반려', pending: '대기' }[w.status || 'pending'],
confirmed: w.confirmed_at ? new Date(w.confirmed_at).toLocaleString('ko') : '-'
});
// 근태 맵 구성: { 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;
});
// Sheet 2: 일별 상세
const sheet2 = workbook.addWorksheet('일별 상세');
sheet2.columns = [
{ header: '이름', key: 'name', width: 12 },
{ header: '날짜', key: 'date', width: 12 },
{ header: '요일', key: 'dow', width: 6 },
{ header: '작업보고서(h)', key: 'report', width: 14 },
{ header: '근태관리(h)', key: 'attend', width: 12 },
{ header: '근태유형', key: 'atype', width: 12 },
{ header: '휴가유형', key: 'vtype', width: 10 },
{ header: '시간차이', key: 'diff', width: 10 },
{ header: '상태', key: 'status', width: 10 },
];
sheet2.getRow(1).eachCell(cell => { Object.assign(cell, headerStyle); });
// 연차 맵: { user_id -> { total, used, remaining } }
const vacMap = {};
vacations.forEach(v => { vacMap[v.user_id] = v; });
const detailData = await Model.getExcelData(y, m);
// 월 정보
const daysInMonth = new Date(y, m, 0).getDate();
const DAYS_KR = ['일', '월', '화', '수', '목', '금', '토'];
detailData.forEach(row => {
if (!row.record_date) return;
const d = new Date(row.record_date);
const diff = (parseFloat(row.report_hours || 0) - parseFloat(row.attendance_hours || 0)).toFixed(2);
const r = sheet2.addRow({
name: row.worker_name, date: row.record_date instanceof Date ? row.record_date.toISOString().split('T')[0] : String(row.record_date).split('T')[0],
dow: DAYS_KR[d.getDay()],
report: parseFloat(row.report_hours || 0),
attend: parseFloat(row.attendance_hours || 0),
atype: row.attendance_type_name || '', vtype: row.vacation_type_name || '',
diff: parseFloat(diff),
status: Math.abs(parseFloat(diff)) > 0.5 ? '불일치' : '일치'
});
if (Math.abs(parseFloat(diff)) > 0.5) {
r.eachCell(cell => { cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFF2CC' } }; });
}
if (row.vacation_type_name) {
r.eachCell(cell => { cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE2EFDA' } }; });
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(`생산팀_월간근무현황_${year}${String(month).padStart(2, '0')}월.xlsx`);
// ── 응답 ──
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 workbook.xlsx.write(res);
await wb.xlsx.write(res);
res.end();
} catch (err) {
logger.error('monthlyComparison exportExcel error:', err);

View File

@@ -36,7 +36,8 @@ const MonthlyComparisonModel = {
dar.is_present,
dar.notes,
wat.type_name AS attendance_type_name,
vt.type_name AS vacation_type_name
vt.type_name AS vacation_type_name,
vt.deduct_days AS vacation_days
FROM daily_attendance_records dar
LEFT JOIN work_attendance_types wat ON dar.attendance_type_id = wat.id
LEFT JOIN vacation_types vt ON dar.vacation_type_id = vt.id
@@ -162,34 +163,56 @@ const MonthlyComparisonModel = {
return rows.map(r => r.user_id);
},
// 7. 엑셀용 전체 일별 상세
async getExcelData(year, month) {
// 7. 출근부 엑셀용 — 작업자 목록 + 일별 근태 + 연차잔액
async getExportData(year, month, departmentId) {
const db = await getDb();
const [rows] = await db.query(`
SELECT
w.worker_name, d.department_name, w.job_type,
dar.record_date,
dar.total_work_hours AS attendance_hours,
wat.type_name AS attendance_type_name,
vt.type_name AS vacation_type_name,
COALESCE(wr.total_hours, 0) AS report_hours
// (a) 해당 부서 활성 작업자 (worker_id 순)
let workerSql = `
SELECT w.user_id, w.worker_id, w.worker_name, w.job_type,
COALESCE(d.department_name, '미배정') AS department_name
FROM workers w
LEFT JOIN departments d ON w.department_id = d.department_id
LEFT JOIN daily_attendance_records dar
ON w.user_id = dar.user_id
AND YEAR(dar.record_date) = ? AND MONTH(dar.record_date) = ?
LEFT JOIN work_attendance_types wat ON dar.attendance_type_id = wat.id
LEFT JOIN vacation_types vt ON dar.vacation_type_id = vt.id
LEFT JOIN (
SELECT user_id, report_date, SUM(work_hours) AS total_hours
FROM daily_work_reports
WHERE YEAR(report_date) = ? AND MONTH(report_date) = ?
GROUP BY user_id, report_date
) wr ON w.user_id = wr.user_id AND dar.record_date = wr.report_date
WHERE w.status = 'active'
ORDER BY d.department_name, w.worker_name, dar.record_date
`, [year, month, year, month]);
return rows;
`;
const workerParams = [];
if (departmentId) { workerSql += ' AND w.department_id = ?'; workerParams.push(departmentId); }
workerSql += ' ORDER BY w.worker_id';
const [workers] = await db.query(workerSql, workerParams);
if (workers.length === 0) return { workers: [], attendance: [], vacations: [] };
const userIds = workers.map(w => w.user_id);
const placeholders = userIds.map(() => '?').join(',');
// (b) 일별 근태 기록
const [attendance] = await db.query(`
SELECT dar.user_id, dar.record_date,
dar.total_work_hours,
dar.attendance_type_id,
dar.vacation_type_id,
vt.type_code AS vacation_type_code,
vt.type_name AS vacation_type_name,
vt.deduct_days
FROM daily_attendance_records dar
LEFT JOIN vacation_types vt ON dar.vacation_type_id = vt.id
WHERE dar.user_id IN (${placeholders})
AND YEAR(dar.record_date) = ? AND MONTH(dar.record_date) = ?
ORDER BY dar.user_id, dar.record_date
`, [...userIds, year, month]);
// (c) 연차 잔액 (sp_vacation_balances)
const [vacations] = await db.query(`
SELECT svb.user_id,
SUM(svb.total_days) AS total_days,
SUM(svb.used_days) AS used_days,
SUM(svb.total_days - svb.used_days) AS remaining_days
FROM sp_vacation_balances svb
WHERE svb.user_id IN (${placeholders}) AND svb.year = ?
GROUP BY svb.user_id
`, [...userIds, year]);
return { workers, attendance, vacations };
},
// 8. 작업자 정보

View File

@@ -77,7 +77,7 @@ const vacationBalanceModel = {
*/
async create(balanceData) {
const db = await getDb();
const [result] = await db.query(`INSERT INTO vacation_balance_details SET ?`, balanceData);
const [result] = await db.query(`INSERT INTO sp_vacation_balances SET ?`, balanceData);
return result;
},
@@ -86,7 +86,7 @@ const vacationBalanceModel = {
*/
async update(id, updateData) {
const db = await getDb();
const [result] = await db.query(`UPDATE vacation_balance_details SET ? WHERE id = ?`, [updateData, id]);
const [result] = await db.query(`UPDATE sp_vacation_balances SET ? WHERE id = ?`, [updateData, id]);
return result;
},
@@ -95,7 +95,7 @@ const vacationBalanceModel = {
*/
async delete(id) {
const db = await getDb();
const [result] = await db.query(`DELETE FROM vacation_balance_details WHERE id = ?`, [id]);
const [result] = await db.query(`DELETE FROM sp_vacation_balances WHERE id = ?`, [id]);
return result;
},
@@ -166,8 +166,8 @@ const vacationBalanceModel = {
}
const db = await getDb();
const query = `INSERT INTO vacation_balance_details
(user_id, vacation_type_id, year, total_days, used_days, notes, created_by)
const query = `INSERT INTO sp_vacation_balances
(user_id, vacation_type_id, year, total_days, used_days, notes, created_by, balance_type)
VALUES ?`;
const values = balances.map(b => [
@@ -177,7 +177,8 @@ const vacationBalanceModel = {
b.total_days || 0,
b.used_days || 0,
b.notes || null,
b.created_by
b.created_by,
b.balance_type || 'AUTO'
]);
const [result] = await db.query(query, [values]);

View File

@@ -282,12 +282,33 @@ function renderConfirmationStatus(conf) {
const statusEl = document.getElementById('confirmedStatus');
const badge = document.getElementById('statusBadge');
if (!conf) { actions.classList.remove('hidden'); statusEl.classList.add('hidden'); return; }
if (!conf) {
// detail 모드(관리자가 타인의 기록 조회)에서는 버튼 숨김
if (currentMode === 'detail') {
actions.classList.add('hidden');
} else {
actions.classList.remove('hidden');
}
statusEl.classList.add('hidden');
return;
}
badge.textContent = { pending: '미확인', confirmed: '확인완료', rejected: '반려' }[conf.status] || '';
badge.className = `mc-status-badge ${conf.status}`;
if (conf.status === 'confirmed') {
if (currentMode === 'detail') {
// 관리자 상세 뷰: 확인/반려 버튼 숨기고 상태만 표시
actions.classList.add('hidden');
if (conf.status !== 'pending') {
statusEl.classList.remove('hidden');
const statusLabel = { confirmed: '확인 완료', rejected: '반려' }[conf.status] || '';
const dt = conf.confirmed_at ? new Date(conf.confirmed_at).toLocaleString('ko') : '';
const reason = conf.reject_reason ? ` (사유: ${conf.reject_reason})` : '';
document.getElementById('confirmedText').textContent = `${dt} ${statusLabel}${reason}`;
} else {
statusEl.classList.add('hidden');
}
} else if (conf.status === 'confirmed') {
actions.classList.add('hidden');
statusEl.classList.remove('hidden');
const dt = conf.confirmed_at ? new Date(conf.confirmed_at).toLocaleString('ko') : '';
@@ -365,22 +386,30 @@ function filterWorkers(status) {
function updateExportButton(summary, workers) {
const btn = document.getElementById('exportBtn');
const note = document.getElementById('exportNote');
const pendingCount = (workers || []).filter(w => w.status === 'pending').length;
const pendingCount = (workers || []).filter(w => !w.status || w.status === 'pending').length;
const rejectedCount = (workers || []).filter(w => w.status === 'rejected').length;
const allConfirmed = pendingCount === 0 && rejectedCount === 0;
if (pendingCount === 0) {
if (allConfirmed) {
btn.disabled = false;
note.textContent = '모든 작업자가 확인을 완료했습니다';
} else {
btn.disabled = true;
const rejectedCount = (workers || []).filter(w => w.status === 'rejected').length;
note.textContent = `${pendingCount}명 미확인${rejectedCount > 0 ? `, ${rejectedCount}명 반려` : ''} — 전원 확인 후 다운로드 가능합니다`;
const parts = [];
if (pendingCount > 0) parts.push(`${pendingCount}명 미확인`);
if (rejectedCount > 0) parts.push(`${rejectedCount}명 반려`);
note.textContent = `${parts.join(', ')} — 전원 확인 후 다운로드 가능합니다`;
}
}
// ===== Actions =====
let isProcessing = false;
async function confirmMonth() {
if (isProcessing) return;
if (!confirm(`${currentYear}${currentMonth}월 근무 내역을 확인하시겠습니까?`)) return;
isProcessing = true;
try {
let res;
if (MOCK_ENABLED) {
@@ -399,6 +428,8 @@ async function confirmMonth() {
}
} catch (e) {
showToast('네트워크 오류', 'error');
} finally {
isProcessing = false;
}
}
@@ -412,12 +443,14 @@ function closeRejectModal() {
}
async function submitReject() {
if (isProcessing) return;
const reason = document.getElementById('rejectReason').value.trim();
if (!reason) {
showToast('반려 사유를 입력해주세요', 'error');
return;
}
isProcessing = true;
try {
let res;
if (MOCK_ENABLED) {
@@ -437,6 +470,8 @@ async function submitReject() {
}
} catch (e) {
showToast('네트워크 오류', 'error');
} finally {
isProcessing = false;
}
}