diff --git a/system1-factory/api/controllers/monthlyComparisonController.js b/system1-factory/api/controllers/monthlyComparisonController.js index adc3abf..b3ec2a2 100644 --- a/system1-factory/api/controllers/monthlyComparisonController.js +++ b/system1-factory/api/controllers/monthlyComparisonController.js @@ -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); diff --git a/system1-factory/api/models/monthlyComparisonModel.js b/system1-factory/api/models/monthlyComparisonModel.js index 8168c60..4903946 100644 --- a/system1-factory/api/models/monthlyComparisonModel.js +++ b/system1-factory/api/models/monthlyComparisonModel.js @@ -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. 작업자 정보 diff --git a/system1-factory/api/models/vacationBalanceModel.js b/system1-factory/api/models/vacationBalanceModel.js index 7748e4f..230bb25 100644 --- a/system1-factory/api/models/vacationBalanceModel.js +++ b/system1-factory/api/models/vacationBalanceModel.js @@ -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]); diff --git a/system1-factory/web/js/monthly-comparison.js b/system1-factory/web/js/monthly-comparison.js index d39a932..fe6557b 100644 --- a/system1-factory/web/js/monthly-comparison.js +++ b/system1-factory/web/js/monthly-comparison.js @@ -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; } }