- 공통 유틸리티 추출 (common/utils.js, common/base-state.js) - TBM 모바일 인라인 JS/CSS 외부 파일로 분리 (tbm-mobile.js, tbm-mobile.css) - 미사용 코드 삭제 (index.js, work-report-*.js 등 5개 파일) - TBM/작업보고 state.js, utils.js를 공통 모듈 기반으로 전환 - 작업보고서 SSO 인증 호환 수정 (token/user 함수) - tbmModel.js: incomplete-reports 쿼리에서 users→sso_users 조인 수정, leader_name 조인 추가 - docker-compose.yml: system1-web 볼륨 마운트 추가 - 모바일 인계(handover) 기능 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
763 lines
27 KiB
HTML
763 lines
27 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>연간 연차 현황 | 테크니컬코리아</title>
|
|
<link rel="stylesheet" href="/css/design-system.css">
|
|
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
|
<link rel="icon" type="image/png" href="/img/favicon.png">
|
|
<script src="/js/api-base.js?v=2"></script>
|
|
<script src="/js/app-init.js?v=9" defer></script>
|
|
<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 {
|
|
width: 55px;
|
|
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; }
|
|
</style>
|
|
</head>
|
|
<body class="has-sidebar">
|
|
<div id="navbar-container"></div>
|
|
<div id="sidebar-container"></div>
|
|
|
|
<main class="main-content">
|
|
<div class="page-wrapper">
|
|
<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 class="card">
|
|
<div class="card-body" style="padding:0;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>
|
|
|
|
<!-- 저장 바 -->
|
|
<div class="save-bar">
|
|
<span class="save-status" id="saveStatus">변경사항이 있으면 저장 버튼을 눌러주세요</span>
|
|
<button class="btn btn-success" onclick="saveAll()">저장</button>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
<!-- 경조사 모달 -->
|
|
<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()">×</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="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);
|
|
})();
|
|
|
|
// 전역 변수
|
|
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.worker_id] = {
|
|
carryover: 0,
|
|
annual: 0,
|
|
longService: 0,
|
|
specials: [],
|
|
totalUsed: 0
|
|
};
|
|
});
|
|
|
|
// 잔액 데이터 매핑
|
|
balances.forEach(b => {
|
|
if (!vacationData[b.worker_id]) return;
|
|
const code = b.type_code || '';
|
|
const data = vacationData[b.worker_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.worker_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-worker-id="${w.worker_id}">
|
|
<td>${idx + 1}</td>
|
|
<td class="worker-name">${w.worker_name}</td>
|
|
<td>
|
|
<input type="number" class="num-input ${carryover < 0 ? 'negative' : ''}"
|
|
value="${carryover}" step="0.5"
|
|
data-field="carryover"
|
|
onchange="updateField(${w.worker_id}, 'carryover', this.value)">
|
|
</td>
|
|
<td>
|
|
<input type="number" class="num-input"
|
|
value="${annual}" step="0.5"
|
|
data-field="annual"
|
|
onchange="updateField(${w.worker_id}, 'annual', this.value)">
|
|
</td>
|
|
<td>
|
|
<input type="number" class="num-input"
|
|
value="${longService}" step="0.5"
|
|
data-field="longService"
|
|
onchange="updateField(${w.worker_id}, 'longService', this.value)">
|
|
</td>
|
|
<td>
|
|
<button class="special-btn" onclick="openSpecialModal(${w.worker_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;">${totalGenerated.toFixed(2)}</td>
|
|
<td style="color:#6b7280;">${totalUsed > 0 ? totalUsed.toFixed(2) : '-'}</td>
|
|
<td class="remaining ${remainingClass}">${remaining.toFixed(2)}</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-worker-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-worker-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 = totalGenerated.toFixed(2);
|
|
cells[7].textContent = totalUsed > 0 ? totalUsed.toFixed(2) : '-';
|
|
cells[8].textContent = remaining.toFixed(2);
|
|
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="${s.days}" step="0.5" min="0"
|
|
onchange="updateSpecialDays(${idx}, this.value)">
|
|
<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.worker_id];
|
|
if (!d) continue;
|
|
|
|
if (typeIdMap['CARRYOVER']) {
|
|
balancesToSave.push({
|
|
worker_id: w.worker_id,
|
|
vacation_type_id: typeIdMap['CARRYOVER'],
|
|
year: currentYear,
|
|
total_days: d.carryover
|
|
});
|
|
}
|
|
if (typeIdMap['ANNUAL']) {
|
|
balancesToSave.push({
|
|
worker_id: w.worker_id,
|
|
vacation_type_id: typeIdMap['ANNUAL'],
|
|
year: currentYear,
|
|
total_days: d.annual
|
|
});
|
|
}
|
|
if (typeIdMap['LONG_SERVICE']) {
|
|
balancesToSave.push({
|
|
worker_id: w.worker_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({
|
|
worker_id: w.worker_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>
|
|
</body>
|
|
</html>
|