fix(tkuser): 연차 배정 모달 간소화 — 휴가유형 드롭다운 제거

- "휴가 유형" 드롭다운 → hidden (vacation_type_id 자동 설정)
- "배정 유형"이 메인 셀렉터: 기본연차/이월/장기근속/경조사
- balance_type별 vacation_type_id 자동 매핑:
  AUTO/MANUAL→ANNUAL_FULL, CARRY_OVER→CARRYOVER, LONG_SERVICE→LONG_SERVICE
- 경조사(COMPANY_GRANT) 선택 시 서브 드롭다운 표시

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-03-31 09:12:33 +09:00
parent 9528a544c6
commit 9bd3888738
7 changed files with 239 additions and 64 deletions

View File

@@ -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());

View File

@@ -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 = '반반차';

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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 = '<p style="text-align:center;color:#9ca3af;padding:1rem;">로딩 중...</p>';
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 = '<h4 style="font-size:0.85rem;font-weight:600;margin-bottom:0.5rem;">월별 요약</h4>';
var html = '<h4 style="font-size:0.85rem;font-weight:600;margin-bottom:0.5rem;">월별 요약</h4>';
html += '<table class="data-table" style="margin-bottom:1rem;"><thead><tr>';
html += '<th style="position:static !important;">월</th><th style="position:static !important;">연차</th><th style="position:static !important;">반차</th><th style="position:static !important;">반반차</th><th style="position:static !important;">합계</th>';
html += '</tr></thead><tbody>';
for (let m = 1; m <= 12; m++) {
for (var m = 1; m <= 12; m++) {
if (!monthly[m]) continue;
const d = monthly[m];
html += `<tr>
<td>${m}월</td>
<td>${d.ANNUAL_FULL > 0 ? fmtNum(d.ANNUAL_FULL) : '-'}</td>
<td>${d.ANNUAL_HALF > 0 ? fmtNum(d.ANNUAL_HALF) : '-'}</td>
<td>${d.ANNUAL_QUARTER > 0 ? fmtNum(d.ANNUAL_QUARTER) : '-'}</td>
<td style="font-weight:600">${fmtNum(d.total)}</td>
</tr>`;
var md = monthly[m];
html += '<tr><td>' + m + '월</td>';
html += '<td>' + (md.ANNUAL_FULL > 0 ? fmtNum(md.ANNUAL_FULL) : '-') + '</td>';
html += '<td>' + (md.ANNUAL_HALF > 0 ? fmtNum(md.ANNUAL_HALF) : '-') + '</td>';
html += '<td>' + (md.ANNUAL_QUARTER > 0 ? fmtNum(md.ANNUAL_QUARTER) : '-') + '</td>';
html += '<td style="font-weight:600">' + fmtNum(md.total) + '</td></tr>';
}
html += `<tr style="font-weight:700;border-top:2px solid #e5e7eb;">
<td>합계</td>
<td>${fmtNum(totals.ANNUAL_FULL)}</td>
<td>${fmtNum(totals.ANNUAL_HALF)}</td>
<td>${fmtNum(totals.ANNUAL_QUARTER)}</td>
<td style="color:#059669">${fmtNum(grandTotal)}</td>
</tr>`;
html += '<tr style="font-weight:700;border-top:2px solid #e5e7eb;">';
html += '<td>합계</td>';
html += '<td>' + fmtNum(totals.ANNUAL_FULL) + '</td>';
html += '<td>' + fmtNum(totals.ANNUAL_HALF) + '</td>';
html += '<td>' + fmtNum(totals.ANNUAL_QUARTER) + '</td>';
html += '<td style="color:#059669">' + fmtNum(grandTotal) + '</td></tr>';
html += '</tbody></table>';
// 상세 내역
// 상세 내역 — 월별 아코디언
html += '<h4 style="font-size:0.85rem;font-weight:600;margin-bottom:0.5rem;">상세 내역</h4>';
html += '<div style="font-size:0.8rem;">';
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 += `<div style="padding:0.25rem 0;border-bottom:1px solid #f3f4f6;display:flex;justify-content:space-between">
<span>${dateStr} ${r.vacation_type_name || ''}</span>
<span style="color:#6b7280">${fmtNum(days)}일</span>
</div>`;
});
html += '</div>';
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 += '<div class="month-accordion">';
html += '<div class="month-header" onclick="toggleMonth(' + mm + ')">';
html += '<span class="month-arrow" id="arrow-' + mm + '">' + (isOpen ? '\u25BC' : '\u25B6') + '</span>';
html += mm + '월 (' + fmtNum(monthly[mm].total) + '일)';
html += '</div>';
html += '<div class="month-body" id="month-' + mm + '" style="display:' + (isOpen ? 'block' : 'none') + ';">';
html += monthRecords.map(function(r) { return renderRecordRow(r); }).join('');
html += '</div></div>';
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 '<div class="record-row" id="rec-' + r.id + '">'
+ '<span class="record-info">' + dateStr + ' ' + (r.vacation_type_name || '') + '</span>'
+ '<span class="record-days">' + fmtNum(days) + '일</span>'
+ '<span class="record-actions">'
+ '<button onclick="editRecord(' + r.id + ',\'' + escapedDate + '\',' + r.vacation_type_id + ')">수정</button>'
+ '<button class="btn-del" onclick="deleteRecord(' + r.id + ',\'' + escapedDate + '\',' + r.user_id + ',' + r.vacation_type_id + ',\'' + dateStr + '\',\'' + escapedName + '\',' + days + ')">삭제</button>'
+ '</span></div>';
}
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 =
'<input type="date" value="' + recordDate + '" id="edit-date-' + recordId + '">'
+ '<select id="edit-type-' + recordId + '">'
+ '<option value="1"' + (currentTypeId==1?' selected':'') + '>연차</option>'
+ '<option value="2"' + (currentTypeId==2?' selected':'') + '>반차</option>'
+ '<option value="3"' + (currentTypeId==3?' selected':'') + '>반반차</option>'
+ '</select>'
+ '<button class="btn-save" onclick="saveEdit(' + recordId + ',\'' + recordDate + '\',' + currentTypeId + ')">저장</button>'
+ '<button class="btn-cancel" onclick="cancelEdit(' + recordId + ')">취소</button>';
}
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 => {

View File

@@ -898,21 +898,16 @@
<option value="">선택</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">휴가 유형 <span class="text-red-400">*</span></label>
<select id="vbType" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" required>
<option value="">선택</option>
</select>
</div>
<input type="hidden" id="vbType" value="">
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">배정 유형</label>
<label class="block text-xs font-medium text-gray-600 mb-1">유형 <span class="text-red-400">*</span></label>
<select id="vbBalanceType" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" onchange="onBalanceTypeChange()">
<option value="AUTO">기본연차</option>
<option value="MANUAL">추가부여</option>
<option value="CARRY_OVER">이월연차</option>
<option value="LONG_SERVICE">장기근속</option>
<option value="COMPANY_GRANT">회사부여</option>
<option value="COMPANY_GRANT">경조사/특별휴가</option>
</select>
</div>
<div>
@@ -930,6 +925,19 @@
<input type="number" id="vbUsedDays" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" value="0" step="0.25" min="0">
</div>
</div>
<div id="vbSpecialTypeRow" class="hidden">
<label class="block text-xs font-medium text-gray-600 mb-1">경조사 유형</label>
<select id="vbSpecialType" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
<option value="WEDDING">결혼 (5일)</option>
<option value="SPOUSE_BIRTH">배우자 출산 (10일)</option>
<option value="CHILD_WEDDING">자녀 결혼 (1일)</option>
<option value="CONDOLENCE_PARENT">부모 사망 (5일)</option>
<option value="CONDOLENCE_SPOUSE_PARENT">배우자 부모 사망 (5일)</option>
<option value="CONDOLENCE_GRANDPARENT">조부모 사망 (3일)</option>
<option value="CONDOLENCE_SIBLING">형제자매 사망 (3일)</option>
<option value="OTHER">기타 (1일)</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">만료일</label>
<input type="date" id="vbExpiresAt" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
@@ -2416,7 +2424,7 @@
<script src="/static/js/tkuser-issue-types.js?v=2026031401"></script>
<script src="/static/js/tkuser-workplaces.js?v=2026031401"></script>
<script src="/static/js/tkuser-tasks.js?v=2026031401"></script>
<script src="/static/js/tkuser-vacations.js?v=2026033101"></script>
<script src="/static/js/tkuser-vacations.js?v=2026033102"></script>
<script src="/static/js/tkuser-vacation-settings.js?v=2026032501"></script>
<script src="/static/js/tkuser-layout-map.js?v=2026031401"></script>
<script src="/static/js/tkuser-partners.js?v=2026031601"></script>

View File

@@ -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 = '<option value="">선택</option>';
vacTypes.filter(t => t.is_active).forEach(t => { tSel.innerHTML += `<option value="${t.id}">${escHtml(t.type_name)} (${escHtml(t.type_code)})</option>`; });
// 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); }