Files
tk-factory-services/system1-factory/web/pages/attendance/annual-overview.html
Hyungi Ahn 0de9d5bb48 feat(sso): 인앱 브라우저 SSO 토큰 릴레이 — 카톡 WebView 쿠키 미공유 해결
카카오톡 인앱 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>
2026-04-01 15:44:02 +09:00

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()">&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>
<!-- 월별 사용 내역 모달 -->
<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()">&times;</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>