Files
tk-factory-services/system1-factory/web/pages/attendance/annual-overview.html
Hyungi Ahn eea99359b5 fix(tkfb): 연차 현황 빈 행 제거 + 입력칸 너비 확대
- card/card-body 래퍼 제거 → 테이블 위 빈 행 해소
- num-input: width:55px → min-width:65px; width:auto (음수 소수점 대응)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:04:28 +09:00

802 lines
29 KiB
HTML

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>연간 연차 현황 - TK 공장관리</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026031601">
<style>
.page-wrapper {
padding: 1.5rem;
max-width: 1200px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.page-title { font-size: 1.5rem; font-weight: 700; margin: 0; }
.page-desc { color: #6b7280; font-size: 0.875rem; margin-top: 0.25rem; }
.controls { display: flex; gap: 0.5rem; align-items: center; }
.controls select {
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
}
.btn-primary { background: #3b82f6; color: white; }
.btn-success { background: #10b981; color: white; }
.btn-sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; }
/* 범례 */
.legend-box {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
padding: 0.5rem 1rem;
background: #f9fafb;
border-radius: 0.5rem;
font-size: 0.8rem;
color: #6b7280;
flex-wrap: wrap;
align-items: center;
}
.legend-item { display: flex; align-items: center; gap: 0.25rem; }
.legend-dot {
width: 12px; height: 12px; border-radius: 2px;
}
.dot-carryover { background: #fef3c7; border: 1px solid #f59e0b; }
.dot-annual { background: #dbeafe; border: 1px solid #3b82f6; }
.dot-longservice { background: #f3e8ff; border: 1px solid #a855f7; }
.dot-special { background: #fce7f3; border: 1px solid #ec4899; }
/* 테이블 */
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
background: white;
}
.data-table th, .data-table td {
padding: 0.6rem 0.5rem;
text-align: center;
border: 1px solid #e5e7eb;
}
.data-table th {
background: #f9fafb;
font-weight: 600;
font-size: 0.8rem;
}
.data-table th.col-carryover { background: #fef3c7; color: #92400e; }
.data-table th.col-annual { background: #dbeafe; color: #1e40af; }
.data-table th.col-longservice { background: #f3e8ff; color: #7c3aed; }
.data-table th.col-special { background: #fce7f3; color: #be185d; }
.data-table th.col-total { background: #dcfce7; color: #166534; }
.data-table td.worker-name { text-align: left; font-weight: 500; }
.data-table tr:hover { background: #f9fafb; }
/* 입력 필드 */
.num-input {
min-width: 65px;
width: auto;
padding: 0.25rem;
border: 1px solid #d1d5db;
border-radius: 0.25rem;
text-align: center;
font-size: 0.8rem;
}
.num-input:focus {
outline: none;
border-color: #3b82f6;
}
.num-input.negative { color: #dc2626; }
/* 경조사 버튼 */
.special-btn {
padding: 0.2rem 0.5rem;
font-size: 0.75rem;
background: #fce7f3;
color: #be185d;
border: 1px solid #f9a8d4;
border-radius: 0.25rem;
cursor: pointer;
}
.special-btn:hover { background: #fbcfe8; }
.special-count {
display: inline-block;
min-width: 20px;
padding: 0.125rem 0.375rem;
background: #be185d;
color: white;
border-radius: 10px;
font-size: 0.7rem;
margin-left: 0.25rem;
}
/* 잔여 */
.remaining { font-weight: 700; }
.remaining.positive { color: #059669; }
.remaining.zero { color: #6b7280; }
.remaining.negative { color: #dc2626; }
/* 저장 바 */
.save-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1rem;
padding: 0.75rem 1rem;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
}
.save-status { font-size: 0.875rem; color: #6b7280; }
/* 경조사 모달 */
.modal-overlay {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 1000;
justify-content: center;
align-items: center;
}
.modal-overlay.active { display: flex; }
.modal-content {
background: white;
border-radius: 0.75rem;
width: 90%;
max-width: 500px;
max-height: 80vh;
overflow-y: auto;
padding: 1.5rem;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid #e5e7eb;
}
.modal-title { font-size: 1rem; font-weight: 600; }
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #6b7280;
}
.special-list { margin-bottom: 1rem; }
.special-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
border-bottom: 1px solid #f3f4f6;
}
.special-item select {
flex: 1;
padding: 0.375rem;
border: 1px solid #d1d5db;
border-radius: 0.25rem;
font-size: 0.8rem;
}
.special-item input {
width: 60px;
padding: 0.375rem;
border: 1px solid #d1d5db;
border-radius: 0.25rem;
text-align: center;
font-size: 0.8rem;
}
.special-item .delete-btn {
padding: 0.25rem 0.5rem;
background: #fee2e2;
color: #dc2626;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.75rem;
}
.add-special-btn {
width: 100%;
padding: 0.5rem;
background: #f3f4f6;
border: 1px dashed #9ca3af;
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.8rem;
color: #6b7280;
}
.add-special-btn:hover { background: #e5e7eb; }
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 1px solid #e5e7eb;
}
.loading { text-align: center; padding: 2rem; color: #6b7280; }
@media (max-width: 768px) {
.controls { flex-wrap: wrap; gap: 0.5rem; }
.controls select { flex: 1; }
.legend-box { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.5rem; }
.data-table { font-size: 0.7rem; }
.data-table th, .data-table td { padding: 0.375rem 0.25rem; }
.num-input { width: 45px; font-size: 0.75rem; }
.save-bar { position: sticky; bottom: 0; z-index: 20; background: white; box-shadow: 0 -2px 8px rgba(0,0,0,0.08); }
}
</style>
</head>
<body class="bg-gray-50">
<header class="bg-orange-700 text-white sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-14">
<div class="flex items-center gap-3">
<button id="mobileMenuBtn" class="lg:hidden text-orange-200 hover:text-white"><i class="fas fa-bars text-xl"></i></button>
<i class="fas fa-industry text-xl text-orange-200"></i>
<h1 class="text-lg font-semibold">TK 공장관리</h1>
</div>
<div class="flex items-center gap-4">
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃"><i class="fas fa-sign-out-alt"></i></button>
</div>
</div>
</div>
</header>
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
<div class="flex gap-6">
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
<div class="flex-1 min-w-0">
<div class="page-header">
<div>
<h1 class="page-title">연간 연차 현황</h1>
<p class="page-desc">작업자별 연차 발생 및 사용 현황을 관리합니다</p>
</div>
<div class="controls">
<select id="yearSelect"></select>
<button class="btn btn-primary" onclick="loadData()">조회</button>
</div>
</div>
<!-- 범례 -->
<div class="legend-box">
<span style="font-weight:500;">차감 우선순위:</span>
<div class="legend-item"><div class="legend-dot dot-carryover"></div>1. 이월</div>
<div class="legend-item"><div class="legend-dot dot-annual"></div>2. 정기연차</div>
<div class="legend-item"><div class="legend-dot dot-longservice"></div>3. 장기근속</div>
<div class="legend-item"><div class="legend-dot dot-special"></div>4. 경조사</div>
</div>
<!-- 테이블 -->
<div style="overflow-x:auto;">
<table class="data-table">
<thead>
<tr>
<th style="width:40px">No</th>
<th style="min-width:70px">이름</th>
<th class="col-carryover" style="width:80px">이월</th>
<th class="col-annual" style="width:80px">정기연차</th>
<th class="col-longservice" style="width:80px">장기근속</th>
<th class="col-special" style="width:100px">경조사</th>
<th class="col-total" style="width:70px">총 발생</th>
<th class="col-total" style="width:70px">총 사용</th>
<th class="col-total" style="width:70px">잔여</th>
</tr>
</thead>
<tbody id="tableBody">
<tr><td colspan="9" class="loading">로딩 중...</td></tr>
</tbody>
</table>
</div>
<!-- 저장 바 -->
<div class="save-bar">
<span class="save-status" id="saveStatus">변경사항이 있으면 저장 버튼을 눌러주세요</span>
<button class="btn btn-success" onclick="saveAll()">저장</button>
</div>
</div>
</div>
</div>
<!-- 경조사 모달 -->
<div class="modal-overlay" id="specialModal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title" id="specialModalTitle">경조사 휴가</h3>
<button class="modal-close" onclick="closeSpecialModal()">&times;</button>
</div>
<div class="modal-body">
<div class="special-list" id="specialList">
<!-- 동적 생성 -->
</div>
<button class="add-special-btn" onclick="addSpecialItem()">+ 경조사 추가</button>
</div>
<div class="modal-footer">
<button class="btn btn-primary" onclick="saveSpecialAndClose()">확인</button>
</div>
</div>
</div>
<script src="/static/js/tkfb-core.js?v=2026031601"></script>
<script src="/js/api-base.js?v=2026031401"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script>
// axios 설정
(function() {
const check = setInterval(() => {
if (window.API_BASE_URL) {
clearInterval(check);
axios.defaults.baseURL = window.API_BASE_URL;
const token = localStorage.getItem('sso_token');
if (token) axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
}, 50);
})();
// 숫자 포맷: 정수면 소수점 없이, 소수면 2자리
function fmtNum(v) {
const n = parseFloat(v) || 0;
return n % 1 === 0 ? n.toString() : n.toFixed(2);
}
// 전역 변수
let workers = [];
let currentYear = new Date().getFullYear();
let vacationData = {}; // { workerId: { carryover, annual, longService, specials: [{type, days}], totalUsed } }
let currentWorkerId = null;
// 경조사 유형
const specialTypes = [
{ code: 'WEDDING', name: '결혼', defaultDays: 5 },
{ code: 'SPOUSE_BIRTH', name: '배우자 출산', defaultDays: 10 },
{ code: 'CHILD_WEDDING', name: '자녀 결혼', defaultDays: 1 },
{ code: 'CONDOLENCE_PARENT', name: '부모 사망', defaultDays: 5 },
{ code: 'CONDOLENCE_SPOUSE_PARENT', name: '배우자 부모 사망', defaultDays: 5 },
{ code: 'CONDOLENCE_GRANDPARENT', name: '조부모 사망', defaultDays: 3 },
{ code: 'CONDOLENCE_SIBLING', name: '형제자매 사망', defaultDays: 3 },
{ code: 'OTHER', name: '기타', defaultDays: 1 }
];
// 초기화
document.addEventListener('DOMContentLoaded', async () => {
await waitForAxios();
initYearSelector();
await loadData();
});
function waitForAxios() {
return new Promise(resolve => {
const check = setInterval(() => {
if (axios.defaults.baseURL) { clearInterval(check); resolve(); }
}, 50);
setTimeout(() => { clearInterval(check); resolve(); }, 5000);
});
}
function initYearSelector() {
const select = document.getElementById('yearSelect');
const now = new Date().getFullYear();
for (let y = now - 2; y <= now + 1; y++) {
const opt = document.createElement('option');
opt.value = y;
opt.textContent = `${y}`;
if (y === now) opt.selected = true;
select.appendChild(opt);
}
}
async function loadData() {
currentYear = parseInt(document.getElementById('yearSelect').value);
document.getElementById('tableBody').innerHTML = '<tr><td colspan="9" class="loading">로딩 중...</td></tr>';
try {
// 작업자 로드
const workersRes = await axios.get('/workers?limit=100');
workers = (workersRes.data.data || [])
.filter(w => w.status === 'active' && w.employment_status === 'employed')
.sort((a, b) => a.worker_name.localeCompare(b.worker_name, 'ko'));
// 휴가 잔액 로드
let balances = [];
try {
const balancesRes = await axios.get(`/vacation-balances/year/${currentYear}`);
balances = balancesRes.data.data || [];
} catch (e) { console.log('휴가 잔액 로드 실패'); }
// 데이터 정리
vacationData = {};
workers.forEach(w => {
vacationData[w.user_id] = {
carryover: 0,
annual: 0,
longService: 0,
specials: [],
totalUsed: 0
};
});
// 잔액 데이터 매핑
balances.forEach(b => {
if (!vacationData[b.user_id]) return;
const code = b.type_code || '';
const data = vacationData[b.user_id];
if (code === 'CARRYOVER' || b.type_name === '이월') {
data.carryover = b.total_days || 0;
data.totalUsed += b.used_days || 0;
} else if (code === 'ANNUAL' || b.type_name === '정기연차' || b.type_name === '연차') {
data.annual = b.total_days || 0;
data.totalUsed += b.used_days || 0;
} else if (code === 'LONG_SERVICE' || b.type_name === '장기근속') {
data.longService = b.total_days || 0;
data.totalUsed += b.used_days || 0;
} else if (code.startsWith('SPECIAL_') || specialTypes.some(st => st.code === code)) {
data.specials.push({
type: code,
typeName: b.type_name,
days: b.total_days || 0,
id: b.id
});
data.totalUsed += b.used_days || 0;
}
});
renderTable();
} catch (error) {
console.error('데이터 로드 오류:', error);
document.getElementById('tableBody').innerHTML = '<tr><td colspan="9" class="loading" style="color:#ef4444;">데이터 로드 실패</td></tr>';
}
}
function renderTable() {
const tbody = document.getElementById('tableBody');
if (workers.length === 0) {
tbody.innerHTML = '<tr><td colspan="9" class="loading">작업자가 없습니다</td></tr>';
return;
}
tbody.innerHTML = workers.map((w, idx) => {
const d = vacationData[w.user_id] || { carryover: 0, annual: 0, longService: 0, specials: [], totalUsed: 0 };
const carryover = parseFloat(d.carryover) || 0;
const annual = parseFloat(d.annual) || 0;
const longService = parseFloat(d.longService) || 0;
const totalUsed = parseFloat(d.totalUsed) || 0;
const specialTotal = (d.specials || []).reduce((sum, s) => sum + (parseFloat(s.days) || 0), 0);
const totalGenerated = carryover + annual + longService + specialTotal;
const remaining = totalGenerated - totalUsed;
const remainingClass = remaining > 0 ? 'positive' : remaining < 0 ? 'negative' : 'zero';
return `
<tr data-user-id="${w.user_id}">
<td>${idx + 1}</td>
<td class="worker-name">${w.worker_name}</td>
<td>
<input type="number" class="num-input ${carryover < 0 ? 'negative' : ''}"
value="${fmtNum(carryover)}" step="0.25"
data-field="carryover"
onchange="updateField(${w.user_id}, 'carryover', this.value)"
onblur="var n=parseFloat(this.value||0);this.value=n%1===0?n.toString():n.toFixed(2)">
</td>
<td>
<input type="number" class="num-input"
value="${fmtNum(annual)}" step="0.25"
data-field="annual"
onchange="updateField(${w.user_id}, 'annual', this.value)"
onblur="var n=parseFloat(this.value||0);this.value=n%1===0?n.toString():n.toFixed(2)">
</td>
<td>
<input type="number" class="num-input"
value="${fmtNum(longService)}" step="0.25"
data-field="longService"
onchange="updateField(${w.user_id}, 'longService', this.value)"
onblur="var n=parseFloat(this.value||0);this.value=n%1===0?n.toString():n.toFixed(2)">
</td>
<td>
<button class="special-btn" onclick="openSpecialModal(${w.user_id}, '${w.worker_name}')">
${(d.specials || []).length > 0 ? `${specialTotal}` : '추가'}
${(d.specials || []).length > 0 ? `<span class="special-count">${d.specials.length}</span>` : ''}
</button>
</td>
<td style="font-weight:600;color:#059669;">${fmtNum(totalGenerated)}</td>
<td style="color:#6b7280;">${fmtNum(totalUsed)}</td>
<td class="remaining ${remainingClass}">${fmtNum(remaining)}</td>
</tr>
`;
}).join('');
}
function updateField(workerId, field, value) {
const val = parseFloat(value) || 0;
if (!vacationData[workerId]) {
vacationData[workerId] = { carryover: 0, annual: 0, longService: 0, specials: [], totalUsed: 0 };
}
vacationData[workerId][field] = val;
// 입력 스타일 업데이트
const input = document.querySelector(`tr[data-user-id="${workerId}"] input[data-field="${field}"]`);
if (input) {
input.classList.toggle('negative', val < 0);
}
// 행 합계 업데이트
updateRowTotals(workerId);
markChanged();
}
function updateRowTotals(workerId) {
const row = document.querySelector(`tr[data-user-id="${workerId}"]`);
if (!row) return;
const d = vacationData[workerId];
if (!d) return;
const carryover = parseFloat(d.carryover) || 0;
const annual = parseFloat(d.annual) || 0;
const longService = parseFloat(d.longService) || 0;
const totalUsed = parseFloat(d.totalUsed) || 0;
const specialTotal = (d.specials || []).reduce((sum, s) => sum + (parseFloat(s.days) || 0), 0);
const totalGenerated = carryover + annual + longService + specialTotal;
const remaining = totalGenerated - totalUsed;
const cells = row.querySelectorAll('td');
cells[6].textContent = fmtNum(totalGenerated);
cells[7].textContent = fmtNum(totalUsed);
cells[8].textContent = fmtNum(remaining);
cells[8].className = `remaining ${remaining > 0 ? 'positive' : remaining < 0 ? 'negative' : 'zero'}`;
}
function markChanged() {
document.getElementById('saveStatus').textContent = '변경사항이 있습니다. 저장 버튼을 눌러주세요.';
document.getElementById('saveStatus').style.color = '#f59e0b';
}
// ===== 경조사 모달 =====
function openSpecialModal(workerId, workerName) {
currentWorkerId = workerId;
document.getElementById('specialModalTitle').textContent = `${workerName} - 경조사 휴가`;
renderSpecialList();
document.getElementById('specialModal').classList.add('active');
}
function closeSpecialModal() {
document.getElementById('specialModal').classList.remove('active');
currentWorkerId = null;
}
function renderSpecialList() {
const container = document.getElementById('specialList');
const specials = vacationData[currentWorkerId]?.specials || [];
if (specials.length === 0) {
container.innerHTML = '<p style="text-align:center;color:#9ca3af;padding:1rem;">경조사 휴가가 없습니다</p>';
return;
}
container.innerHTML = specials.map((s, idx) => `
<div class="special-item" data-idx="${idx}">
<select onchange="updateSpecialType(${idx}, this.value)">
${specialTypes.map(st => `
<option value="${st.code}" ${s.type === st.code ? 'selected' : ''}>${st.name}</option>
`).join('')}
</select>
<input type="number" value="${fmtNum(s.days)}" step="0.25" min="0"
onchange="updateSpecialDays(${idx}, this.value)"
onblur="var n=parseFloat(this.value||0);this.value=n%1===0?n.toString():n.toFixed(2)">
<span>일</span>
<button class="delete-btn" onclick="deleteSpecialItem(${idx})">삭제</button>
</div>
`).join('');
}
function addSpecialItem() {
if (!vacationData[currentWorkerId]) return;
vacationData[currentWorkerId].specials.push({
type: 'WEDDING',
typeName: '결혼',
days: 5
});
renderSpecialList();
}
function updateSpecialType(idx, typeCode) {
const specials = vacationData[currentWorkerId]?.specials;
if (!specials || !specials[idx]) return;
const typeInfo = specialTypes.find(st => st.code === typeCode);
specials[idx].type = typeCode;
specials[idx].typeName = typeInfo?.name || typeCode;
specials[idx].days = typeInfo?.defaultDays || specials[idx].days;
renderSpecialList();
}
function updateSpecialDays(idx, value) {
const specials = vacationData[currentWorkerId]?.specials;
if (!specials || !specials[idx]) return;
specials[idx].days = parseFloat(value) || 0;
}
function deleteSpecialItem(idx) {
const specials = vacationData[currentWorkerId]?.specials;
if (!specials) return;
specials.splice(idx, 1);
renderSpecialList();
}
function saveSpecialAndClose() {
updateRowTotals(currentWorkerId);
renderTable(); // 경조사 버튼 텍스트 업데이트
markChanged();
closeSpecialModal();
}
// ESC로 모달 닫기
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeSpecialModal();
});
document.getElementById('specialModal').addEventListener('click', e => {
if (e.target.id === 'specialModal') closeSpecialModal();
});
// ===== 저장 =====
async function saveAll() {
const balancesToSave = [];
// 휴가 유형 ID 매핑 (서버에서 가져와야 하지만 일단 하드코딩)
// 실제로는 vacation_types 테이블에서 조회해야 함
const typeIdMap = {
'CARRYOVER': null,
'ANNUAL': null,
'LONG_SERVICE': null
};
// 휴가 유형 ID 조회
try {
const typesRes = await axios.get('/vacation-types');
const types = typesRes.data.data || [];
types.forEach(t => {
if (t.type_code === 'CARRYOVER' || t.type_name === '이월') typeIdMap['CARRYOVER'] = t.id;
if (t.type_code === 'ANNUAL' || t.type_name === '정기연차' || t.type_name === '연차') typeIdMap['ANNUAL'] = t.id;
if (t.type_code === 'LONG_SERVICE' || t.type_name === '장기근속') typeIdMap['LONG_SERVICE'] = t.id;
});
} catch (e) {
console.error('휴가 유형 로드 실패:', e);
}
// 필요한 유형이 없으면 생성 (deduct_days 필수)
if (!typeIdMap['CARRYOVER']) {
try {
const res = await axios.post('/vacation-types', { type_code: 'CARRYOVER', type_name: '이월', deduct_days: 1, priority: 1 });
typeIdMap['CARRYOVER'] = res.data.data?.id;
} catch (e) { console.error('이월 유형 생성 실패'); }
}
if (!typeIdMap['ANNUAL']) {
try {
const res = await axios.post('/vacation-types', { type_code: 'ANNUAL', type_name: '정기연차', deduct_days: 1, priority: 2 });
typeIdMap['ANNUAL'] = res.data.data?.id;
} catch (e) { console.error('정기연차 유형 생성 실패'); }
}
if (!typeIdMap['LONG_SERVICE']) {
try {
const res = await axios.post('/vacation-types', { type_code: 'LONG_SERVICE', type_name: '장기근속', deduct_days: 1, priority: 3 });
typeIdMap['LONG_SERVICE'] = res.data.data?.id;
} catch (e) { console.error('장기근속 유형 생성 실패'); }
}
// 데이터 수집
for (const w of workers) {
const d = vacationData[w.user_id];
if (!d) continue;
if (typeIdMap['CARRYOVER']) {
balancesToSave.push({
user_id: w.user_id,
vacation_type_id: typeIdMap['CARRYOVER'],
year: currentYear,
total_days: d.carryover
});
}
if (typeIdMap['ANNUAL']) {
balancesToSave.push({
user_id: w.user_id,
vacation_type_id: typeIdMap['ANNUAL'],
year: currentYear,
total_days: d.annual
});
}
if (typeIdMap['LONG_SERVICE']) {
balancesToSave.push({
user_id: w.user_id,
vacation_type_id: typeIdMap['LONG_SERVICE'],
year: currentYear,
total_days: d.longService
});
}
// 경조사 데이터 저장
for (const special of d.specials) {
if (special.days > 0) {
// 경조사 유형 ID 조회 또는 생성
let specialTypeId = typeIdMap[special.type];
if (!specialTypeId) {
try {
const typesRes = await axios.get('/vacation-types');
const existingType = (typesRes.data.data || []).find(t => t.type_code === special.type);
if (existingType) {
specialTypeId = existingType.id;
typeIdMap[special.type] = specialTypeId;
}
} catch (e) {}
}
if (!specialTypeId) {
try {
const typeInfo = specialTypes.find(st => st.code === special.type);
const res = await axios.post('/vacation-types', {
type_code: special.type,
type_name: typeInfo?.name || special.type,
deduct_days: typeInfo?.defaultDays || 1,
priority: 4
});
specialTypeId = res.data.data?.id;
typeIdMap[special.type] = specialTypeId;
} catch (e) { console.error(`${special.type} 유형 생성 실패`); }
}
if (specialTypeId) {
balancesToSave.push({
user_id: w.user_id,
vacation_type_id: specialTypeId,
year: currentYear,
total_days: special.days,
notes: special.typeName || special.type
});
}
}
}
}
if (balancesToSave.length === 0) {
alert('저장할 데이터가 없습니다.');
return;
}
try {
const res = await axios.post('/vacation-balances/bulk-upsert', { balances: balancesToSave });
if (res.data.success) {
alert(res.data.message);
document.getElementById('saveStatus').textContent = '저장 완료';
document.getElementById('saveStatus').style.color = '#10b981';
await loadData();
} else {
alert('저장 실패: ' + res.data.message);
}
} catch (error) {
console.error('저장 오류:', error);
alert('저장 중 오류: ' + (error.response?.data?.message || error.message));
}
}
</script>
<script>initAuth();</script>
</body>
</html>