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 += `
- | ${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)} |
-
`;
+ var md = monthly[m];
+ html += '| ' + m + '월 | ';
+ html += '' + (md.ANNUAL_FULL > 0 ? fmtNum(md.ANNUAL_FULL) : '-') + ' | ';
+ html += '' + (md.ANNUAL_HALF > 0 ? fmtNum(md.ANNUAL_HALF) : '-') + ' | ';
+ html += '' + (md.ANNUAL_QUARTER > 0 ? fmtNum(md.ANNUAL_QUARTER) : '-') + ' | ';
+ html += '' + fmtNum(md.total) + ' |
';
}
- html += `
- | 합계 |
- ${fmtNum(totals.ANNUAL_FULL)} |
- ${fmtNum(totals.ANNUAL_HALF)} |
- ${fmtNum(totals.ANNUAL_QUARTER)} |
- ${fmtNum(grandTotal)} |
-
`;
+ html += '';
+ html += '| 합계 | ';
+ html += '' + fmtNum(totals.ANNUAL_FULL) + ' | ';
+ html += '' + fmtNum(totals.ANNUAL_HALF) + ' | ';
+ html += '' + fmtNum(totals.ANNUAL_QUARTER) + ' | ';
+ html += '' + fmtNum(grandTotal) + ' |
';
html += '
';
- // 상세 내역
+ // 상세 내역 — 월별 아코디언
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 += '
';
+ 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 @@
-
-
-
-
+
+
+
+
+
@@ -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); }