카카오톡 인앱 WebView는 서브도메인 간 쿠키를 공유하지 않아 tkds에서 로그인 후 tkfb로 리다이렉트 시 인증이 풀리는 문제. - sso-relay.js: URL hash의 _sso= 토큰을 로컬 쿠키+localStorage로 설정 - gateway dashboard: 로그인 후 redirect URL에 #_sso=<token> 추가 - 전 서비스 HTML: core JS 직전에 sso-relay.js 로드 (81개 파일) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1130 lines
46 KiB
HTML
1130 lines
46 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>
|
|
<script>tailwind.config = { corePlugins: { preflight: false } }</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=2026040103">
|
|
<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;
|
|
position: static !important; /* tkfb.css의 sticky 오버라이드 */
|
|
}
|
|
.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;
|
|
}
|
|
|
|
/* 아코디언 */
|
|
.month-accordion { margin-bottom: 0.25rem; }
|
|
.month-header {
|
|
cursor: pointer; padding: 0.5rem;
|
|
background: #f9fafb; border-radius: 0.375rem;
|
|
font-weight: 600; font-size: 0.85rem;
|
|
display: flex; align-items: center; gap: 0.5rem;
|
|
user-select: none;
|
|
}
|
|
.month-header:hover { background: #f3f4f6; }
|
|
.month-arrow { font-size: 0.7rem; width: 1rem; text-align: center; }
|
|
.month-body { padding: 0.25rem 0 0.25rem 1.5rem; }
|
|
.record-row {
|
|
display: flex; justify-content: space-between; align-items: center;
|
|
padding: 0.3rem 0; border-bottom: 1px solid #f3f4f6;
|
|
font-size: 0.8rem; gap: 0.5rem;
|
|
}
|
|
.record-row .record-info { flex: 1; }
|
|
.record-row .record-days { color: #6b7280; white-space: nowrap; }
|
|
.record-actions { display: flex; gap: 0.25rem; white-space: nowrap; }
|
|
.record-actions button {
|
|
font-size: 0.7rem; padding: 0.15rem 0.4rem;
|
|
border: 1px solid #d1d5db; border-radius: 0.25rem;
|
|
background: white; cursor: pointer;
|
|
}
|
|
.record-actions button:hover { background: #f3f4f6; }
|
|
.record-actions .btn-del { color: #dc2626; border-color: #fca5a5; }
|
|
.record-actions .btn-del:hover { background: #fee2e2; }
|
|
/* 인라인 편집 */
|
|
.edit-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.3rem 0; border-bottom: 1px solid #f3f4f6; font-size: 0.8rem; }
|
|
.edit-row input[type="date"] { padding: 0.2rem 0.3rem; border: 1px solid #93c5fd; border-radius: 0.25rem; font-size: 0.78rem; }
|
|
.edit-row select { padding: 0.2rem 0.3rem; border: 1px solid #93c5fd; border-radius: 0.25rem; font-size: 0.78rem; }
|
|
.edit-row .btn-save { background: #3b82f6; color: white; border: none; padding: 0.2rem 0.5rem; border-radius: 0.25rem; font-size: 0.7rem; cursor: pointer; }
|
|
.edit-row .btn-cancel { background: #f3f4f6; border: 1px solid #d1d5db; padding: 0.2rem 0.5rem; border-radius: 0.25rem; font-size: 0.7rem; cursor: pointer; }
|
|
|
|
.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()">×</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>
|
|
|
|
<!-- 월별 사용 내역 모달 -->
|
|
<div class="modal-overlay" id="monthlyDetailModal">
|
|
<div class="modal-content" style="max-width:600px;">
|
|
<div class="modal-header">
|
|
<h3 class="modal-title" id="monthlyDetailTitle">연차 사용 내역</h3>
|
|
<button class="modal-close" onclick="closeMonthlyDetail()">×</button>
|
|
</div>
|
|
<div class="modal-body" id="monthlyDetailBody" style="max-height:60vh;overflow-y:auto;">
|
|
<p style="text-align:center;color:#9ca3af;padding:1rem;">로딩 중...</p>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button class="btn btn-primary" onclick="closeMonthlyDetail()">닫기</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="/js/sso-relay.js?v=20260401"></script>
|
|
<script src="/static/js/tkfb-core.js?v=2026040105"></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/28 만료 → 3/1부터)
|
|
function isExpired(expiresAt) {
|
|
if (!expiresAt) return false;
|
|
const today = new Date(); today.setHours(0,0,0,0);
|
|
const exp = new Date(expiresAt); exp.setHours(0,0,0,0);
|
|
return today > exp;
|
|
}
|
|
|
|
// 숫자 포맷: 정수면 소수점 없이, 소수면 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;
|
|
|
|
// 월별 세부 모달 상태
|
|
let currentModalUserId = null;
|
|
let currentModalName = '';
|
|
let editBackup = {};
|
|
let earlyLeaveVtId = null; // EARLY_LEAVE vacation_type_id (동적 조회)
|
|
const DAYS_KR = ['일', '월', '화', '수', '목', '금', '토'];
|
|
|
|
// 경조사 유형
|
|
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 {
|
|
// EARLY_LEAVE 유형 ID 조회 (최초 1회)
|
|
if (!earlyLeaveVtId) {
|
|
try {
|
|
var vtRes = await axios.get('/attendance/vacation-types');
|
|
var elType = (vtRes.data.data || []).find(function(t) { return t.type_code === 'EARLY_LEAVE'; });
|
|
earlyLeaveVtId = elType ? elType.id : null;
|
|
} catch(e) {}
|
|
}
|
|
|
|
// 작업자 로드
|
|
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,
|
|
carryoverUsed: 0,
|
|
carryoverExpiresAt: null,
|
|
annual: 0,
|
|
longService: 0,
|
|
specials: [],
|
|
totalUsed: 0
|
|
};
|
|
});
|
|
|
|
// 잔액 데이터 매핑 — type_code 우선 분류 (balance_type 무관)
|
|
balances.forEach(b => {
|
|
if (!vacationData[b.user_id]) return;
|
|
const btype = (b.balance_type || '').toUpperCase();
|
|
const code = b.type_code || '';
|
|
const data = vacationData[b.user_id];
|
|
|
|
if (code === 'CARRYOVER' || b.type_name === '이월') {
|
|
data.carryover += parseFloat(b.total_days) || 0;
|
|
data.carryoverUsed += parseFloat(b.used_days) || 0;
|
|
data.carryoverExpiresAt = b.expires_at || data.carryoverExpiresAt;
|
|
data.totalUsed += parseFloat(b.used_days) || 0;
|
|
} else if (code === 'LONG_SERVICE' || b.type_name === '장기근속') {
|
|
data.longService += parseFloat(b.total_days) || 0;
|
|
data.totalUsed += parseFloat(b.used_days) || 0;
|
|
} else if (code === 'ANNUAL' || code === 'ANNUAL_FULL' || btype === 'AUTO' || btype === 'MANUAL' || b.type_name === '정기연차' || b.type_name === '연차') {
|
|
data.annual += parseFloat(b.total_days) || 0;
|
|
data.totalUsed += parseFloat(b.used_days) || 0;
|
|
} else if (btype === 'COMPANY_GRANT' || code.startsWith('SPECIAL_') || specialTypes.some(st => st.code === code)) {
|
|
var days = parseFloat(b.total_days) || 0;
|
|
if (days > 0) {
|
|
data.specials.push({ type: code, typeName: b.type_name, days: days, id: b.id });
|
|
}
|
|
data.totalUsed += parseFloat(b.used_days) || 0;
|
|
} else {
|
|
// 미분류 → 정기연차에 합산
|
|
data.annual += parseFloat(b.total_days) || 0;
|
|
data.totalUsed += parseFloat(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, carryoverUsed: 0, carryoverExpiresAt: null, annual: 0, longService: 0, specials: [], totalUsed: 0 };
|
|
const carryover = parseFloat(d.carryover) || 0;
|
|
const carryoverExpired = isExpired(d.carryoverExpiresAt);
|
|
const carryoverUsed = parseFloat(d.carryoverUsed) || 0;
|
|
const carryoverLapsed = carryoverExpired ? Math.max(0, carryover - carryoverUsed) : 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" style="cursor:pointer;color:#2563eb;text-decoration:underline" onclick="openMonthlyDetail(${w.user_id}, '${w.worker_name.replace(/'/g, "\\'")}')">${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)">
|
|
${carryoverExpired && carryover > 0 ? `<div style="font-size:10px;margin-top:2px;line-height:1.2">
|
|
<span style="color:#10b981">소진${fmtNum(carryoverUsed)}</span>
|
|
${carryoverLapsed > 0 ? `<span style="color:#9ca3af;text-decoration:line-through;margin-left:3px">만료${fmtNum(carryoverLapsed)}</span>` : ''}
|
|
</div>` : ''}
|
|
</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(); closeMonthlyDetail(); }
|
|
});
|
|
document.getElementById('specialModal').addEventListener('click', e => {
|
|
if (e.target.id === 'specialModal') closeSpecialModal();
|
|
});
|
|
|
|
// ===== 월별 사용 내역 모달 =====
|
|
async function openMonthlyDetail(userId, workerName) {
|
|
currentModalUserId = userId;
|
|
currentModalName = workerName;
|
|
editBackup = {};
|
|
const year = parseInt(document.getElementById('yearSelect').value);
|
|
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 records = (res.data.data || []).filter(r => r.vacation_type_id);
|
|
|
|
if (records.length === 0) {
|
|
document.getElementById('monthlyDetailBody').innerHTML = '<p style="text-align:center;color:#9ca3af;padding:1rem;">연차 사용 내역이 없습니다</p>';
|
|
return;
|
|
}
|
|
|
|
// 월별 그룹핑
|
|
const monthly = {};
|
|
const totals = { ANNUAL_FULL: 0, ANNUAL_HALF: 0, ANNUAL_QUARTER: 0, EARLY_LEAVE: 0, other: 0 };
|
|
|
|
records.forEach(r => {
|
|
const d = new Date(r.record_date);
|
|
const m = d.getMonth() + 1;
|
|
if (!monthly[m]) monthly[m] = { ANNUAL_FULL: 0, ANNUAL_HALF: 0, ANNUAL_QUARTER: 0, EARLY_LEAVE: 0, other: 0, total: 0 };
|
|
const days = parseFloat(r.vacation_days) || 1;
|
|
const code = r.vacation_type_code || 'other';
|
|
if (['ANNUAL_FULL', 'ANNUAL_HALF', 'ANNUAL_QUARTER', 'EARLY_LEAVE'].includes(code)) {
|
|
monthly[m][code] += days;
|
|
totals[code] += days;
|
|
} else {
|
|
monthly[m].other += days;
|
|
totals.other += days;
|
|
}
|
|
monthly[m].total += days;
|
|
});
|
|
|
|
const grandTotal = totals.ANNUAL_FULL + totals.ANNUAL_HALF + totals.ANNUAL_QUARTER + totals.EARLY_LEAVE + totals.other;
|
|
|
|
// 월별 요약 테이블
|
|
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><th style="position:static !important;">합계</th>';
|
|
html += '</tr></thead><tbody>';
|
|
for (var m = 1; m <= 12; m++) {
|
|
if (!monthly[m]) continue;
|
|
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>' + (md.EARLY_LEAVE > 0 ? fmtNum(md.EARLY_LEAVE) : '-') + '</td>';
|
|
html += '<td style="font-weight:600">' + fmtNum(md.total) + '</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>' + fmtNum(totals.EARLY_LEAVE) + '</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>';
|
|
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) {
|
|
document.getElementById('monthlyDetailBody').innerHTML = '<p style="text-align:center;color:#ef4444;padding:1rem;">데이터 로드 실패</p>';
|
|
}
|
|
}
|
|
|
|
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>'
|
|
+ (earlyLeaveVtId ? '<option value="' + earlyLeaveVtId + '"' + (currentTypeId==earlyLeaveVtId?' 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 => {
|
|
if (e.target.id === 'monthlyDetailModal') closeMonthlyDetail();
|
|
});
|
|
|
|
// ===== 저장 =====
|
|
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,
|
|
balance_type: 'CARRY_OVER'
|
|
});
|
|
}
|
|
if (typeIdMap['ANNUAL']) {
|
|
balancesToSave.push({
|
|
user_id: w.user_id,
|
|
vacation_type_id: typeIdMap['ANNUAL'],
|
|
year: currentYear,
|
|
total_days: d.annual,
|
|
balance_type: 'AUTO'
|
|
});
|
|
}
|
|
if (typeIdMap['LONG_SERVICE']) {
|
|
balancesToSave.push({
|
|
user_id: w.user_id,
|
|
vacation_type_id: typeIdMap['LONG_SERVICE'],
|
|
year: currentYear,
|
|
total_days: d.longService,
|
|
balance_type: 'LONG_SERVICE'
|
|
});
|
|
}
|
|
|
|
// 경조사 데이터 저장
|
|
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,
|
|
balance_type: 'COMPANY_GRANT'
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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>
|