diff --git a/system1-factory/api/db/migrations/20260119120001_create_attendance_tables.js b/system1-factory/api/db/migrations/20260119120001_create_attendance_tables.js index 3f61c12..6bb2e24 100644 --- a/system1-factory/api/db/migrations/20260119120001_create_attendance_tables.js +++ b/system1-factory/api/db/migrations/20260119120001_create_attendance_tables.js @@ -36,7 +36,7 @@ exports.up = async function(knex) { table.increments('id').primary(); table.string('type_code', 20).unique().notNullable().comment('휴가 코드'); table.string('type_name', 50).notNullable().comment('휴가 이름'); - table.decimal('deduct_days', 3, 1).defaultTo(1.0).comment('차감 일수'); + table.decimal('deduct_days', 4, 2).defaultTo(1.00).comment('차감 일수'); table.boolean('is_active').defaultTo(true).comment('활성 여부'); table.timestamp('created_at').defaultTo(knex.fn.now()); table.timestamp('updated_at').defaultTo(knex.fn.now()); diff --git a/system1-factory/api/db/migrations/20260331_fix_deduct_days_precision.sql b/system1-factory/api/db/migrations/20260331_fix_deduct_days_precision.sql new file mode 100644 index 0000000..750f712 --- /dev/null +++ b/system1-factory/api/db/migrations/20260331_fix_deduct_days_precision.sql @@ -0,0 +1,4 @@ +-- vacation_types.deduct_days 정밀도 수정: DECIMAL(3,1) → DECIMAL(4,2) +-- 0.25(반반차)가 0.3으로 반올림되는 문제 해결 +ALTER TABLE vacation_types MODIFY deduct_days DECIMAL(4,2) DEFAULT 1.00; +UPDATE vacation_types SET deduct_days = 0.25 WHERE deduct_days = 0.3 AND type_name = '반반차'; diff --git a/system1-factory/api/index.js b/system1-factory/api/index.js index 9dc9bd8..6e0ee3e 100644 --- a/system1-factory/api/index.js +++ b/system1-factory/api/index.js @@ -48,7 +48,7 @@ async function runStartupMigrations() { const fs = require('fs'); const path = require('path'); const db = await getDb(); - const migrationFiles = ['20260326_schedule_extensions.sql', '20260330_add_proxy_input_fields.sql', '20260330_create_monthly_work_confirmations.sql']; + const migrationFiles = ['20260326_schedule_extensions.sql', '20260330_add_proxy_input_fields.sql', '20260330_create_monthly_work_confirmations.sql', '20260331_fix_deduct_days_precision.sql']; for (const file of migrationFiles) { const sqlPath = path.join(__dirname, 'db', 'migrations', file); if (!fs.existsSync(sqlPath)) continue; diff --git a/system1-factory/api/services/attendanceService.js b/system1-factory/api/services/attendanceService.js index c207724..29bfcd8 100644 --- a/system1-factory/api/services/attendanceService.js +++ b/system1-factory/api/services/attendanceService.js @@ -9,16 +9,21 @@ const AttendanceModel = require('../models/attendanceModel'); const vacationBalanceModel = require('../models/vacationBalanceModel'); +const { getDb } = require('../dbPool'); const { ValidationError, NotFoundError, DatabaseError } = require('../utils/errors'); const logger = require('../utils/logger'); /** - * 휴가 사용 유형 ID를 차감 일수로 변환 - * vacation_type_id: 1=연차(1일), 2=반차(0.5일), 3=반반차(0.25일) + * 휴가 사용 유형 ID → 차감 일수 (DB vacation_types.deduct_days 조회) */ -const getVacationDays = (vacationTypeId) => { - const daysMap = { 1: 1, 2: 0.5, 3: 0.25 }; - return daysMap[vacationTypeId] || 0; +const getVacationDays = async (vacationTypeId) => { + if (!vacationTypeId) return 0; + const db = await getDb(); + const [rows] = await db.execute( + 'SELECT deduct_days FROM vacation_types WHERE id = ?', + [vacationTypeId] + ); + return rows.length > 0 ? parseFloat(rows[0].deduct_days) || 0 : 0; }; /** @@ -143,8 +148,8 @@ const upsertAttendanceRecordService = async (recordData) => { // 3. 휴가 잔액 연동 (vacation_balance_details.used_days 업데이트) const year = new Date(record_date).getFullYear(); - const previousDays = getVacationDays(previousVacationTypeId); - const newDays = getVacationDays(vacation_type_id); + const previousDays = await getVacationDays(previousVacationTypeId); + const newDays = await getVacationDays(vacation_type_id); // 이전 휴가가 있었고 변경된 경우 → 복구 후 차감 if (previousDays !== newDays) { diff --git a/system1-factory/web/pages/attendance/annual-overview.html b/system1-factory/web/pages/attendance/annual-overview.html index 118ac5e..8b621c5 100644 --- a/system1-factory/web/pages/attendance/annual-overview.html +++ b/system1-factory/web/pages/attendance/annual-overview.html @@ -744,13 +744,16 @@ // ===== 월별 사용 내역 모달 ===== async function openMonthlyDetail(userId, workerName) { + currentModalUserId = userId; + currentModalName = workerName; + editBackup = {}; const year = parseInt(document.getElementById('yearSelect').value); - document.getElementById('monthlyDetailTitle').textContent = `${workerName} — ${year}년 연차 사용 내역`; + document.getElementById('monthlyDetailTitle').textContent = workerName + ' \u2014 ' + year + '년 연차 사용 내역'; document.getElementById('monthlyDetailBody').innerHTML = '

로딩 중...

'; document.getElementById('monthlyDetailModal').classList.add('active'); try { - const res = await axios.get(`/attendance/records?start_date=${year}-01-01&end_date=${year}-12-31&user_id=${userId}`); + const res = await axios.get('/attendance/records?start_date=' + year + '-01-01&end_date=' + year + '-12-31&user_id=' + userId); const records = (res.data.data || []).filter(r => r.vacation_type_id); if (records.length === 0) { @@ -759,7 +762,6 @@ } // 월별 그룹핑 - const DAYS_KR = ['일', '월', '화', '수', '목', '금', '토']; const monthly = {}; const totals = { ANNUAL_FULL: 0, ANNUAL_HALF: 0, ANNUAL_QUARTER: 0, other: 0 }; @@ -782,43 +784,45 @@ const grandTotal = totals.ANNUAL_FULL + totals.ANNUAL_HALF + totals.ANNUAL_QUARTER + totals.other; // 월별 요약 테이블 - let html = '

월별 요약

'; + var html = '

월별 요약

'; html += ''; html += ''; html += ''; - for (let m = 1; m <= 12; m++) { + for (var m = 1; m <= 12; m++) { if (!monthly[m]) continue; - const d = monthly[m]; - html += ` - - - - - - `; + var md = monthly[m]; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; } - html += ` - - - - - - `; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; html += '
연차반차반반차합계
${m}월${d.ANNUAL_FULL > 0 ? fmtNum(d.ANNUAL_FULL) : '-'}${d.ANNUAL_HALF > 0 ? fmtNum(d.ANNUAL_HALF) : '-'}${d.ANNUAL_QUARTER > 0 ? fmtNum(d.ANNUAL_QUARTER) : '-'}${fmtNum(d.total)}
' + m + '월' + (md.ANNUAL_FULL > 0 ? fmtNum(md.ANNUAL_FULL) : '-') + '' + (md.ANNUAL_HALF > 0 ? fmtNum(md.ANNUAL_HALF) : '-') + '' + (md.ANNUAL_QUARTER > 0 ? fmtNum(md.ANNUAL_QUARTER) : '-') + '' + fmtNum(md.total) + '
합계${fmtNum(totals.ANNUAL_FULL)}${fmtNum(totals.ANNUAL_HALF)}${fmtNum(totals.ANNUAL_QUARTER)}${fmtNum(grandTotal)}
합계' + fmtNum(totals.ANNUAL_FULL) + '' + fmtNum(totals.ANNUAL_HALF) + '' + fmtNum(totals.ANNUAL_QUARTER) + '' + fmtNum(grandTotal) + '
'; - // 상세 내역 + // 상세 내역 — 월별 아코디언 html += '

상세 내역

'; - html += '
'; - records.sort((a, b) => a.record_date.localeCompare(b.record_date)).forEach(r => { - const d = new Date(r.record_date); - const dateStr = `${d.getMonth()+1}/${String(d.getDate()).padStart(2,'0')}(${DAYS_KR[d.getDay()]})`; - const days = parseFloat(r.vacation_days) || 1; - html += `
- ${dateStr} ${r.vacation_type_name || ''} - ${fmtNum(days)}일 -
`; - }); - html += '
'; + records.sort(function(a, b) { return a.record_date.localeCompare(b.record_date); }); + var firstMonth = true; + for (var mm = 1; mm <= 12; mm++) { + if (!monthly[mm]) continue; + var monthRecords = records.filter(function(r) { return new Date(r.record_date).getMonth() + 1 === mm; }); + var isOpen = firstMonth; + html += '
'; + html += '
'; + html += '' + (isOpen ? '\u25BC' : '\u25B6') + ''; + html += mm + '월 (' + fmtNum(monthly[mm].total) + '일)'; + html += '
'; + html += '
'; + html += monthRecords.map(function(r) { return renderRecordRow(r); }).join(''); + html += '
'; + firstMonth = false; + } document.getElementById('monthlyDetailBody').innerHTML = html; } catch (e) { @@ -826,8 +830,141 @@ } } + function renderRecordRow(r) { + var d = new Date(r.record_date); + var dateStr = (d.getMonth()+1) + '/' + String(d.getDate()).padStart(2,'0') + '(' + DAYS_KR[d.getDay()] + ')'; + var days = parseFloat(r.vacation_days) || 1; + var escapedDate = r.record_date.substring(0, 10); + var escapedName = (r.vacation_type_name || '').replace(/'/g, "\\'"); + return '
' + + '' + dateStr + ' ' + (r.vacation_type_name || '') + '' + + '' + fmtNum(days) + '일' + + '' + + '' + + '' + + '
'; + } + + function toggleMonth(m) { + var body = document.getElementById('month-' + m); + var arrow = document.getElementById('arrow-' + m); + if (body.style.display === 'none') { + body.style.display = 'block'; + arrow.textContent = '\u25BC'; + } else { + body.style.display = 'none'; + arrow.textContent = '\u25B6'; + } + } + + function editRecord(recordId, recordDate, currentTypeId) { + var row = document.getElementById('rec-' + recordId); + if (!row) return; + editBackup[recordId] = { html: row.innerHTML, cls: row.className }; + row.className = 'edit-row'; + row.innerHTML = + '' + + '' + + '' + + ''; + } + + function cancelEdit(recordId) { + var row = document.getElementById('rec-' + recordId); + if (row && editBackup[recordId]) { + row.className = editBackup[recordId].cls; + row.innerHTML = editBackup[recordId].html; + delete editBackup[recordId]; + } + } + + async function saveEdit(recordId, originalDate, originalTypeId) { + var dateEl = document.getElementById('edit-date-' + recordId); + var typeEl = document.getElementById('edit-type-' + recordId); + if (!dateEl || !typeEl) return; + var newDate = dateEl.value; + var newTypeId = parseInt(typeEl.value); + + if (!newDate) { alert('날짜를 선택해주세요.'); return; } + + try { + if (newDate !== originalDate) { + // 새 날짜에 기존 기록 있는지 확인 + var checkRes = await axios.get('/attendance/records?start_date=' + newDate + '&end_date=' + newDate + '&user_id=' + currentModalUserId); + var existing = (checkRes.data.data || []).find(function(r) { return String(r.user_id) === String(currentModalUserId); }); + if (existing && existing.vacation_type_id) { + if (!confirm(newDate + '에 이미 ' + (existing.vacation_type_name || '연차') + ' 기록이 있습니다. 덮어쓰시겠습니까?')) return; + } else if (existing && existing.total_work_hours > 0) { + if (!confirm(newDate + '에 이미 근태 기록이 있습니다. 연차로 변경하시겠습니까?')) return; + } + + // Step 1: 원래 날짜 휴가 제거 + await axios.put('/attendance/records', { + record_date: originalDate, user_id: currentModalUserId, + vacation_type_id: null + }); + + // Step 2: 새 날짜 휴가 등록 + try { + await axios.put('/attendance/records', { + record_date: newDate, user_id: currentModalUserId, + vacation_type_id: newTypeId + }); + } catch (err2) { + // Step 2 실패 → Step 1 롤백 + try { + await axios.put('/attendance/records', { + record_date: originalDate, user_id: currentModalUserId, + vacation_type_id: originalTypeId + }); + alert('새 날짜 등록에 실패하여 원래 상태로 복원했습니다.'); + } catch (rollbackErr) { + alert('오류가 발생했습니다. 원래 날짜(' + originalDate + ')의 연차가 제거된 상태입니다. 관리자에게 문의해주세요.'); + } + return; + } + } else if (newTypeId !== originalTypeId) { + // 유형만 변경 + await axios.put('/attendance/records', { + record_date: originalDate, user_id: currentModalUserId, + vacation_type_id: newTypeId + }); + } else { + cancelEdit(recordId); + return; + } + + // 모달 + 메인 테이블 새로고침 + await openMonthlyDetail(currentModalUserId, currentModalName); + loadData(); + } catch (err) { + alert('저장 중 오류가 발생했습니다: ' + (err.response?.data?.message || err.message)); + } + } + + async function deleteRecord(recordId, recordDate, userId, typeId, dateStr, typeName, days) { + if (!confirm(dateStr + ' ' + typeName + '(' + fmtNum(days) + '일)를 삭제하시겠습니까?')) return; + try { + await axios.put('/attendance/records', { + record_date: recordDate, user_id: userId, + vacation_type_id: null + }); + await openMonthlyDetail(currentModalUserId, currentModalName); + loadData(); + } catch (err) { + alert('삭제 중 오류가 발생했습니다: ' + (err.response?.data?.message || err.message)); + } + } + function closeMonthlyDetail() { document.getElementById('monthlyDetailModal').classList.remove('active'); + currentModalUserId = null; + currentModalName = ''; + editBackup = {}; } document.getElementById('monthlyDetailModal').addEventListener('click', e => { diff --git a/user-management/web/index.html b/user-management/web/index.html index 5c8c982..3a0d539 100644 --- a/user-management/web/index.html +++ b/user-management/web/index.html @@ -898,21 +898,16 @@ -
- - -
+
- +
@@ -930,6 +925,19 @@
+
@@ -2416,7 +2424,7 @@ - + diff --git a/user-management/web/static/js/tkuser-vacations.js b/user-management/web/static/js/tkuser-vacations.js index 3fa3c6f..dddcec5 100644 --- a/user-management/web/static/js/tkuser-vacations.js +++ b/user-management/web/static/js/tkuser-vacations.js @@ -320,23 +320,45 @@ async function autoCalcVacation() { } catch(e) { showToast(e.message, 'error'); } } -// balance_type 변경 시 expires_at 기본값 조정 +// vacation_type_id 자동 매핑 (vacTypes에서 type_code로 찾기) +function getVacTypeId(typeCode) { + const t = vacTypes.find(v => v.type_code === typeCode); + return t ? t.id : null; +} + +// balance_type 변경 시 vacation_type_id + expires_at + 경조사 드롭다운 조정 function onBalanceTypeChange() { const bt = document.getElementById('vbBalanceType').value; const expiresInput = document.getElementById('vbExpiresAt'); - const year = document.getElementById('vacYear')?.value || new Date().getFullYear(); + const specialRow = document.getElementById('vbSpecialTypeRow'); + const year = document.getElementById('vbYear')?.value || document.getElementById('vacYear')?.value || new Date().getFullYear(); - if (bt === 'LONG_SERVICE') { + // vacation_type_id 자동 설정 + const typeMap = { AUTO: 'ANNUAL_FULL', MANUAL: 'ANNUAL_FULL', CARRY_OVER: 'CARRYOVER', LONG_SERVICE: 'LONG_SERVICE' }; + if (typeMap[bt]) { + document.getElementById('vbType').value = getVacTypeId(typeMap[bt]) || ''; + } + + // 경조사 드롭다운 표시/숨김 + if (bt === 'COMPANY_GRANT') { + specialRow.classList.remove('hidden'); + // 경조사 유형으로 vacation_type_id 설정 + const specialCode = document.getElementById('vbSpecialType').value; + document.getElementById('vbType').value = getVacTypeId(specialCode) || getVacTypeId('ANNUAL_FULL') || ''; + } else { + specialRow.classList.add('hidden'); + } + + // 만료일 + if (bt === 'LONG_SERVICE' || bt === 'COMPANY_GRANT') { expiresInput.disabled = true; expiresInput.value = ''; } else { expiresInput.disabled = false; if (bt === 'CARRY_OVER') { - // 해당연 2월말 - const febEnd = new Date(parseInt(year), 2, 0); // month=2, day=0 = last day of Feb + const febEnd = new Date(parseInt(year), 2, 0); expiresInput.value = febEnd.toISOString().substring(0, 10); } else { - // 연말 expiresInput.value = `${year}-12-31`; } } @@ -376,10 +398,10 @@ function openVacBalanceModal(editId) { }); uSel.appendChild(group); }); - // 유형 셀렉트 - const tSel = document.getElementById('vbType'); - tSel.innerHTML = ''; - vacTypes.filter(t => t.is_active).forEach(t => { tSel.innerHTML += ``; }); + // vacation_type_id 자동 설정 (기본: ANNUAL_FULL) + document.getElementById('vbType').value = getVacTypeId('ANNUAL_FULL') || ''; + document.getElementById('vbSpecialTypeRow')?.classList.add('hidden'); + if (editId) { const b = vacBalances.find(x => x.id === editId); if (!b) return; @@ -387,8 +409,7 @@ function openVacBalanceModal(editId) { document.getElementById('vbEditId').value = b.id; uSel.value = b.user_id; uSel.disabled = true; - tSel.value = b.vacation_type_id; - tSel.disabled = true; + document.getElementById('vbType').value = b.vacation_type_id; if (b.balance_type) document.getElementById('vbBalanceType').value = b.balance_type; document.getElementById('vbTotalDays').value = b.total_days; document.getElementById('vbUsedDays').value = b.used_days; @@ -397,15 +418,15 @@ function openVacBalanceModal(editId) { if (b.balance_type === 'LONG_SERVICE') document.getElementById('vbExpiresAt').disabled = true; } else { uSel.disabled = false; - tSel.disabled = false; } + onBalanceTypeChange(); // vacation_type_id + 만료일 + 경조사 드롭다운 설정 document.getElementById('vacBalanceModal').classList.remove('hidden'); } function closeVacBalanceModal() { document.getElementById('vacBalanceModal').classList.add('hidden'); document.getElementById('vbUser').disabled = false; - document.getElementById('vbType').disabled = false; document.getElementById('vbExpiresAt').disabled = false; + document.getElementById('vbSpecialTypeRow')?.classList.add('hidden'); } function editVacBalance(id) { openVacBalanceModal(id); }