security: 보안 강제 시스템 구축 + 하드코딩 비밀번호 제거
보안 감사 결과 CRITICAL 2건, HIGH 5건 발견 → 수정 완료 + 자동화 구축. [보안 수정] - issue-view.js: 하드코딩 비밀번호 → crypto.getRandomValues() 랜덤 생성 - pushSubscriptionController.js: ntfy 비밀번호 → process.env.NTFY_SUB_PASSWORD - DEPLOY-GUIDE.md/PROGRESS.md/migration SQL: 평문 비밀번호 → placeholder - docker-compose.yml/.env.example: NTFY_SUB_PASSWORD 환경변수 추가 [보안 강제 시스템 - 신규] - scripts/security-scan.sh: 8개 규칙 (CRITICAL 2, HIGH 4, MEDIUM 2) 3모드(staged/all/diff), severity, .securityignore, MEDIUM 임계값 - .githooks/pre-commit: 로컬 빠른 피드백 - .githooks/pre-receive-server.sh: Gitea 서버 최종 차단 bypass 거버넌스([SECURITY-BYPASS: 사유] + 사용자 제한 + 로그) - SECURITY-CHECKLIST.md: 10개 카테고리 자동/수동 구분 - docs/SECURITY-GUIDE.md: 운영자 가이드 (워크플로우, bypass, FAQ) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1129
system1-factory/web/public/pages/attendance/annual-overview.html
Normal file
1129
system1-factory/web/public/pages/attendance/annual-overview.html
Normal file
File diff suppressed because it is too large
Load Diff
428
system1-factory/web/public/pages/attendance/checkin.html
Normal file
428
system1-factory/web/public/pages/attendance/checkin.html
Normal file
@@ -0,0 +1,428 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>출근 체크 - TK 공장관리</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
|
||||
<style>
|
||||
.page-wrapper {
|
||||
padding: 1.5rem;
|
||||
max-width: 1200px;
|
||||
}
|
||||
.page-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.page-desc {
|
||||
color: #64748b;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.controls input[type="date"] {
|
||||
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;
|
||||
}
|
||||
.btn-primary { background: #3b82f6; color: white; }
|
||||
.btn-outline { background: white; border: 1px solid #d1d5db; }
|
||||
.btn-success { background: #10b981; color: white; }
|
||||
|
||||
/* 요약 바 */
|
||||
.summary-bar {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
.summary-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.summary-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.dot-present { background: #10b981; }
|
||||
.dot-absent { background: #ef4444; }
|
||||
.dot-vacation { background: #3b82f6; }
|
||||
.summary-count { font-weight: 700; }
|
||||
.summary-label { color: #6b7280; font-size: 0.875rem; }
|
||||
|
||||
/* 작업자 목록 */
|
||||
.worker-list {
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
.worker-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
margin: 0.25rem;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 2rem;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.worker-chip:hover {
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
.worker-chip.present {
|
||||
border-color: #10b981;
|
||||
background: #ecfdf5;
|
||||
}
|
||||
.worker-chip.absent {
|
||||
border-color: #ef4444;
|
||||
background: #fef2f2;
|
||||
}
|
||||
.worker-chip.vacation {
|
||||
border-color: #3b82f6;
|
||||
background: #eff6ff;
|
||||
cursor: default;
|
||||
}
|
||||
.chip-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #d1d5db;
|
||||
}
|
||||
.worker-chip.present .chip-dot { background: #10b981; }
|
||||
.worker-chip.absent .chip-dot { background: #ef4444; }
|
||||
.worker-chip.vacation .chip-dot { background: #3b82f6; }
|
||||
|
||||
.save-section {
|
||||
text-align: center;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.btn-save {
|
||||
padding: 0.75rem 2rem;
|
||||
font-size: 1rem;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-save:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
.btn-save:disabled {
|
||||
background: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn-save.saved {
|
||||
background: #10b981;
|
||||
}
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.status-badge.saved {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
.status-badge.unsaved {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
/* 모바일 최적화 */
|
||||
@media (max-width: 768px) {
|
||||
.summary-bar { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.5rem; padding: 0.75rem; }
|
||||
.summary-item { flex-direction: column; text-align: center; }
|
||||
.summary-count { font-size: 1.25rem; }
|
||||
.controls { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; }
|
||||
.controls input[type="date"] { grid-column: 1 / -1; }
|
||||
.worker-chip { padding: 0.625rem 1rem; font-size: 1rem; }
|
||||
.btn-save { width: 100%; font-size: 1.1rem; padding: 1rem; }
|
||||
.save-section { position: sticky; bottom: 0; background: white; padding: 1rem; margin: 0 -1rem; box-shadow: 0 -2px 8px rgba(0,0,0,0.08); z-index: 20; }
|
||||
}
|
||||
</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">
|
||||
|
||||
<h1 class="page-title">출근 체크</h1>
|
||||
<p class="page-desc">클릭하여 출근/결근 상태를 변경하세요</p>
|
||||
|
||||
<div class="controls">
|
||||
<input type="date" id="selectedDate">
|
||||
<button class="btn btn-primary" onclick="loadCheckinData()">새로고침</button>
|
||||
<button class="btn btn-outline" onclick="setAllPresent()">전체 출근</button>
|
||||
<button class="btn btn-outline" onclick="setAllAbsent()">전체 결근</button>
|
||||
</div>
|
||||
|
||||
<div class="summary-bar">
|
||||
<div class="summary-item">
|
||||
<span class="summary-dot dot-present"></span>
|
||||
<span class="summary-count" id="presentCount">0</span>
|
||||
<span class="summary-label">출근</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-dot dot-absent"></span>
|
||||
<span class="summary-count" id="absentCount">0</span>
|
||||
<span class="summary-label">결근</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-dot dot-vacation"></span>
|
||||
<span class="summary-count" id="vacationCount">0</span>
|
||||
<span class="summary-label">연차</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="worker-list" id="workerList">
|
||||
<!-- 작업자 칩이 여기에 렌더링됩니다 -->
|
||||
</div>
|
||||
|
||||
<div class="save-section">
|
||||
<div id="saveStatus" style="margin-bottom: 1rem;"></div>
|
||||
<button id="saveBtn" class="btn-save" onclick="saveCheckin()">출근 체크 저장</button>
|
||||
</div>
|
||||
|
||||
</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>
|
||||
(function() {
|
||||
const checkApiConfig = setInterval(() => {
|
||||
if (window.API_BASE_URL) {
|
||||
clearInterval(checkApiConfig);
|
||||
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 checkinStatus = {};
|
||||
let isAlreadySaved = false;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await waitForAxiosConfig();
|
||||
document.getElementById('selectedDate').value = new Date().toISOString().split('T')[0];
|
||||
loadCheckinData();
|
||||
|
||||
// 날짜 변경 시 자동 로드
|
||||
document.getElementById('selectedDate').addEventListener('change', loadCheckinData);
|
||||
});
|
||||
|
||||
function waitForAxiosConfig() {
|
||||
return new Promise((resolve) => {
|
||||
const check = setInterval(() => {
|
||||
if (axios.defaults.baseURL) {
|
||||
clearInterval(check);
|
||||
resolve();
|
||||
}
|
||||
}, 50);
|
||||
setTimeout(() => { clearInterval(check); resolve(); }, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadCheckinData() {
|
||||
const selectedDate = document.getElementById('selectedDate').value;
|
||||
if (!selectedDate) return alert('날짜를 선택해주세요.');
|
||||
|
||||
try {
|
||||
const [workersRes, checkinRes, recordsRes] = await Promise.all([
|
||||
axios.get('/workers?limit=100'),
|
||||
axios.get(`/attendance/checkin-list?date=${selectedDate}`).catch(() => ({ data: { data: [] } })),
|
||||
axios.get(`/attendance/daily-records?date=${selectedDate}`).catch(() => ({ data: { data: [] } }))
|
||||
]);
|
||||
|
||||
const allWorkers = workersRes.data.data || [];
|
||||
workers = allWorkers.filter(w => w.status === 'active' && (!w.employment_status || w.employment_status === 'employed'));
|
||||
const checkinList = checkinRes.data.data || [];
|
||||
const records = recordsRes.data.data || [];
|
||||
|
||||
// 이미 저장된 기록이 있는지 확인
|
||||
isAlreadySaved = records.length > 0;
|
||||
|
||||
checkinStatus = {};
|
||||
workers.forEach(w => {
|
||||
const checkin = checkinList.find(c => c.user_id === w.user_id);
|
||||
const record = records.find(r => r.user_id === w.user_id);
|
||||
|
||||
if (checkin?.vacation_status === 'approved' || record?.vacation_type_id) {
|
||||
checkinStatus[w.user_id] = { status: 'vacation', vacationType: checkin?.vacation_type_name || record?.vacation_type_name || '연차' };
|
||||
} else if (record && record.is_present === 0) {
|
||||
checkinStatus[w.user_id] = { status: 'absent' };
|
||||
} else if (record && record.is_present === 1) {
|
||||
checkinStatus[w.user_id] = { status: 'present' };
|
||||
} else {
|
||||
// 기록이 없으면 기본 출근
|
||||
checkinStatus[w.user_id] = { status: 'present' };
|
||||
}
|
||||
});
|
||||
|
||||
render();
|
||||
updateSaveStatus();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('데이터 로드 실패');
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
const container = document.getElementById('workerList');
|
||||
if (workers.length === 0) {
|
||||
container.innerHTML = '<p style="color:#6b7280;text-align:center;padding:2rem;">작업자가 없습니다</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = workers.map(w => {
|
||||
const s = checkinStatus[w.user_id] || { status: 'present' };
|
||||
const label = s.status === 'present' ? '출근' : s.status === 'absent' ? '결근' : (s.vacationType || '연차');
|
||||
return `<span class="worker-chip ${s.status}" onclick="toggle(${w.user_id})"><span class="chip-dot"></span>${w.worker_name} <small style="color:#6b7280">${label}</small></span>`;
|
||||
}).join('');
|
||||
|
||||
updateSummary();
|
||||
}
|
||||
|
||||
function toggle(id) {
|
||||
const s = checkinStatus[id];
|
||||
if (s.status === 'vacation') return;
|
||||
s.status = s.status === 'present' ? 'absent' : 'present';
|
||||
render();
|
||||
}
|
||||
|
||||
function setAllPresent() {
|
||||
workers.forEach(w => {
|
||||
if (checkinStatus[w.user_id]?.status !== 'vacation') {
|
||||
checkinStatus[w.user_id] = { status: 'present' };
|
||||
}
|
||||
});
|
||||
render();
|
||||
}
|
||||
|
||||
function setAllAbsent() {
|
||||
workers.forEach(w => {
|
||||
if (checkinStatus[w.user_id]?.status !== 'vacation') {
|
||||
checkinStatus[w.user_id] = { status: 'absent' };
|
||||
}
|
||||
});
|
||||
render();
|
||||
}
|
||||
|
||||
function updateSummary() {
|
||||
let p = 0, a = 0, v = 0;
|
||||
Object.values(checkinStatus).forEach(s => {
|
||||
if (s.status === 'present') p++;
|
||||
else if (s.status === 'absent') a++;
|
||||
else v++;
|
||||
});
|
||||
document.getElementById('presentCount').textContent = p;
|
||||
document.getElementById('absentCount').textContent = a;
|
||||
document.getElementById('vacationCount').textContent = v;
|
||||
}
|
||||
|
||||
function updateSaveStatus() {
|
||||
const statusEl = document.getElementById('saveStatus');
|
||||
const saveBtn = document.getElementById('saveBtn');
|
||||
|
||||
if (isAlreadySaved) {
|
||||
statusEl.innerHTML = '<span class="status-badge saved">이 날짜는 이미 출근 체크가 완료되었습니다</span>';
|
||||
saveBtn.textContent = '수정하여 다시 저장';
|
||||
saveBtn.classList.add('saved');
|
||||
} else {
|
||||
statusEl.innerHTML = '<span class="status-badge unsaved">아직 저장되지 않았습니다</span>';
|
||||
saveBtn.textContent = '출근 체크 저장';
|
||||
saveBtn.classList.remove('saved');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCheckin() {
|
||||
const date = document.getElementById('selectedDate').value;
|
||||
if (!date) return alert('날짜를 선택해주세요.');
|
||||
|
||||
// 이미 저장된 경우 확인
|
||||
if (isAlreadySaved) {
|
||||
if (!confirm('이미 저장된 데이터가 있습니다.\n수정하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 연차가 아닌 작업자들만 체크인 데이터로 전송
|
||||
const checkins = workers
|
||||
.filter(w => checkinStatus[w.user_id]?.status !== 'vacation')
|
||||
.map(w => ({
|
||||
user_id: w.user_id,
|
||||
is_present: checkinStatus[w.user_id]?.status === 'present'
|
||||
}));
|
||||
|
||||
try {
|
||||
const res = await axios.post('/attendance/checkins', { date, checkins });
|
||||
if (res.data.success) {
|
||||
alert(`${res.data.data.saved_count}명 출근 체크 저장 완료`);
|
||||
isAlreadySaved = true;
|
||||
updateSaveStatus();
|
||||
} else {
|
||||
alert('저장 실패: ' + (res.data.message || '알 수 없는 오류'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('저장 실패: ' + (e.response?.data?.message || e.message));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script>initAuth();</script>
|
||||
</body>
|
||||
</html>
|
||||
472
system1-factory/web/public/pages/attendance/daily.html
Normal file
472
system1-factory/web/public/pages/attendance/daily.html
Normal file
@@ -0,0 +1,472 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>일일 출퇴근 입력 - TK 공장관리</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
|
||||
</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 class="page-title-section">
|
||||
<h1 class="page-title">일일 출퇴근 입력</h1>
|
||||
<p class="page-description">오늘 출근한 작업자들의 출퇴근 기록을 입력합니다</p>
|
||||
</div>
|
||||
|
||||
<div class="page-actions">
|
||||
<input type="date" id="selectedDate" class="form-control" style="width: auto;">
|
||||
<button class="btn btn-primary" onclick="loadAttendanceRecords()">
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 출퇴근 입력 폼 -->
|
||||
<div class="content-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">작업자 출퇴근 기록</h2>
|
||||
<p class="text-muted">근태 구분을 선택하면 근무시간이 자동 입력됩니다. 연장근로는 별도로 입력 가능합니다. (조퇴: 2시간, 반반차: 6시간, 반차: 4시간, 정시: 8시간, 주말근무: 0시간)</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="attendanceList" class="data-table-container">
|
||||
<!-- 출퇴근 기록 목록이 여기에 동적으로 렌더링됩니다 -->
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin-top: 2rem;">
|
||||
<button class="btn btn-primary" onclick="saveAllAttendance()" style="padding: 1rem 3rem; font-size: 1.1rem;">
|
||||
전체 저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</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 checkApiConfig = setInterval(() => {
|
||||
if (window.API_BASE_URL) {
|
||||
clearInterval(checkApiConfig);
|
||||
axios.defaults.baseURL = window.API_BASE_URL;
|
||||
const token = localStorage.getItem('sso_token');
|
||||
if (token) {
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
axios.interceptors.request.use(
|
||||
config => {
|
||||
const token = localStorage.getItem('sso_token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
error => Promise.reject(error)
|
||||
);
|
||||
|
||||
axios.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
if (error.response?.status === 401) {
|
||||
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
|
||||
window.location.href = '/pages/login.html';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
}, 50);
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// 전역 변수
|
||||
let workers = [];
|
||||
let attendanceRecords = [];
|
||||
|
||||
// 근태 구분 옵션 (근무시간 자동 설정, 연장근로는 별도 입력)
|
||||
const attendanceTypes = [
|
||||
{ value: 'on_time', label: '정시', hours: 8 },
|
||||
{ value: 'half_leave', label: '반차', hours: 4 },
|
||||
{ value: 'quarter_leave', label: '반반차', hours: 6 },
|
||||
{ value: 'early_leave', label: '조퇴', hours: 2 },
|
||||
{ value: 'weekend_work', label: '주말근무', hours: 0 },
|
||||
{ value: 'annual_leave', label: '연차', hours: 0 },
|
||||
{ value: 'custom', label: '특이사항', hours: 0 }
|
||||
];
|
||||
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await waitForAxiosConfig();
|
||||
initializePage();
|
||||
});
|
||||
|
||||
function waitForAxiosConfig() {
|
||||
return new Promise((resolve) => {
|
||||
const check = setInterval(() => {
|
||||
if (axios.defaults.baseURL) {
|
||||
clearInterval(check);
|
||||
resolve();
|
||||
}
|
||||
}, 50);
|
||||
setTimeout(() => {
|
||||
clearInterval(check);
|
||||
resolve();
|
||||
}, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
async function initializePage() {
|
||||
// 오늘 날짜 설정
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
document.getElementById('selectedDate').value = today;
|
||||
|
||||
try {
|
||||
await loadWorkers();
|
||||
await loadAttendanceRecords();
|
||||
} catch (error) {
|
||||
console.error('초기화 오류:', error);
|
||||
alert('페이지 초기화 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorkers() {
|
||||
try {
|
||||
const response = await axios.get('/workers?limit=100');
|
||||
if (response.data.success) {
|
||||
workers = response.data.data.filter(w => w.employment_status === 'employed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('작업자 목록 로드 오류:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAttendanceRecords() {
|
||||
const selectedDate = document.getElementById('selectedDate').value;
|
||||
if (!selectedDate) {
|
||||
alert('날짜를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 출퇴근 기록과 체크인 목록(휴가 정보 포함)을 동시에 가져오기
|
||||
const [recordsResponse, checkinResponse] = await Promise.all([
|
||||
axios.get(`/attendance/records?date=${selectedDate}`).catch(() => ({ data: { success: false, data: [] } })),
|
||||
axios.get(`/attendance/checkin-list?date=${selectedDate}`).catch(() => ({ data: { success: false, data: [] } }))
|
||||
]);
|
||||
|
||||
const existingRecords = recordsResponse.data.success ? recordsResponse.data.data : [];
|
||||
const checkinList = checkinResponse.data.success ? checkinResponse.data.data : [];
|
||||
|
||||
// 체크인 목록을 기준으로 출퇴근 기록 생성 (연차 정보 포함)
|
||||
attendanceRecords = checkinList.map(worker => {
|
||||
const existingRecord = existingRecords.find(r => r.user_id === worker.user_id);
|
||||
const isOnVacation = worker.vacation_status === 'approved';
|
||||
|
||||
// 기존 기록이 있으면 사용, 없으면 초기화
|
||||
if (existingRecord) {
|
||||
return existingRecord;
|
||||
} else {
|
||||
return {
|
||||
user_id: worker.user_id,
|
||||
worker_name: worker.worker_name,
|
||||
attendance_date: selectedDate,
|
||||
total_hours: isOnVacation ? 0 : 8,
|
||||
overtime_hours: 0,
|
||||
attendance_type: isOnVacation ? 'annual_leave' : 'on_time',
|
||||
is_custom: false,
|
||||
is_new: true,
|
||||
is_on_vacation: isOnVacation,
|
||||
vacation_type_name: worker.vacation_type_name
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
renderAttendanceList();
|
||||
} catch (error) {
|
||||
console.error('출퇴근 기록 로드 오류:', error);
|
||||
alert('출퇴근 기록 조회 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
function initializeAttendanceRecords() {
|
||||
const selectedDate = document.getElementById('selectedDate').value;
|
||||
attendanceRecords = workers.map(worker => ({
|
||||
user_id: worker.user_id,
|
||||
worker_name: worker.worker_name,
|
||||
attendance_date: selectedDate,
|
||||
total_hours: 8,
|
||||
overtime_hours: 0,
|
||||
attendance_type: 'on_time',
|
||||
is_custom: false,
|
||||
is_new: true
|
||||
}));
|
||||
renderAttendanceList();
|
||||
}
|
||||
|
||||
function renderAttendanceList() {
|
||||
const container = document.getElementById('attendanceList');
|
||||
|
||||
if (attendanceRecords.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<p>등록된 작업자가 없거나 출퇴근 기록이 없습니다.</p>
|
||||
<button class="btn btn-primary" onclick="initializeAttendanceRecords()">
|
||||
작업자 목록으로 초기화
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
|
||||
if (isMobile) {
|
||||
// 모바일: 카드 뷰
|
||||
const cardsHTML = `
|
||||
<div class="mobile-attendance-cards">
|
||||
${attendanceRecords.map((record, index) => {
|
||||
const isCustom = record.is_custom || record.attendance_type === 'custom';
|
||||
const isHoursReadonly = !isCustom;
|
||||
const isOnVacation = record.is_on_vacation || false;
|
||||
const vacationLabel = record.vacation_type_name ? ` (${record.vacation_type_name})` : '';
|
||||
|
||||
return `
|
||||
<div class="mobile-attendance-card ${isOnVacation ? 'vacation' : ''}">
|
||||
<div class="card-name">
|
||||
${record.worker_name}
|
||||
${isOnVacation ? `<span style="padding: 0.125rem 0.5rem; background: #dbeafe; color: #1e40af; border-radius: 0.25rem; font-size: 0.7rem; font-weight: 500;">연차${vacationLabel}</span>` : ''}
|
||||
</div>
|
||||
<div class="card-fields">
|
||||
<div class="card-field">
|
||||
<label>근태 구분</label>
|
||||
<select onchange="updateAttendanceType(${index}, this.value)">
|
||||
${attendanceTypes.map(type => `
|
||||
<option value="${type.value}" ${record.attendance_type === type.value ? 'selected' : ''}>${type.label}</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="card-field">
|
||||
<label>근무시간</label>
|
||||
<input type="number" id="hours_${index}" value="${record.total_hours || 0}"
|
||||
min="0" max="24" step="0.5" ${isHoursReadonly ? 'readonly' : ''}
|
||||
onchange="updateTotalHours(${index}, this.value)"
|
||||
style="background: ${isHoursReadonly ? '#f9fafb' : 'white'}; text-align: center;">
|
||||
</div>
|
||||
<div class="card-field">
|
||||
<label>연장근로</label>
|
||||
<input type="number" id="overtime_${index}" value="${record.overtime_hours || 0}"
|
||||
min="0" max="12" step="0.5"
|
||||
onchange="updateOvertimeHours(${index}, this.value)"
|
||||
style="text-align: center;">
|
||||
</div>
|
||||
<div class="card-field">
|
||||
<label>특이사항</label>
|
||||
<div style="padding: 0.5rem; text-align: center;">
|
||||
${isCustom ? '<span style="color: #dc2626; font-weight: 600;">✓</span>' : '<span style="color: #9ca3af;">-</span>'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
`;
|
||||
container.innerHTML = cardsHTML;
|
||||
} else {
|
||||
// 데스크탑: 테이블 뷰
|
||||
const tableHTML = `
|
||||
<table class="data-table" style="font-size: 0.95rem;">
|
||||
<thead style="background-color: #f8f9fa;">
|
||||
<tr>
|
||||
<th style="width: 120px;">작업자</th>
|
||||
<th style="width: 180px;">근태 구분</th>
|
||||
<th style="width: 100px;">근무시간</th>
|
||||
<th style="width: 120px;">연장근로</th>
|
||||
<th style="width: 100px;">특이사항</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${attendanceRecords.map((record, index) => {
|
||||
const isCustom = record.is_custom || record.attendance_type === 'custom';
|
||||
const isHoursReadonly = !isCustom;
|
||||
const isOnVacation = record.is_on_vacation || false;
|
||||
const vacationLabel = record.vacation_type_name ? ` (${record.vacation_type_name})` : '';
|
||||
|
||||
return `
|
||||
<tr style="border-bottom: 1px solid #e5e7eb; ${isOnVacation ? 'background-color: #f0f9ff;' : ''}">
|
||||
<td style="padding: 0.75rem; font-weight: 600;">
|
||||
${record.worker_name}
|
||||
${isOnVacation ? `<span style="margin-left: 0.5rem; display: inline-block; padding: 0.125rem 0.5rem; background-color: #dbeafe; color: #1e40af; border-radius: 0.25rem; font-size: 0.75rem; font-weight: 500;">연차${vacationLabel}</span>` : ''}
|
||||
</td>
|
||||
<td style="padding: 0.75rem;">
|
||||
<select class="form-control"
|
||||
onchange="updateAttendanceType(${index}, this.value)"
|
||||
style="width: 160px; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 0.375rem;">
|
||||
${attendanceTypes.map(type => `
|
||||
<option value="${type.value}" ${record.attendance_type === type.value ? 'selected' : ''}>
|
||||
${type.label}
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</td>
|
||||
<td style="padding: 0.75rem;">
|
||||
<input type="number" class="form-control"
|
||||
id="hours_${index}"
|
||||
value="${record.total_hours || 0}"
|
||||
min="0" max="24" step="0.5"
|
||||
${isHoursReadonly ? 'readonly' : ''}
|
||||
onchange="updateTotalHours(${index}, this.value)"
|
||||
style="width: 90px; padding: 0.5rem; border: 1px solid ${isHoursReadonly ? '#e5e7eb' : '#d1d5db'};
|
||||
border-radius: 0.375rem; background-color: ${isHoursReadonly ? '#f9fafb' : 'white'}; text-align: center;">
|
||||
</td>
|
||||
<td style="padding: 0.75rem;">
|
||||
<input type="number" class="form-control"
|
||||
id="overtime_${index}"
|
||||
value="${record.overtime_hours || 0}"
|
||||
min="0" max="12" step="0.5"
|
||||
onchange="updateOvertimeHours(${index}, this.value)"
|
||||
style="width: 90px; padding: 0.5rem; border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem; background-color: white; text-align: center;">
|
||||
</td>
|
||||
<td style="padding: 0.75rem; text-align: center;">
|
||||
${isCustom ?
|
||||
'<span style="color: #dc2626; font-weight: 600;">✓</span>' :
|
||||
'<span style="color: #9ca3af;">-</span>'}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
container.innerHTML = tableHTML;
|
||||
}
|
||||
}
|
||||
|
||||
// 화면 크기 변경 시 재렌더링
|
||||
let resizeTimer;
|
||||
window.addEventListener('resize', () => {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(() => {
|
||||
if (attendanceRecords.length > 0) renderAttendanceList();
|
||||
}, 250);
|
||||
});
|
||||
|
||||
function updateTotalHours(index, value) {
|
||||
attendanceRecords[index].total_hours = parseFloat(value) || 0;
|
||||
}
|
||||
|
||||
function updateOvertimeHours(index, value) {
|
||||
attendanceRecords[index].overtime_hours = parseFloat(value) || 0;
|
||||
}
|
||||
|
||||
function updateAttendanceType(index, value) {
|
||||
const record = attendanceRecords[index];
|
||||
record.attendance_type = value;
|
||||
|
||||
// 근태 구분에 따라 자동으로 근무시간 설정
|
||||
const attendanceType = attendanceTypes.find(t => t.value === value);
|
||||
|
||||
if (value === 'custom') {
|
||||
// 특이사항 선택 시 수동 입력 가능
|
||||
record.is_custom = true;
|
||||
// 기존 값 유지, 수동 입력 가능
|
||||
} else if (attendanceType) {
|
||||
// 다른 근태 구분 선택 시 근무시간만 자동 설정
|
||||
record.is_custom = false;
|
||||
record.total_hours = attendanceType.hours;
|
||||
// 연장근로는 유지 (별도 입력 가능)
|
||||
}
|
||||
|
||||
// UI 다시 렌더링
|
||||
renderAttendanceList();
|
||||
}
|
||||
|
||||
async function saveAllAttendance() {
|
||||
const selectedDate = document.getElementById('selectedDate').value;
|
||||
|
||||
if (!selectedDate) {
|
||||
alert('날짜를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (attendanceRecords.length === 0) {
|
||||
alert('저장할 출퇴근 기록이 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 모든 기록을 API 형식에 맞게 변환
|
||||
const recordsToSave = attendanceRecords.map(record => ({
|
||||
user_id: record.user_id,
|
||||
attendance_date: selectedDate,
|
||||
total_hours: record.total_hours || 0,
|
||||
overtime_hours: record.overtime_hours || 0,
|
||||
attendance_type: record.attendance_type || 'on_time',
|
||||
is_custom: record.is_custom || false
|
||||
}));
|
||||
|
||||
try {
|
||||
// 각 기록을 순차적으로 저장
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const data of recordsToSave) {
|
||||
try {
|
||||
const response = await axios.post('/attendance/records', data);
|
||||
if (response.data.success) {
|
||||
successCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`작업자 ${data.user_id} 저장 오류:`, error);
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (errorCount === 0) {
|
||||
alert(`${successCount}건의 출퇴근 기록이 모두 저장되었습니다.`);
|
||||
await loadAttendanceRecords(); // 저장 후 새로고침
|
||||
} else {
|
||||
alert(`${successCount}건 저장 완료, ${errorCount}건 저장 실패\n자세한 내용은 콘솔을 확인해주세요.`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('전체 저장 오류:', error);
|
||||
alert('저장 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script>initAuth();</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,171 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<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">
|
||||
<link rel="stylesheet" href="/css/monthly-comparison.css?v=2026040107">
|
||||
</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="mc-header">
|
||||
<div class="mc-header-row">
|
||||
<button type="button" onclick="typeof goBackToList==='function'?goBackToList():history.back()" class="mc-back-btn"><i class="fas fa-arrow-left"></i></button>
|
||||
<h1 id="pageTitle" style="display:flex;align-items:center;gap:8px;flex:1">월간 근무 비교</h1>
|
||||
<button id="viewToggleBtn" class="mc-view-toggle hidden" onclick="toggleViewMode()">
|
||||
<i class="fas fa-users-cog"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 월 네비게이션 -->
|
||||
<div class="mc-month-nav">
|
||||
<button type="button" onclick="changeMonth(-1)"><i class="fas fa-chevron-left"></i></button>
|
||||
<span id="monthLabel">2026년 3월</span>
|
||||
<button type="button" onclick="changeMonth(1)"><i class="fas fa-chevron-right"></i></button>
|
||||
<div class="mc-status-badge" id="statusBadge"></div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ 작업자 뷰 ═══ -->
|
||||
<div id="workerView">
|
||||
<div class="mc-summary-cards">
|
||||
<div class="mc-card"><div class="mc-card-value" id="totalDays">-</div><div class="mc-card-label">총근무일</div></div>
|
||||
<div class="mc-card"><div class="mc-card-value" id="totalHours">-</div><div class="mc-card-label">총시간</div></div>
|
||||
<div class="mc-card"><div class="mc-card-value" id="overtimeHours">-</div><div class="mc-card-label">연장근로</div></div>
|
||||
<div class="mc-card"><div class="mc-card-value" id="vacationDays">-</div><div class="mc-card-label">휴가</div></div>
|
||||
</div>
|
||||
|
||||
<div class="mc-mismatch-alert hidden" id="mismatchAlert">
|
||||
<i class="fas fa-exclamation-triangle text-amber-500"></i>
|
||||
<span id="mismatchText"></span>
|
||||
</div>
|
||||
|
||||
<div class="mc-daily-list" id="dailyList">
|
||||
<div class="ds-skeleton"></div>
|
||||
<div class="ds-skeleton"></div>
|
||||
<div class="ds-skeleton"></div>
|
||||
</div>
|
||||
|
||||
<div class="mc-bottom-actions hidden" id="bottomActions">
|
||||
<button type="button" class="mc-confirm-btn" id="confirmBtn" onclick="confirmMonth()">
|
||||
<i class="fas fa-check-circle mr-2"></i>확인 완료
|
||||
</button>
|
||||
<button type="button" class="mc-reject-btn" id="rejectBtn" onclick="openRejectModal()">
|
||||
<i class="fas fa-times-circle mr-2"></i>문제 있음
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mc-confirmed-status hidden" id="confirmedStatus">
|
||||
<i class="fas fa-check-circle text-green-500"></i>
|
||||
<span id="confirmedText"></span>
|
||||
</div>
|
||||
|
||||
<!-- 하단 검토완료 버튼 제거됨 — 상단 헤더로 이동 -->
|
||||
</div>
|
||||
|
||||
<!-- ═══ 관리자 뷰 ═══ -->
|
||||
<div id="adminView" class="hidden">
|
||||
<div class="mc-admin-summary" id="adminSummary">
|
||||
<div class="mc-progress-bar"><div class="mc-progress-fill" id="progressFill"></div></div>
|
||||
<div class="mc-progress-text" id="progressText"></div>
|
||||
<div class="mc-status-counts" id="statusCounts"></div>
|
||||
<button type="button" class="mc-review-send-btn hidden" id="reviewSendBtn" onclick="sendReviewAll()" style="margin-top:8px;width:100%;padding:10px;background:#2563eb;color:white;border:none;border-radius:8px;font-size:0.8rem;font-weight:600;cursor:pointer;">확인요청 발송</button>
|
||||
</div>
|
||||
|
||||
<div class="mc-filter-tabs">
|
||||
<button class="mc-tab active" data-filter="all" onclick="filterWorkers('all')">전체</button>
|
||||
<button class="mc-tab" data-filter="confirmed" onclick="filterWorkers('confirmed')">확인</button>
|
||||
<button class="mc-tab" data-filter="review_sent" onclick="filterWorkers('review_sent')">확인요청</button>
|
||||
<button class="mc-tab" data-filter="pending" onclick="filterWorkers('pending')">미검토</button>
|
||||
<button class="mc-tab" data-filter="change_request" onclick="filterWorkers('change_request')">수정요청</button>
|
||||
<button class="mc-tab" data-filter="rejected" onclick="filterWorkers('rejected')">반려</button>
|
||||
</div>
|
||||
|
||||
<div class="mc-worker-list" id="adminWorkerList">
|
||||
<div class="ds-skeleton"></div>
|
||||
</div>
|
||||
|
||||
<div class="mc-bottom-export" id="bottomExport">
|
||||
<button type="button" class="mc-export-btn" id="exportBtn" onclick="downloadExcel()" disabled>
|
||||
<i class="fas fa-file-excel mr-2"></i>엑셀 다운로드
|
||||
</button>
|
||||
<div class="mc-export-note" id="exportNote"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 빈 상태 -->
|
||||
<div class="mc-empty hidden" id="emptyState">
|
||||
<i class="fas fa-calendar-xmark text-3xl text-gray-300"></i>
|
||||
<p>해당 월의 데이터가 없습니다</p>
|
||||
</div>
|
||||
|
||||
<!-- 권한 없음 -->
|
||||
<div class="ds-empty hidden" id="noPermission">
|
||||
<i class="fas fa-lock text-3xl text-gray-300"></i>
|
||||
<p>접근 권한이 없습니다</p>
|
||||
<a href="/pages/dashboard-new.html" class="ds-link">대시보드로 이동</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 반려 모달 -->
|
||||
<div class="mc-modal-overlay hidden" id="rejectModal">
|
||||
<div class="mc-modal">
|
||||
<div class="mc-modal-header">
|
||||
<span><i class="fas fa-times-circle text-red-500 mr-2"></i>문제 있음 (반려)</span>
|
||||
<button type="button" onclick="closeRejectModal()"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
<div class="mc-modal-body">
|
||||
<p class="mc-modal-desc">반려 사유를 입력해주세요:</p>
|
||||
<textarea id="rejectReason" class="mc-textarea" rows="3" placeholder="예: 3/2 근무시간이 실제와 다릅니다"></textarea>
|
||||
<p class="mc-modal-note">
|
||||
<i class="fas fa-info-circle text-blue-400 mr-1"></i>
|
||||
반려 시 생산지원팀에 알림이 전달됩니다.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mc-modal-footer">
|
||||
<button type="button" class="mc-modal-cancel" onclick="closeRejectModal()">취소</button>
|
||||
<button type="button" class="mc-modal-submit" id="rejectSubmitBtn" onclick="submitReject()">반려 제출</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast -->
|
||||
<div id="toastContainer" class="toast-container"></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=2026031701"></script>
|
||||
<script src="/js/monthly-comparison.js?v=2026040109"></script>
|
||||
<script>initAuth();</script>
|
||||
</body>
|
||||
</html>
|
||||
1191
system1-factory/web/public/pages/attendance/monthly.html
Normal file
1191
system1-factory/web/public/pages/attendance/monthly.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,113 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<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">
|
||||
<link rel="stylesheet" href="/css/my-monthly-confirm.css?v=2026040106">
|
||||
</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="mmc-month-nav">
|
||||
<button type="button" onclick="changeMonth(-1)"><i class="fas fa-chevron-left"></i></button>
|
||||
<span id="monthLabel">2026년 3월</span>
|
||||
<button type="button" onclick="changeMonth(1)"><i class="fas fa-chevron-right"></i></button>
|
||||
<div class="mmc-status-badge" id="statusBadge"></div>
|
||||
</div>
|
||||
|
||||
<!-- 사용자 정보 -->
|
||||
<div class="mmc-user-info" id="userInfo">
|
||||
<span id="userName">-</span>
|
||||
<span id="userDept" class="mmc-user-dept">-</span>
|
||||
</div>
|
||||
|
||||
<!-- 출근부 테이블 -->
|
||||
<div class="mmc-table-wrap" id="tableWrap">
|
||||
<div class="mmc-skeleton"></div>
|
||||
<div class="mmc-skeleton"></div>
|
||||
</div>
|
||||
|
||||
<!-- 요약 카드 -->
|
||||
<div class="mmc-sum-cards" id="summaryCards"></div>
|
||||
|
||||
<!-- 연차 현황 -->
|
||||
<div class="mmc-vacation-cards" id="vacationCards"></div>
|
||||
|
||||
<!-- 확인 상태 메시지 -->
|
||||
<div class="mmc-confirmed-status hidden" id="confirmedStatus">
|
||||
<i class="fas fa-check-circle text-green-500"></i>
|
||||
<span id="confirmedText"></span>
|
||||
</div>
|
||||
|
||||
<!-- 확인/반려 버튼 -->
|
||||
<div class="mmc-bottom-actions" id="bottomActions">
|
||||
<button type="button" class="mmc-confirm-btn" id="confirmBtn" onclick="confirmMonth()">
|
||||
<i class="fas fa-check-circle mr-2"></i>확인 완료
|
||||
</button>
|
||||
<button type="button" class="mmc-reject-btn" id="rejectBtn" onclick="openRejectModal()">
|
||||
<i class="fas fa-times-circle mr-2"></i>문제 있음
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 반려 모달 -->
|
||||
<div class="mmc-modal-overlay hidden" id="rejectModal">
|
||||
<div class="mmc-modal">
|
||||
<div class="mmc-modal-header">
|
||||
<span><i class="fas fa-times-circle text-red-500 mr-2"></i>문제 있음 (반려)</span>
|
||||
<button type="button" onclick="closeRejectModal()"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
<div class="mmc-modal-body">
|
||||
<p class="mmc-modal-desc">반려 사유를 입력해주세요:</p>
|
||||
<textarea id="rejectReason" class="mmc-textarea" rows="3" placeholder="예: 3/2 근무시간이 실제와 다릅니다"></textarea>
|
||||
<p class="mmc-modal-note">
|
||||
<i class="fas fa-info-circle text-blue-400 mr-1"></i>
|
||||
반려 시 생산지원팀에 알림이 전달됩니다.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mmc-modal-footer">
|
||||
<button type="button" class="mmc-modal-cancel" onclick="closeRejectModal()">취소</button>
|
||||
<button type="button" class="mmc-modal-submit" onclick="submitReject()">반려 제출</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast -->
|
||||
<div id="toastContainer" class="toast-container"></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=2026031701"></script>
|
||||
<script src="/js/my-monthly-confirm.js?v=2026040106"></script>
|
||||
<script>initAuth();</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,575 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>내 연차 정보 - TK 공장관리</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
|
||||
<link rel="stylesheet" href="/css/tbm-mobile.css?v=2026033108">
|
||||
<style>
|
||||
.page-wrapper {
|
||||
padding-bottom: calc(80px + env(safe-area-inset-bottom, 0px));
|
||||
padding: 1.5rem;
|
||||
max-width: 1000px;
|
||||
}
|
||||
.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; }
|
||||
|
||||
/* 작업자 선택 (관리자용) */
|
||||
.admin-controls {
|
||||
display: none;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #fef3c7;
|
||||
border: 1px solid #f59e0b;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.admin-controls.visible { display: flex; }
|
||||
.admin-controls label { font-weight: 500; color: #92400e; }
|
||||
.admin-controls select {
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
/* 카드 그리드 */
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* 연차 카드 */
|
||||
.vacation-card {
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
.vacation-card h3 {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 1rem 0;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid #3b82f6;
|
||||
color: #1e40af;
|
||||
}
|
||||
.vacation-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
.vacation-item:last-child { border-bottom: none; }
|
||||
.vacation-item .label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #374151;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.vacation-item .dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.dot-carryover { background: #fbbf24; }
|
||||
.dot-annual { background: #3b82f6; }
|
||||
.dot-longservice { background: #a855f7; }
|
||||
.dot-special { background: #ec4899; }
|
||||
.vacation-item .days {
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.days.positive { color: #059669; }
|
||||
.days.zero { color: #9ca3af; }
|
||||
.days.negative { color: #dc2626; }
|
||||
|
||||
/* 총 합계 */
|
||||
.vacation-total {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 2px solid #e5e7eb;
|
||||
font-weight: 600;
|
||||
}
|
||||
.vacation-total .label { font-size: 0.9rem; color: #111827; }
|
||||
.vacation-total .days { font-size: 1.25rem; }
|
||||
|
||||
/* 연장근로 카드 */
|
||||
.overtime-card {
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
.overtime-card h3 {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 1rem 0;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid #f97316;
|
||||
color: #c2410c;
|
||||
}
|
||||
.overtime-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.overtime-controls select {
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.overtime-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.overtime-stat {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
background: #fff7ed;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.overtime-stat .value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #ea580c;
|
||||
}
|
||||
.overtime-stat .label {
|
||||
font-size: 0.75rem;
|
||||
color: #9a3412;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* 월별 상세 */
|
||||
.overtime-detail {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.overtime-day {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.4rem 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.overtime-day:last-child { border-bottom: none; }
|
||||
.overtime-day .date { color: #6b7280; }
|
||||
.overtime-day .hours { font-weight: 600; color: #ea580c; }
|
||||
|
||||
/* 로딩/에러 */
|
||||
.loading, .error, .no-data {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
.error { color: #dc2626; }
|
||||
|
||||
/* 안내 메시지 */
|
||||
.info-message {
|
||||
padding: 1rem;
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 0.5rem;
|
||||
color: #1e40af;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</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" id="workerNameDisplay">연차 잔여 현황 및 월간 연장근로 시간을 확인합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 관리자용 작업자 선택 -->
|
||||
<div class="admin-controls" id="adminControls">
|
||||
<label>작업자 선택:</label>
|
||||
<select id="workerSelect" onchange="onWorkerChange()">
|
||||
<option value="">-- 선택 --</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 작업자 미연결 안내 -->
|
||||
<div class="info-message" id="noWorkerMessage" style="display:none;">
|
||||
현재 계정에 연결된 작업자 정보가 없습니다. 관리자에게 문의하세요.
|
||||
</div>
|
||||
|
||||
<!-- 정보 그리드 -->
|
||||
<div class="info-grid" id="infoGrid" style="display:none;">
|
||||
<!-- 연차 잔여 현황 -->
|
||||
<div class="vacation-card">
|
||||
<h3 id="vacationCardTitle">연차 잔여 현황</h3>
|
||||
<div id="vacationList">
|
||||
<div class="loading">로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 월간 연장근로 -->
|
||||
<div class="overtime-card">
|
||||
<h3>월간 연장근로 현황</h3>
|
||||
<div class="overtime-controls">
|
||||
<select id="yearSelect" onchange="loadOvertimeData()"></select>
|
||||
<select id="monthSelect" onchange="loadOvertimeData()"></select>
|
||||
</div>
|
||||
<div id="overtimeContent">
|
||||
<div class="loading">로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</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 = window.getSSOToken ? window.getSSOToken() : (localStorage.getItem('sso_token'));
|
||||
if (token) axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
}, 50);
|
||||
})();
|
||||
|
||||
// 전역 변수
|
||||
let currentUser = null;
|
||||
let currentWorkerId = null;
|
||||
let isAdmin = false;
|
||||
let workers = [];
|
||||
|
||||
// 초기화
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await waitForAxios();
|
||||
await initPage();
|
||||
});
|
||||
|
||||
function waitForAxios() {
|
||||
return new Promise(resolve => {
|
||||
const check = setInterval(() => {
|
||||
if (axios.defaults.baseURL) { clearInterval(check); resolve(); }
|
||||
}, 50);
|
||||
setTimeout(() => { clearInterval(check); resolve(); }, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
async function initPage() {
|
||||
// 현재 사용자 정보 가져오기 (쿠키 우선, localStorage 폴백)
|
||||
currentUser = window.getSSOUser ? window.getSSOUser() : null;
|
||||
if (!currentUser) {
|
||||
const userStr = localStorage.getItem('sso_user');
|
||||
if (userStr) { try { currentUser = JSON.parse(userStr); } catch(e) {} }
|
||||
}
|
||||
|
||||
// 관리자 여부 확인
|
||||
const userRole = (currentUser?.role || currentUser?.access_level || '').toLowerCase();
|
||||
isAdmin = ['admin', 'system admin', 'system', 'system_admin'].includes(userRole);
|
||||
|
||||
// 연도/월 선택기 초기화
|
||||
initDateSelectors();
|
||||
|
||||
if (isAdmin) {
|
||||
// 관리자: 작업자 선택 UI 표시
|
||||
document.getElementById('adminControls').classList.add('visible');
|
||||
await loadWorkers();
|
||||
} else {
|
||||
// 일반 사용자: 본인 user_id 사용
|
||||
if (currentUser?.user_id) {
|
||||
currentWorkerId = currentUser.user_id;
|
||||
document.getElementById('infoGrid').style.display = 'grid';
|
||||
await loadAllData();
|
||||
} else {
|
||||
// user_id가 없는 경우
|
||||
document.getElementById('noWorkerMessage').style.display = 'block';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function initDateSelectors() {
|
||||
const now = new Date();
|
||||
const yearSelect = document.getElementById('yearSelect');
|
||||
const monthSelect = document.getElementById('monthSelect');
|
||||
|
||||
// 연도 (올해 ± 1년)
|
||||
for (let y = now.getFullYear() - 1; y <= now.getFullYear() + 1; y++) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = y;
|
||||
opt.textContent = `${y}년`;
|
||||
if (y === now.getFullYear()) opt.selected = true;
|
||||
yearSelect.appendChild(opt);
|
||||
}
|
||||
|
||||
// 월
|
||||
for (let m = 1; m <= 12; m++) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m;
|
||||
opt.textContent = `${m}월`;
|
||||
if (m === now.getMonth() + 1) opt.selected = true;
|
||||
monthSelect.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorkers() {
|
||||
try {
|
||||
const res = await axios.get('/workers?limit=100');
|
||||
workers = (res.data.data || [])
|
||||
.filter(w => w.status === 'active' && w.employment_status === 'employed')
|
||||
.sort((a, b) => a.worker_name.localeCompare(b.worker_name, 'ko'));
|
||||
|
||||
const select = document.getElementById('workerSelect');
|
||||
workers.forEach(w => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = w.user_id;
|
||||
opt.textContent = w.worker_name;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('작업자 목록 로드 실패:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function onWorkerChange() {
|
||||
const workerId = document.getElementById('workerSelect').value;
|
||||
if (!workerId) {
|
||||
document.getElementById('infoGrid').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
currentWorkerId = parseInt(workerId);
|
||||
const worker = workers.find(w => w.user_id === currentWorkerId);
|
||||
document.getElementById('workerNameDisplay').textContent =
|
||||
`${worker?.worker_name || ''}님의 연차 잔여 현황 및 월간 연장근로 시간`;
|
||||
document.getElementById('infoGrid').style.display = 'grid';
|
||||
await loadAllData();
|
||||
}
|
||||
|
||||
async function loadAllData() {
|
||||
await Promise.all([
|
||||
loadVacationData(),
|
||||
loadOvertimeData()
|
||||
]);
|
||||
}
|
||||
|
||||
// ===== 연차 잔여 현황 =====
|
||||
async function loadVacationData() {
|
||||
const container = document.getElementById('vacationList');
|
||||
container.innerHTML = '<div class="loading">로딩 중...</div>';
|
||||
|
||||
const year = new Date().getFullYear();
|
||||
document.getElementById('vacationCardTitle').textContent = `연차 잔여 현황 (${year}년)`;
|
||||
|
||||
try {
|
||||
const res = await axios.get(`/vacation-balances/worker/${currentWorkerId}/year/${year}`);
|
||||
const balances = res.data.data || [];
|
||||
|
||||
if (balances.length === 0) {
|
||||
container.innerHTML = '<div class="no-data">등록된 연차 정보가 없습니다</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// 유형별 정리
|
||||
const typeOrder = ['CARRYOVER', 'ANNUAL', 'LONG_SERVICE'];
|
||||
const typeNames = {
|
||||
'CARRYOVER': '이월',
|
||||
'ANNUAL': '정기연차',
|
||||
'LONG_SERVICE': '장기근속'
|
||||
};
|
||||
const dotClasses = {
|
||||
'CARRYOVER': 'dot-carryover',
|
||||
'ANNUAL': 'dot-annual',
|
||||
'LONG_SERVICE': 'dot-longservice'
|
||||
};
|
||||
|
||||
let totalDays = 0;
|
||||
let usedDays = 0;
|
||||
let html = '';
|
||||
|
||||
// 정렬된 순서로 표시
|
||||
const sortedBalances = balances.sort((a, b) => {
|
||||
const aIdx = typeOrder.indexOf(a.type_code);
|
||||
const bIdx = typeOrder.indexOf(b.type_code);
|
||||
if (aIdx === -1 && bIdx === -1) return 0;
|
||||
if (aIdx === -1) return 1;
|
||||
if (bIdx === -1) return -1;
|
||||
return aIdx - bIdx;
|
||||
});
|
||||
|
||||
sortedBalances.forEach(b => {
|
||||
const total = parseFloat(b.total_days) || 0;
|
||||
const used = parseFloat(b.used_days) || 0;
|
||||
const remaining = total - used;
|
||||
totalDays += total;
|
||||
usedDays += used;
|
||||
|
||||
const dotClass = dotClasses[b.type_code] || 'dot-special';
|
||||
const typeName = typeNames[b.type_code] || b.type_name || b.type_code;
|
||||
const remainingClass = remaining > 0 ? 'positive' : remaining < 0 ? 'negative' : 'zero';
|
||||
|
||||
html += `
|
||||
<div class="vacation-item">
|
||||
<span class="label">
|
||||
<span class="dot ${dotClass}"></span>
|
||||
${typeName}
|
||||
</span>
|
||||
<span class="days ${remainingClass}">
|
||||
${remaining.toFixed(1)}일
|
||||
<small style="color:#9ca3af;font-weight:normal;">(${total.toFixed(1)} - ${used.toFixed(1)})</small>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
// 총 합계
|
||||
const totalRemaining = totalDays - usedDays;
|
||||
const totalClass = totalRemaining > 0 ? 'positive' : totalRemaining < 0 ? 'negative' : 'zero';
|
||||
|
||||
html += `
|
||||
<div class="vacation-total">
|
||||
<span class="label">총 잔여</span>
|
||||
<span class="days ${totalClass}">${totalRemaining.toFixed(1)}일</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
} catch (e) {
|
||||
console.error('연차 데이터 로드 실패:', e);
|
||||
container.innerHTML = '<div class="error">데이터를 불러오는데 실패했습니다</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 월간 연장근로 =====
|
||||
async function loadOvertimeData() {
|
||||
const container = document.getElementById('overtimeContent');
|
||||
container.innerHTML = '<div class="loading">로딩 중...</div>';
|
||||
|
||||
const year = parseInt(document.getElementById('yearSelect').value);
|
||||
const month = parseInt(document.getElementById('monthSelect').value);
|
||||
|
||||
// 해당 월의 시작일/종료일
|
||||
const startDate = `${year}-${String(month).padStart(2, '0')}-01`;
|
||||
const lastDay = new Date(year, month, 0).getDate();
|
||||
const endDate = `${year}-${String(month).padStart(2, '0')}-${lastDay}`;
|
||||
|
||||
try {
|
||||
// 근태 기록에서 연장근로 데이터 조회
|
||||
const res = await axios.get(`/attendance/records?start_date=${startDate}&end_date=${endDate}&user_id=${currentWorkerId}`);
|
||||
const records = res.data.data || [];
|
||||
|
||||
// 8시간 초과분 계산
|
||||
let totalOvertimeHours = 0;
|
||||
const overtimeDays = [];
|
||||
|
||||
records.forEach(r => {
|
||||
const hours = parseFloat(r.total_work_hours) || 0;
|
||||
if (hours > 8) {
|
||||
const overtime = hours - 8;
|
||||
totalOvertimeHours += overtime;
|
||||
overtimeDays.push({
|
||||
date: r.record_date,
|
||||
hours: overtime
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 총 근무일수
|
||||
const workDays = records.filter(r => (parseFloat(r.total_work_hours) || 0) > 0).length;
|
||||
|
||||
// 렌더링
|
||||
let html = `
|
||||
<div class="overtime-summary">
|
||||
<div class="overtime-stat">
|
||||
<div class="value">${totalOvertimeHours.toFixed(1)}h</div>
|
||||
<div class="label">총 연장근로</div>
|
||||
</div>
|
||||
<div class="overtime-stat">
|
||||
<div class="value">${overtimeDays.length}일</div>
|
||||
<div class="label">연장근로 일수</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (overtimeDays.length > 0) {
|
||||
html += '<div class="overtime-detail">';
|
||||
overtimeDays.sort((a, b) => a.date.localeCompare(b.date)).forEach(d => {
|
||||
const dateObj = new Date(d.date);
|
||||
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
const dayName = dayNames[dateObj.getDay()];
|
||||
const displayDate = `${dateObj.getMonth() + 1}/${dateObj.getDate()} (${dayName})`;
|
||||
|
||||
html += `
|
||||
<div class="overtime-day">
|
||||
<span class="date">${displayDate}</span>
|
||||
<span class="hours">+${d.hours.toFixed(1)}h</span>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
html += '</div>';
|
||||
} else {
|
||||
html += '<div class="no-data" style="padding:1rem;">연장근로 기록이 없습니다</div>';
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
} catch (e) {
|
||||
console.error('연장근로 데이터 로드 실패:', e);
|
||||
container.innerHTML = '<div class="error">데이터를 불러오는데 실패했습니다</div>';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script>initAuth();</script>
|
||||
<script src="/static/js/shared-bottom-nav.js?v=2026040103"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,363 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>휴가 발생 입력 - TK 공장관리</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
|
||||
<link rel="stylesheet" href="/css/vacation-allocation.css">
|
||||
</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">
|
||||
<h1 class="page-title">휴가 발생 입력</h1>
|
||||
<p class="page-description">작업자별 휴가를 입력하고 특별 휴가를 관리합니다</p>
|
||||
</div>
|
||||
|
||||
<!-- 탭 네비게이션 -->
|
||||
<div class="tab-navigation">
|
||||
<button class="tab-button active" data-tab="individual">개별 입력</button>
|
||||
<button class="tab-button" data-tab="bulk">일괄 입력</button>
|
||||
<button class="tab-button" data-tab="special">특별 휴가 관리</button>
|
||||
</div>
|
||||
|
||||
<!-- 탭 1: 개별 입력 -->
|
||||
<section id="tab-individual" class="tab-content active">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">개별 작업자 휴가 입력</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
<!-- 입력 폼 -->
|
||||
<div class="form-section">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="individualWorker">작업자 선택 <span class="required">*</span></label>
|
||||
<select id="individualWorker" class="form-select" required>
|
||||
<option value="">선택하세요</option>
|
||||
<!-- JavaScript로 동적 생성 -->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="individualYear">연도 <span class="required">*</span></label>
|
||||
<select id="individualYear" class="form-select" required>
|
||||
<!-- JavaScript로 동적 생성 -->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="individualVacationType">휴가 유형 <span class="required">*</span></label>
|
||||
<select id="individualVacationType" class="form-select" required>
|
||||
<option value="">선택하세요</option>
|
||||
<!-- JavaScript로 동적 생성 -->
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 자동 계산 섹션 -->
|
||||
<div class="auto-calculate-section">
|
||||
<div class="section-header">
|
||||
<h3>자동 계산 (연차만 해당)</h3>
|
||||
<button id="autoCalculateBtn" class="btn btn-secondary btn-sm">
|
||||
입사일 기준 자동 계산
|
||||
</button>
|
||||
</div>
|
||||
<div id="autoCalculateResult" class="alert alert-info" style="display: none;">
|
||||
<!-- 계산 결과 표시 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 수동 입력 -->
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="individualTotalDays">총 부여 일수 <span class="required">*</span></label>
|
||||
<input type="number" id="individualTotalDays" class="form-input" min="0" step="0.5" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="individualUsedDays">사용 일수</label>
|
||||
<input type="number" id="individualUsedDays" class="form-input" min="0" step="0.5" value="0">
|
||||
</div>
|
||||
|
||||
<div class="form-group full-width">
|
||||
<label for="individualNotes">비고</label>
|
||||
<input type="text" id="individualNotes" class="form-input" placeholder="예: 2026년 연차">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button id="individualSubmitBtn" class="btn btn-primary">
|
||||
저장
|
||||
</button>
|
||||
<button id="individualResetBtn" class="btn btn-secondary">
|
||||
초기화
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 기존 데이터 테이블 -->
|
||||
<div class="existing-data-section">
|
||||
<h3>기존 입력 내역</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>작업자</th>
|
||||
<th>연도</th>
|
||||
<th>휴가 유형</th>
|
||||
<th>총 일수</th>
|
||||
<th>사용 일수</th>
|
||||
<th>잔여 일수</th>
|
||||
<th>비고</th>
|
||||
<th>작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="individualTableBody">
|
||||
<tr>
|
||||
<td colspan="8" class="loading-state">
|
||||
<p>작업자를 선택하세요</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 탭 2: 일괄 입력 -->
|
||||
<section id="tab-bulk" class="tab-content">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">근속년수별 연차 일괄 생성</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
<div class="alert alert-warning">
|
||||
<strong>주의:</strong> 이 기능은 연차(ANNUAL) 휴가 유형만 생성합니다. 각 작업자의 입사일을 기준으로 근속년수를 계산하여 자동으로 연차를 부여합니다.
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="bulkYear">대상 연도 <span class="required">*</span></label>
|
||||
<select id="bulkYear" class="form-select" required>
|
||||
<!-- JavaScript로 동적 생성 -->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="bulkEmploymentStatus">재직 상태</label>
|
||||
<select id="bulkEmploymentStatus" class="form-select">
|
||||
<option value="employed">재직 중만</option>
|
||||
<option value="all">전체</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button id="bulkPreviewBtn" class="btn btn-secondary">
|
||||
미리보기
|
||||
</button>
|
||||
<button id="bulkSubmitBtn" class="btn btn-primary" disabled>
|
||||
일괄 생성
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 미리보기 테이블 -->
|
||||
<div id="bulkPreviewSection" class="preview-section" style="display: none;">
|
||||
<h3>생성 예정 내역</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>작업자</th>
|
||||
<th>입사일</th>
|
||||
<th>근속년수</th>
|
||||
<th>부여 연차</th>
|
||||
<th>계산 근거</th>
|
||||
<th>상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="bulkPreviewTableBody">
|
||||
<!-- JavaScript로 동적 생성 -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 탭 3: 특별 휴가 관리 -->
|
||||
<section id="tab-special" class="tab-content">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">특별 휴가 유형 관리</h2>
|
||||
<button id="addSpecialTypeBtn" class="btn btn-primary btn-sm">
|
||||
+ 새 휴가 유형 추가
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>유형명</th>
|
||||
<th>코드</th>
|
||||
<th>우선순위</th>
|
||||
<th>특별 휴가</th>
|
||||
<th>시스템 유형</th>
|
||||
<th>설명</th>
|
||||
<th>작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="specialTypesTableBody">
|
||||
<tr>
|
||||
<td colspan="7" class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>데이터를 불러오는 중...</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 알림 토스트 -->
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
|
||||
<!-- 모달: 휴가 유형 추가/수정 -->
|
||||
<div id="vacationTypeModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="modalTitle">휴가 유형 추가</h3>
|
||||
<button class="modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="vacationTypeForm">
|
||||
<input type="hidden" id="modalTypeId">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="modalTypeName">유형명 <span class="required">*</span></label>
|
||||
<input type="text" id="modalTypeName" class="form-input" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="modalTypeCode">코드 <span class="required">*</span></label>
|
||||
<input type="text" id="modalTypeCode" class="form-input" required>
|
||||
<small>예: ANNUAL, SICK, MATERNITY (영문 대문자)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="modalPriority">우선순위 <span class="required">*</span></label>
|
||||
<input type="number" id="modalPriority" class="form-input" min="1" required>
|
||||
<small>낮을수록 먼저 차감</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" id="modalIsSpecial">
|
||||
특별 휴가
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="modalDescription">설명</label>
|
||||
<textarea id="modalDescription" class="form-input" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">저장</button>
|
||||
<button type="button" class="btn btn-secondary modal-close">취소</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 모달: 휴가 수정 -->
|
||||
<div id="editBalanceModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>휴가 수정</h3>
|
||||
<button class="modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="editBalanceForm">
|
||||
<input type="hidden" id="editBalanceId">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="editTotalDays">총 일수 <span class="required">*</span></label>
|
||||
<input type="number" id="editTotalDays" class="form-input" min="0" step="0.5" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="editUsedDays">사용 일수 <span class="required">*</span></label>
|
||||
<input type="number" id="editUsedDays" class="form-input" min="0" step="0.5" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="editNotes">비고</label>
|
||||
<input type="text" id="editNotes" class="form-input">
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">저장</button>
|
||||
<button type="button" class="btn btn-secondary modal-close">취소</button>
|
||||
</div>
|
||||
</form>
|
||||
</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 type="module" src="/js/vacation-allocation.js" defer></script>
|
||||
<script>initAuth();</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,287 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>휴가 승인 관리 - TK 공장관리</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
|
||||
<style>
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
}
|
||||
.tab {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.tab:hover {
|
||||
color: #111827;
|
||||
}
|
||||
.tab.active {
|
||||
color: #3b82f6;
|
||||
border-bottom-color: #3b82f6;
|
||||
}
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 모바일 최적화 */
|
||||
@media (max-width: 768px) {
|
||||
.tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; scrollbar-width: none; }
|
||||
.tabs::-webkit-scrollbar { display: none; }
|
||||
.tab { flex-shrink: 0; padding: 0.625rem 1rem; white-space: nowrap; font-size: 0.875rem; }
|
||||
}
|
||||
</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 class="page-title-section">
|
||||
<h1 class="page-title">휴가 승인 관리</h1>
|
||||
<p class="page-description">휴가 신청을 승인하거나 거부합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 탭 메뉴 -->
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="switchTab('pending')">승인 대기 목록</button>
|
||||
<button class="tab" onclick="switchTab('all')">전체 신청 내역</button>
|
||||
</div>
|
||||
|
||||
<!-- 승인 대기 목록 탭 -->
|
||||
<div id="pendingTab" class="tab-content active">
|
||||
<div class="content-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">승인 대기 목록</h2>
|
||||
<p class="text-muted">대기 중인 휴가 신청을 승인하거나 거부할 수 있습니다</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="pendingRequestsList" class="data-table-container">
|
||||
<!-- 승인 대기 목록이 여기에 동적으로 렌더링됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 전체 신청 내역 탭 -->
|
||||
<div id="allTab" class="tab-content">
|
||||
<div class="content-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">전체 신청 내역</h2>
|
||||
<div class="page-actions">
|
||||
<input type="date" id="filterStartDate" class="form-control" style="width: auto;">
|
||||
<span style="margin: 0 0.5rem;">~</span>
|
||||
<input type="date" id="filterEndDate" class="form-control" style="width: auto;">
|
||||
<button class="btn btn-secondary" onclick="filterAllRequests()">
|
||||
조회
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="resetFilter()">
|
||||
전체
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="allRequestsList" class="data-table-container">
|
||||
<!-- 전체 신청 내역이 여기에 동적으로 렌더링됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</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 src="/js/vacation-common.js"></script>
|
||||
<script>
|
||||
// axios 기본 설정
|
||||
(function() {
|
||||
const checkApiConfig = setInterval(() => {
|
||||
if (window.API_BASE_URL) {
|
||||
clearInterval(checkApiConfig);
|
||||
axios.defaults.baseURL = window.API_BASE_URL;
|
||||
const token = localStorage.getItem('sso_token');
|
||||
if (token) {
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
axios.interceptors.request.use(
|
||||
config => {
|
||||
const token = localStorage.getItem('sso_token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
error => Promise.reject(error)
|
||||
);
|
||||
|
||||
axios.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
if (error.response?.status === 401) {
|
||||
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
|
||||
window.location.href = '/pages/login.html';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
}, 50);
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
let allRequestsData = [];
|
||||
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await waitForAxiosConfig();
|
||||
initializePage();
|
||||
});
|
||||
|
||||
async function initializePage() {
|
||||
try {
|
||||
const currentUser = getCurrentUser();
|
||||
|
||||
// 관리자 권한 체크
|
||||
if (currentUser.access_level !== 'system' && currentUser.access_level !== 'admin') {
|
||||
alert('관리자만 접근할 수 있습니다.');
|
||||
window.location.href = '/pages/attendance/vacation-request.html';
|
||||
return;
|
||||
}
|
||||
|
||||
await loadPendingRequests();
|
||||
await loadAllRequests();
|
||||
|
||||
// 휴가 업데이트 이벤트 리스너
|
||||
window.addEventListener('vacation-updated', () => {
|
||||
loadPendingRequests();
|
||||
loadAllRequests();
|
||||
});
|
||||
|
||||
// 날짜 필터 초기화 (최근 3개월)
|
||||
const today = new Date();
|
||||
const threeMonthsAgo = new Date();
|
||||
threeMonthsAgo.setMonth(today.getMonth() - 3);
|
||||
|
||||
document.getElementById('filterStartDate').value = threeMonthsAgo.toISOString().split('T')[0];
|
||||
document.getElementById('filterEndDate').value = today.toISOString().split('T')[0];
|
||||
} catch (error) {
|
||||
console.error('초기화 오류:', error);
|
||||
alert('페이지 초기화 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
function switchTab(tabName) {
|
||||
// 모든 탭과 컨텐츠 비활성화
|
||||
document.querySelectorAll('.tab').forEach(tab => tab.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
|
||||
|
||||
// 선택한 탭 활성화
|
||||
if (tabName === 'pending') {
|
||||
document.querySelector('.tab:nth-child(1)').classList.add('active');
|
||||
document.getElementById('pendingTab').classList.add('active');
|
||||
} else if (tabName === 'all') {
|
||||
document.querySelector('.tab:nth-child(2)').classList.add('active');
|
||||
document.getElementById('allTab').classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPendingRequests() {
|
||||
try {
|
||||
const response = await axios.get('/vacation-requests/pending');
|
||||
if (response.data.success) {
|
||||
renderVacationRequests(response.data.data, 'pendingRequestsList', true, 'approval');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('승인 대기 목록 로드 오류:', error);
|
||||
document.getElementById('pendingRequestsList').innerHTML = `
|
||||
<div class="empty-state">
|
||||
<p>승인 대기 중인 신청이 없습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAllRequests() {
|
||||
try {
|
||||
const response = await axios.get('/vacation-requests');
|
||||
if (response.data.success) {
|
||||
allRequestsData = response.data.data;
|
||||
renderVacationRequests(allRequestsData, 'allRequestsList', false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('전체 신청 내역 로드 오류:', error);
|
||||
document.getElementById('allRequestsList').innerHTML = `
|
||||
<div class="empty-state">
|
||||
<p>신청 내역이 없습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function filterAllRequests() {
|
||||
const startDate = document.getElementById('filterStartDate').value;
|
||||
const endDate = document.getElementById('filterEndDate').value;
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
alert('시작일과 종료일을 모두 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = allRequestsData.filter(req => {
|
||||
return req.start_date >= startDate && req.start_date <= endDate;
|
||||
});
|
||||
|
||||
renderVacationRequests(filtered, 'allRequestsList', false);
|
||||
}
|
||||
|
||||
function resetFilter() {
|
||||
renderVacationRequests(allRequestsData, 'allRequestsList', false);
|
||||
}
|
||||
</script>
|
||||
<script>initAuth();</script>
|
||||
</body>
|
||||
</html>
|
||||
307
system1-factory/web/public/pages/attendance/vacation-input.html
Normal file
307
system1-factory/web/public/pages/attendance/vacation-input.html
Normal file
@@ -0,0 +1,307 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>휴가 직접 입력 - TK 공장관리</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
|
||||
</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 class="page-title-section">
|
||||
<h1 class="page-title">휴가 직접 입력</h1>
|
||||
<p class="page-description">관리자 권한으로 작업자의 휴가 정보를 직접 입력합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 휴가 직접 입력 폼 -->
|
||||
<div class="content-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">휴가 정보 입력</h2>
|
||||
<p class="text-muted">승인 절차 없이 휴가 정보를 직접 입력합니다. 입력 즉시 승인 상태로 저장됩니다.</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="vacationInputForm" onsubmit="submitVacationInput(event)">
|
||||
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem;">
|
||||
<div class="form-group">
|
||||
<label for="inputWorker">작업자 *</label>
|
||||
<select id="inputWorker" class="form-control" required onchange="updateVacationBalance()">
|
||||
<option value="">선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="inputVacationType">휴가 유형 *</label>
|
||||
<select id="inputVacationType" class="form-control" required>
|
||||
<option value="">선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="inputStartDate">시작일 *</label>
|
||||
<input type="date" id="inputStartDate" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="inputEndDate">종료일 *</label>
|
||||
<input type="date" id="inputEndDate" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="inputDaysUsed">사용 일수 *</label>
|
||||
<input type="number" id="inputDaysUsed" class="form-control" min="0.5" step="0.5" value="1.0" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>작업자 휴가 잔여</label>
|
||||
<div id="workerVacationBalance" style="padding: 0.75rem; background-color: #f9fafb; border-radius: 0.375rem; border: 1px solid #e5e7eb;">
|
||||
<span class="text-muted">작업자를 선택하세요</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="grid-column: 1 / -1;">
|
||||
<label for="inputReason">사유</label>
|
||||
<textarea id="inputReason" class="form-control" rows="3" placeholder="휴가 사유를 입력하세요 (선택)"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin-top: 2rem;">
|
||||
<button type="submit" class="btn btn-primary" style="padding: 1rem 3rem;">
|
||||
즉시 입력 (자동 승인)
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 최근 입력 내역 -->
|
||||
<div class="content-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">최근 입력 내역</h2>
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-secondary" onclick="loadRecentInputs()">
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="recentInputsList" class="data-table-container">
|
||||
<!-- 최근 입력 내역이 여기에 동적으로 렌더링됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</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 src="/js/vacation-common.js"></script>
|
||||
<script>
|
||||
// axios 기본 설정
|
||||
(function() {
|
||||
const checkApiConfig = setInterval(() => {
|
||||
if (window.API_BASE_URL) {
|
||||
clearInterval(checkApiConfig);
|
||||
axios.defaults.baseURL = window.API_BASE_URL;
|
||||
const token = localStorage.getItem('sso_token');
|
||||
if (token) {
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
axios.interceptors.request.use(
|
||||
config => {
|
||||
const token = localStorage.getItem('sso_token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
error => Promise.reject(error)
|
||||
);
|
||||
|
||||
axios.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
if (error.response?.status === 401) {
|
||||
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
|
||||
window.location.href = '/pages/login.html';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
}, 50);
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await waitForAxiosConfig();
|
||||
initializePage();
|
||||
});
|
||||
|
||||
async function initializePage() {
|
||||
try {
|
||||
const currentUser = getCurrentUser();
|
||||
|
||||
// 관리자 권한 체크
|
||||
if (currentUser.access_level !== 'system' && currentUser.access_level !== 'admin') {
|
||||
alert('관리자만 접근할 수 있습니다.');
|
||||
window.location.href = '/pages/attendance/vacation-request.html';
|
||||
return;
|
||||
}
|
||||
|
||||
await loadWorkers();
|
||||
await loadVacationTypes();
|
||||
await loadRecentInputs();
|
||||
|
||||
// 휴가 업데이트 이벤트 리스너
|
||||
window.addEventListener('vacation-updated', loadRecentInputs);
|
||||
} catch (error) {
|
||||
console.error('초기화 오류:', error);
|
||||
alert('페이지 초기화 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
async function updateVacationBalance() {
|
||||
const workerId = document.getElementById('inputWorker').value;
|
||||
const balanceDiv = document.getElementById('workerVacationBalance');
|
||||
|
||||
if (!workerId) {
|
||||
balanceDiv.innerHTML = '<span class="text-muted">작업자를 선택하세요</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(`/attendance/vacation-balance/${workerId}`);
|
||||
if (response.data.success) {
|
||||
const balance = response.data.data;
|
||||
|
||||
if (!balance || Object.keys(balance).length === 0) {
|
||||
balanceDiv.innerHTML = '<span class="text-muted">휴가 잔여 정보가 없습니다</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
const balanceHTML = Object.keys(balance).map(key => {
|
||||
const info = balance[key];
|
||||
return `
|
||||
<div style="display: inline-block; margin-right: 1rem;">
|
||||
<span style="color: #6b7280; font-size: 0.875rem;">${key}:</span>
|
||||
<strong style="color: #111827; font-size: 1rem; margin-left: 0.25rem;">${info.remaining || 0}일</strong>
|
||||
<span style="color: #9ca3af; font-size: 0.75rem; margin-left: 0.25rem;">(전체: ${info.total || 0}일)</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
balanceDiv.innerHTML = balanceHTML;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('휴가 잔여 조회 오류:', error);
|
||||
balanceDiv.innerHTML = '<span class="text-muted" style="color: #dc2626;">조회 실패</span>';
|
||||
}
|
||||
}
|
||||
|
||||
async function submitVacationInput(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const data = {
|
||||
user_id: parseInt(document.getElementById('inputWorker').value),
|
||||
vacation_type_id: parseInt(document.getElementById('inputVacationType').value),
|
||||
start_date: document.getElementById('inputStartDate').value,
|
||||
end_date: document.getElementById('inputEndDate').value,
|
||||
days_used: parseFloat(document.getElementById('inputDaysUsed').value),
|
||||
reason: document.getElementById('inputReason').value || null,
|
||||
auto_approve: true // 자동 승인 플래그
|
||||
};
|
||||
|
||||
if (!confirm(`${document.getElementById('inputWorker').selectedOptions[0].text}의 휴가를 즉시 입력하시겠습니까?\n\n입력 즉시 승인 상태로 저장됩니다.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 휴가 신청 생성
|
||||
const response = await axios.post('/vacation-requests', data);
|
||||
if (response.data.success) {
|
||||
const requestId = response.data.data.request_id;
|
||||
|
||||
// 즉시 승인 처리
|
||||
try {
|
||||
const approveResponse = await axios.patch(`/vacation-requests/${requestId}/approve`);
|
||||
if (approveResponse.data.success) {
|
||||
alert('휴가 정보가 입력되고 자동 승인되었습니다.');
|
||||
document.getElementById('vacationInputForm').reset();
|
||||
document.getElementById('workerVacationBalance').innerHTML = '<span class="text-muted">작업자를 선택하세요</span>';
|
||||
window.dispatchEvent(new Event('vacation-updated'));
|
||||
}
|
||||
} catch (approveError) {
|
||||
console.error('자동 승인 오류:', approveError);
|
||||
alert('휴가 정보는 입력되었으나 자동 승인에 실패했습니다. 승인 관리 페이지에서 수동으로 승인해주세요.');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('휴가 입력 오류:', error);
|
||||
alert(error.response?.data?.message || '휴가 입력 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRecentInputs() {
|
||||
try {
|
||||
const response = await axios.get('/vacation-requests');
|
||||
if (response.data.success) {
|
||||
// 최근 30일 이내 승인된 항목만 표시
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
const thirtyDaysAgoStr = thirtyDaysAgo.toISOString().split('T')[0];
|
||||
|
||||
const recentApproved = response.data.data.filter(req =>
|
||||
req.status === 'approved' &&
|
||||
req.created_at >= thirtyDaysAgoStr
|
||||
).slice(0, 20); // 최근 20개만
|
||||
|
||||
renderVacationRequests(recentApproved, 'recentInputsList', false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('최근 입력 내역 로드 오류:', error);
|
||||
document.getElementById('recentInputsList').innerHTML = `
|
||||
<div class="empty-state">
|
||||
<p>최근 입력 내역이 없습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script>initAuth();</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,484 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>휴가 관리 - TK 공장관리</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
|
||||
<style>
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
}
|
||||
.tab {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.tab:hover {
|
||||
color: #111827;
|
||||
}
|
||||
.tab.active {
|
||||
color: #3b82f6;
|
||||
border-bottom-color: #3b82f6;
|
||||
}
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 모바일 최적화 */
|
||||
@media (max-width: 768px) {
|
||||
.tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; scrollbar-width: none; }
|
||||
.tabs::-webkit-scrollbar { display: none; }
|
||||
.tab { flex-shrink: 0; padding: 0.625rem 1rem; white-space: nowrap; font-size: 0.875rem; }
|
||||
.page-actions { flex-direction: column; width: 100%; gap: 0.5rem; }
|
||||
.page-actions input[type="date"] { width: 100%; }
|
||||
.page-actions .btn { width: 100%; text-align: center; }
|
||||
}
|
||||
</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 class="page-title-section">
|
||||
<h1 class="page-title">휴가 관리</h1>
|
||||
<p class="page-description">휴가 신청을 승인하고 작업자 휴가 정보를 관리합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 탭 메뉴 -->
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="switchTab('approval')">승인 대기 목록</button>
|
||||
<button class="tab" onclick="switchTab('input')">직접 입력</button>
|
||||
<button class="tab" onclick="switchTab('all')">전체 신청 내역</button>
|
||||
</div>
|
||||
|
||||
<!-- 승인 대기 목록 탭 -->
|
||||
<div id="approvalTab" class="tab-content active">
|
||||
<div class="content-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">승인 대기 목록</h2>
|
||||
<p class="text-muted">대기 중인 휴가 신청을 승인하거나 거부할 수 있습니다</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="pendingRequestsList" class="data-table-container">
|
||||
<!-- 승인 대기 목록이 여기에 동적으로 렌더링됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 직접 입력 탭 -->
|
||||
<div id="inputTab" class="tab-content">
|
||||
<div class="content-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">휴가 정보 직접 입력</h2>
|
||||
<p class="text-muted">승인 절차 없이 휴가 정보를 직접 입력합니다. 입력 즉시 승인 상태로 저장됩니다.</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="vacationInputForm" onsubmit="submitVacationInput(event)">
|
||||
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem;">
|
||||
<div class="form-group">
|
||||
<label for="inputWorker">작업자 *</label>
|
||||
<select id="inputWorker" class="form-control" required onchange="updateVacationBalance()">
|
||||
<option value="">선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="inputVacationType">휴가 유형 *</label>
|
||||
<select id="inputVacationType" class="form-control" required>
|
||||
<option value="">선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="inputStartDate">시작일 *</label>
|
||||
<input type="date" id="inputStartDate" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="inputEndDate">종료일 *</label>
|
||||
<input type="date" id="inputEndDate" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="inputDaysUsed">사용 일수 *</label>
|
||||
<input type="number" id="inputDaysUsed" class="form-control" min="0.5" step="0.5" value="1.0" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>작업자 휴가 잔여</label>
|
||||
<div id="workerVacationBalance" style="padding: 0.75rem; background-color: #f9fafb; border-radius: 0.375rem; border: 1px solid #e5e7eb;">
|
||||
<span class="text-muted">작업자를 선택하세요</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="grid-column: 1 / -1;">
|
||||
<label for="inputReason">사유</label>
|
||||
<textarea id="inputReason" class="form-control" rows="3" placeholder="휴가 사유를 입력하세요 (선택)"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin-top: 2rem;">
|
||||
<button type="submit" class="btn btn-primary" style="padding: 1rem 3rem;">
|
||||
즉시 입력 (자동 승인)
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 최근 입력 내역 -->
|
||||
<div class="card" style="margin-top: 2rem;">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">최근 입력 내역</h2>
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-secondary" onclick="loadRecentInputs()">
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="recentInputsList" class="data-table-container">
|
||||
<!-- 최근 입력 내역이 여기에 동적으로 렌더링됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 전체 신청 내역 탭 -->
|
||||
<div id="allTab" class="tab-content">
|
||||
<div class="content-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">전체 신청 내역</h2>
|
||||
<div class="page-actions">
|
||||
<input type="date" id="filterStartDate" class="form-control" style="width: auto;">
|
||||
<span style="margin: 0 0.5rem;">~</span>
|
||||
<input type="date" id="filterEndDate" class="form-control" style="width: auto;">
|
||||
<button class="btn btn-secondary" onclick="filterAllRequests()">
|
||||
조회
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="resetFilter()">
|
||||
전체
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="allRequestsList" class="data-table-container">
|
||||
<!-- 전체 신청 내역이 여기에 동적으로 렌더링됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</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 src="/js/vacation-common.js"></script>
|
||||
<script>
|
||||
// axios 기본 설정
|
||||
(function() {
|
||||
const checkApiConfig = setInterval(() => {
|
||||
if (window.API_BASE_URL) {
|
||||
clearInterval(checkApiConfig);
|
||||
axios.defaults.baseURL = window.API_BASE_URL;
|
||||
const token = localStorage.getItem('sso_token');
|
||||
if (token) {
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
axios.interceptors.request.use(
|
||||
config => {
|
||||
const token = localStorage.getItem('sso_token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
error => Promise.reject(error)
|
||||
);
|
||||
|
||||
axios.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
if (error.response?.status === 401) {
|
||||
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
|
||||
window.location.href = '/pages/login.html';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
}, 50);
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
let allRequestsData = [];
|
||||
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await waitForAxiosConfig();
|
||||
initializePage();
|
||||
});
|
||||
|
||||
async function initializePage() {
|
||||
try {
|
||||
const currentUser = getCurrentUser();
|
||||
|
||||
// 관리자 권한 체크
|
||||
if (currentUser.access_level !== 'system' && currentUser.access_level !== 'admin') {
|
||||
alert('관리자만 접근할 수 있습니다.');
|
||||
window.location.href = '/pages/attendance/vacation-request.html';
|
||||
return;
|
||||
}
|
||||
|
||||
await loadWorkers();
|
||||
await loadVacationTypes();
|
||||
await loadPendingRequests();
|
||||
await loadAllRequests();
|
||||
|
||||
// 휴가 업데이트 이벤트 리스너
|
||||
window.addEventListener('vacation-updated', () => {
|
||||
loadPendingRequests();
|
||||
loadAllRequests();
|
||||
});
|
||||
|
||||
// 날짜 필터 초기화 (최근 3개월)
|
||||
const today = new Date();
|
||||
const threeMonthsAgo = new Date();
|
||||
threeMonthsAgo.setMonth(today.getMonth() - 3);
|
||||
|
||||
document.getElementById('filterStartDate').value = threeMonthsAgo.toISOString().split('T')[0];
|
||||
document.getElementById('filterEndDate').value = today.toISOString().split('T')[0];
|
||||
} catch (error) {
|
||||
console.error('초기화 오류:', error);
|
||||
alert('페이지 초기화 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
function switchTab(tabName) {
|
||||
// 모든 탭과 컨텐츠 비활성화
|
||||
document.querySelectorAll('.tab').forEach(tab => tab.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
|
||||
|
||||
// 선택한 탭 활성화
|
||||
if (tabName === 'approval') {
|
||||
document.querySelector('.tab:nth-child(1)').classList.add('active');
|
||||
document.getElementById('approvalTab').classList.add('active');
|
||||
} else if (tabName === 'input') {
|
||||
document.querySelector('.tab:nth-child(2)').classList.add('active');
|
||||
document.getElementById('inputTab').classList.add('active');
|
||||
} else if (tabName === 'all') {
|
||||
document.querySelector('.tab:nth-child(3)').classList.add('active');
|
||||
document.getElementById('allTab').classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPendingRequests() {
|
||||
try {
|
||||
const response = await axios.get('/vacation-requests/pending');
|
||||
if (response.data.success) {
|
||||
renderVacationRequests(response.data.data, 'pendingRequestsList', true, 'approval');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('승인 대기 목록 로드 오류:', error);
|
||||
document.getElementById('pendingRequestsList').innerHTML = `
|
||||
<div class="empty-state">
|
||||
<p>승인 대기 중인 신청이 없습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAllRequests() {
|
||||
try {
|
||||
const response = await axios.get('/vacation-requests');
|
||||
if (response.data.success) {
|
||||
allRequestsData = response.data.data;
|
||||
renderVacationRequests(allRequestsData, 'allRequestsList', false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('전체 신청 내역 로드 오류:', error);
|
||||
document.getElementById('allRequestsList').innerHTML = `
|
||||
<div class="empty-state">
|
||||
<p>신청 내역이 없습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateVacationBalance() {
|
||||
const workerId = document.getElementById('inputWorker').value;
|
||||
const balanceDiv = document.getElementById('workerVacationBalance');
|
||||
|
||||
if (!workerId) {
|
||||
balanceDiv.innerHTML = '<span class="text-muted">작업자를 선택하세요</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(`/attendance/vacation-balance/${workerId}`);
|
||||
if (response.data.success) {
|
||||
const balance = response.data.data;
|
||||
|
||||
if (!balance || Object.keys(balance).length === 0) {
|
||||
balanceDiv.innerHTML = '<span class="text-muted">휴가 잔여 정보가 없습니다</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
const balanceHTML = Object.keys(balance).map(key => {
|
||||
const info = balance[key];
|
||||
return `
|
||||
<div style="display: inline-block; margin-right: 1rem;">
|
||||
<span style="color: #6b7280; font-size: 0.875rem;">${key}:</span>
|
||||
<strong style="color: #111827; font-size: 1rem; margin-left: 0.25rem;">${info.remaining || 0}일</strong>
|
||||
<span style="color: #9ca3af; font-size: 0.75rem; margin-left: 0.25rem;">(전체: ${info.total || 0}일)</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
balanceDiv.innerHTML = balanceHTML;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('휴가 잔여 조회 오류:', error);
|
||||
balanceDiv.innerHTML = '<span class="text-muted" style="color: #dc2626;">조회 실패</span>';
|
||||
}
|
||||
}
|
||||
|
||||
async function submitVacationInput(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const data = {
|
||||
user_id: parseInt(document.getElementById('inputWorker').value),
|
||||
vacation_type_id: parseInt(document.getElementById('inputVacationType').value),
|
||||
start_date: document.getElementById('inputStartDate').value,
|
||||
end_date: document.getElementById('inputEndDate').value,
|
||||
days_used: parseFloat(document.getElementById('inputDaysUsed').value),
|
||||
reason: document.getElementById('inputReason').value || null
|
||||
};
|
||||
|
||||
if (!confirm(`${document.getElementById('inputWorker').selectedOptions[0].text}의 휴가를 즉시 입력하시겠습니까?\n\n입력 즉시 승인 상태로 저장됩니다.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 휴가 신청 생성
|
||||
const response = await axios.post('/vacation-requests', data);
|
||||
if (response.data.success) {
|
||||
const requestId = response.data.data.request_id;
|
||||
|
||||
// 즉시 승인 처리
|
||||
try {
|
||||
const approveResponse = await axios.patch(`/vacation-requests/${requestId}/approve`);
|
||||
if (approveResponse.data.success) {
|
||||
alert('휴가 정보가 입력되고 자동 승인되었습니다.');
|
||||
document.getElementById('vacationInputForm').reset();
|
||||
document.getElementById('workerVacationBalance').innerHTML = '<span class="text-muted">작업자를 선택하세요</span>';
|
||||
window.dispatchEvent(new Event('vacation-updated'));
|
||||
loadRecentInputs();
|
||||
}
|
||||
} catch (approveError) {
|
||||
console.error('자동 승인 오류:', approveError);
|
||||
alert('휴가 정보는 입력되었으나 자동 승인에 실패했습니다. 승인 관리 탭에서 수동으로 승인해주세요.');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('휴가 입력 오류:', error);
|
||||
alert(error.response?.data?.message || '휴가 입력 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRecentInputs() {
|
||||
try {
|
||||
const response = await axios.get('/vacation-requests');
|
||||
if (response.data.success) {
|
||||
// 최근 30일 이내 승인된 항목만 표시
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
const thirtyDaysAgoStr = thirtyDaysAgo.toISOString().split('T')[0];
|
||||
|
||||
const recentApproved = response.data.data.filter(req =>
|
||||
req.status === 'approved' &&
|
||||
req.created_at >= thirtyDaysAgoStr
|
||||
).slice(0, 20); // 최근 20개만
|
||||
|
||||
renderVacationRequests(recentApproved, 'recentInputsList', false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('최근 입력 내역 로드 오류:', error);
|
||||
document.getElementById('recentInputsList').innerHTML = `
|
||||
<div class="empty-state">
|
||||
<p>최근 입력 내역이 없습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function filterAllRequests() {
|
||||
const startDate = document.getElementById('filterStartDate').value;
|
||||
const endDate = document.getElementById('filterEndDate').value;
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
alert('시작일과 종료일을 모두 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = allRequestsData.filter(req => {
|
||||
return req.start_date >= startDate && req.start_date <= endDate;
|
||||
});
|
||||
|
||||
renderVacationRequests(filtered, 'allRequestsList', false);
|
||||
}
|
||||
|
||||
function resetFilter() {
|
||||
renderVacationRequests(allRequestsData, 'allRequestsList', false);
|
||||
}
|
||||
</script>
|
||||
<script>initAuth();</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,285 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>휴가 신청 - TK 공장관리</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
|
||||
</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 class="page-title-section">
|
||||
<h1 class="page-title">휴가 신청</h1>
|
||||
<p class="page-description">휴가를 신청하고 신청 내역을 확인합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 휴가 잔여 현황 -->
|
||||
<div class="content-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">휴가 잔여 현황</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="vacationBalance" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem;">
|
||||
<!-- 휴가 잔여 정보가 여기에 표시됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 휴가 신청 -->
|
||||
<div class="content-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">휴가 신청</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="vacationRequestForm" onsubmit="submitVacationRequest(event)">
|
||||
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem;">
|
||||
<div class="form-group">
|
||||
<label for="vacationType">휴가 유형 *</label>
|
||||
<select id="vacationType" class="form-control" required>
|
||||
<option value="">선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="daysUsed">사용 일수 *</label>
|
||||
<input type="number" id="daysUsed" class="form-control" min="0.5" step="0.5" value="1.0" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="startDate">시작일 *</label>
|
||||
<input type="date" id="startDate" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="endDate">종료일 *</label>
|
||||
<input type="date" id="endDate" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="grid-column: 1 / -1;">
|
||||
<label for="reason">사유</label>
|
||||
<textarea id="reason" class="form-control" rows="3" placeholder="휴가 사유를 입력하세요 (선택)"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin-top: 2rem;">
|
||||
<button type="submit" class="btn btn-primary" style="padding: 1rem 3rem;">
|
||||
신청하기
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 내 신청 내역 -->
|
||||
<div class="content-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">내 신청 내역</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="myRequestsList" class="data-table-container">
|
||||
<!-- 내 신청 내역이 여기에 동적으로 렌더링됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</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 src="/js/vacation-common.js"></script>
|
||||
<script>
|
||||
// axios 기본 설정
|
||||
(function() {
|
||||
const checkApiConfig = setInterval(() => {
|
||||
if (window.API_BASE_URL) {
|
||||
clearInterval(checkApiConfig);
|
||||
axios.defaults.baseURL = window.API_BASE_URL;
|
||||
const token = localStorage.getItem('sso_token');
|
||||
if (token) {
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
axios.interceptors.request.use(
|
||||
config => {
|
||||
const token = localStorage.getItem('sso_token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
error => Promise.reject(error)
|
||||
);
|
||||
|
||||
axios.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
if (error.response?.status === 401) {
|
||||
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
|
||||
window.location.href = '/pages/login.html';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
}, 50);
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await waitForAxiosConfig();
|
||||
initializePage();
|
||||
});
|
||||
|
||||
async function initializePage() {
|
||||
try {
|
||||
const currentUser = getCurrentUser();
|
||||
|
||||
if (!currentUser || !currentUser.user_id) {
|
||||
alert('작업자 정보가 없습니다. 관리자에게 문의하세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
await loadVacationTypes();
|
||||
await loadVacationBalance();
|
||||
await loadMyRequests();
|
||||
|
||||
// 휴가 업데이트 이벤트 리스너
|
||||
window.addEventListener('vacation-updated', () => {
|
||||
loadVacationBalance();
|
||||
loadMyRequests();
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('초기화 오류:', error);
|
||||
alert('페이지 초기화 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVacationBalance() {
|
||||
const currentUser = getCurrentUser();
|
||||
|
||||
try {
|
||||
const response = await axios.get(`/attendance/vacation-balance/${currentUser.user_id}`);
|
||||
if (response.data.success) {
|
||||
renderVacationBalance(response.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('휴가 잔여 조회 오류:', error);
|
||||
document.getElementById('vacationBalance').innerHTML = `
|
||||
<p class="text-muted">휴가 잔여 정보를 불러올 수 없습니다.</p>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderVacationBalance(balance) {
|
||||
const container = document.getElementById('vacationBalance');
|
||||
|
||||
if (!balance || Object.keys(balance).length === 0) {
|
||||
container.innerHTML = '<p class="text-muted">휴가 잔여 정보가 없습니다.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const balanceHTML = Object.keys(balance).map(key => {
|
||||
const info = balance[key];
|
||||
return `
|
||||
<div style="padding: 1.5rem; background-color: #f9fafb; border-radius: 0.5rem; border: 1px solid #e5e7eb;">
|
||||
<div style="font-size: 0.875rem; color: #6b7280; margin-bottom: 0.5rem;">${key}</div>
|
||||
<div style="font-size: 1.5rem; font-weight: 700; color: #111827;">
|
||||
${info.remaining || 0}일
|
||||
</div>
|
||||
<div style="font-size: 0.75rem; color: #9ca3af; margin-top: 0.25rem;">
|
||||
사용: ${info.used || 0}일 / 전체: ${info.total || 0}일
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = balanceHTML;
|
||||
}
|
||||
|
||||
async function submitVacationRequest(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const currentUser = getCurrentUser();
|
||||
const data = {
|
||||
user_id: currentUser.user_id,
|
||||
vacation_type_id: parseInt(document.getElementById('vacationType').value),
|
||||
start_date: document.getElementById('startDate').value,
|
||||
end_date: document.getElementById('endDate').value,
|
||||
days_used: parseFloat(document.getElementById('daysUsed').value),
|
||||
reason: document.getElementById('reason').value || null
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await axios.post('/vacation-requests', data);
|
||||
if (response.data.success) {
|
||||
alert('휴가 신청이 완료되었습니다.');
|
||||
document.getElementById('vacationRequestForm').reset();
|
||||
window.dispatchEvent(new Event('vacation-updated'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('휴가 신청 오류:', error);
|
||||
alert(error.response?.data?.message || '휴가 신청 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMyRequests() {
|
||||
const currentUser = getCurrentUser();
|
||||
|
||||
try {
|
||||
const response = await axios.get('/vacation-requests');
|
||||
if (response.data.success) {
|
||||
// 내 신청만 필터링
|
||||
const myRequests = response.data.data.filter(req =>
|
||||
req.requested_by === currentUser.user_id || req.user_id === currentUser.user_id
|
||||
);
|
||||
renderVacationRequests(myRequests, 'myRequestsList', true, 'delete');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('내 신청 내역 로드 오류:', error);
|
||||
document.getElementById('myRequestsList').innerHTML = `
|
||||
<div class="empty-state">
|
||||
<p>신청 내역이 없습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script>initAuth();</script>
|
||||
</body>
|
||||
</html>
|
||||
839
system1-factory/web/public/pages/attendance/work-status.html
Normal file
839
system1-factory/web/public/pages/attendance/work-status.html
Normal file
@@ -0,0 +1,839 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>근무 현황 - TK 공장관리</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
|
||||
<style>
|
||||
.page-wrapper {
|
||||
padding: 1.5rem;
|
||||
max-width: 1400px;
|
||||
}
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.page-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.controls input[type="date"] {
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.btn {
|
||||
padding: 0.4rem 0.75rem;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.btn-primary { background: #3b82f6; color: white; }
|
||||
.btn-outline { background: white; border: 1px solid #d1d5db; }
|
||||
.btn-success { background: #10b981; color: white; }
|
||||
|
||||
/* 요약 */
|
||||
.summary-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 0;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: #6b7280;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
.summary-row span { display: flex; align-items: center; gap: 0.25rem; }
|
||||
.summary-row .dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
}
|
||||
.dot-normal { background: #10b981; }
|
||||
.dot-annual { background: #3b82f6; }
|
||||
.dot-half { background: #22c55e; }
|
||||
.dot-quarter { background: #eab308; }
|
||||
.dot-early { background: #ef4444; }
|
||||
.dot-overtime { background: #f97316; }
|
||||
|
||||
/* 테이블 */
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
.data-table th, .data-table td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
.data-table th {
|
||||
background: #f9fafb;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.data-table tr:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
.data-table tr.saved {
|
||||
background: #f0fdf4;
|
||||
}
|
||||
.data-table tr.leave {
|
||||
background: #fefce8; /* 연한 노랑 - 연차/반차/반반차 */
|
||||
}
|
||||
.data-table tr.absent {
|
||||
background: #fef2f2; /* 연한 빨강 - 결근 (연차정보 없음) */
|
||||
}
|
||||
.data-table tr.absent-no-leave {
|
||||
background: #fee2e2; /* 더 진한 빨강 - 출근체크 안하고 연차정보도 없음 */
|
||||
}
|
||||
.leave-tag {
|
||||
font-size: 0.65rem;
|
||||
color: #a16207;
|
||||
background: #fef3c7;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
.status-leave { color: #a16207; }
|
||||
.status-absent-warning { color: #dc2626; font-weight: 600; }
|
||||
.worker-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
.saved-tag {
|
||||
font-size: 0.65rem;
|
||||
color: #10b981;
|
||||
background: #dcfce7;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
.type-select {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
min-width: 100px;
|
||||
}
|
||||
.overtime-input {
|
||||
width: 50px;
|
||||
padding: 0.25rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.25rem;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.hours-cell {
|
||||
text-align: center;
|
||||
min-width: 60px;
|
||||
}
|
||||
.status-present { color: #10b981; }
|
||||
.status-absent { color: #ef4444; }
|
||||
.status-not-hired { color: #9ca3af; font-style: italic; }
|
||||
.data-table tr.not-hired {
|
||||
background: #f3f4f6;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.data-table tr.not-hired .type-select,
|
||||
.data-table tr.not-hired .overtime-input {
|
||||
display: none;
|
||||
}
|
||||
.not-hired-tag {
|
||||
font-size: 0.65rem;
|
||||
color: #6b7280;
|
||||
background: #e5e7eb;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* 저장 영역 */
|
||||
.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.25rem;
|
||||
}
|
||||
.save-status {
|
||||
font-size: 0.8rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
.save-status.saved { color: #10b981; }
|
||||
.save-status.unsaved { color: #f59e0b; }
|
||||
.btn-save {
|
||||
padding: 0.5rem 1.5rem;
|
||||
font-size: 0.875rem;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-save:hover { background: #2563eb; }
|
||||
.btn-save:disabled { background: #9ca3af; cursor: not-allowed; }
|
||||
|
||||
.warning-box {
|
||||
background: #fef3c7;
|
||||
border: 1px solid #fcd34d;
|
||||
color: #92400e;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.25rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.warning-box a { color: #92400e; font-weight: 500; }
|
||||
|
||||
/* 모바일 최적화 */
|
||||
@media (max-width: 768px) {
|
||||
.summary-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.375rem; font-size: 0.7rem; }
|
||||
.summary-row span { flex-direction: column; text-align: center; gap: 0.125rem; }
|
||||
.controls { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; }
|
||||
.controls input[type="date"] { grid-column: 1 / -1; }
|
||||
.save-bar { position: sticky; bottom: 0; z-index: 20; background: white; box-shadow: 0 -2px 8px rgba(0,0,0,0.08); margin: 0 -1rem; padding: 0.75rem 1rem; }
|
||||
.btn-save { width: 100%; padding: 0.75rem; font-size: 1rem; }
|
||||
}
|
||||
</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">
|
||||
<h1 class="page-title">근무 현황</h1>
|
||||
<div class="controls">
|
||||
<input type="date" id="selectedDate">
|
||||
<button class="btn btn-primary" onclick="loadWorkStatus()">조회</button>
|
||||
<button class="btn btn-outline" onclick="setAllNormal()">전체 정시근무</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="noCheckinWarning" class="warning-box" style="display:none;">
|
||||
출근 체크가 완료되지 않았습니다. 먼저 <a href="/pages/attendance/checkin.html">출근 체크</a>를 진행해주세요.
|
||||
</div>
|
||||
|
||||
<div class="summary-row">
|
||||
<span><span class="dot dot-normal"></span> 정시 <strong id="normalCount">0</strong></span>
|
||||
<span><span class="dot dot-annual"></span> 연차 <strong id="annualCount">0</strong></span>
|
||||
<span><span class="dot dot-half"></span> 반차 <strong id="halfCount">0</strong></span>
|
||||
<span><span class="dot dot-quarter"></span> 반반차 <strong id="quarterCount">0</strong></span>
|
||||
<span><span class="dot dot-early"></span> 조퇴 <strong id="earlyCount">0</strong></span>
|
||||
<span><span class="dot dot-overtime"></span> 연장 <strong id="overtimeCount">0</strong></span>
|
||||
<span><span class="dot" style="background:#dc2626;"></span> 결근 <strong id="absentCount">0</strong></span>
|
||||
</div>
|
||||
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:30px">#</th>
|
||||
<th>이름</th>
|
||||
<th>출근</th>
|
||||
<th>근태구분</th>
|
||||
<th class="hours-cell">기본</th>
|
||||
<th class="hours-cell">연장</th>
|
||||
<th class="hours-cell">합계</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="workerTableBody">
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="save-bar">
|
||||
<span id="saveStatus" class="save-status"></span>
|
||||
<button id="saveBtn" class="btn-save" onclick="saveWorkStatus()">저장</button>
|
||||
</div>
|
||||
|
||||
</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>
|
||||
(function() {
|
||||
const checkApiConfig = setInterval(() => {
|
||||
if (window.API_BASE_URL) {
|
||||
clearInterval(checkApiConfig);
|
||||
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 workStatus = {};
|
||||
let hasCheckinData = false;
|
||||
let isAlreadySaved = false;
|
||||
let isSaving = false;
|
||||
let earlyLeaveTypeId = null;
|
||||
|
||||
const attendanceTypes = [
|
||||
{ value: 'normal', label: '정시근무', hours: 8, isLeave: false },
|
||||
{ value: 'annual', label: '연차', hours: 0, isLeave: true },
|
||||
{ value: 'half', label: '반차', hours: 4, isLeave: true },
|
||||
{ value: 'quarter', label: '반반차', hours: 6, isLeave: true },
|
||||
{ value: 'early', label: '조퇴', hours: 2, isLeave: true },
|
||||
{ value: 'overtime', label: '연장근로', hours: 8, isLeave: false }
|
||||
];
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await waitForAxiosConfig();
|
||||
document.getElementById('selectedDate').value = new Date().toISOString().split('T')[0];
|
||||
loadWorkStatus();
|
||||
});
|
||||
|
||||
function waitForAxiosConfig() {
|
||||
return new Promise((resolve) => {
|
||||
const check = setInterval(() => {
|
||||
if (axios.defaults.baseURL) {
|
||||
clearInterval(check);
|
||||
resolve();
|
||||
}
|
||||
}, 50);
|
||||
setTimeout(() => { clearInterval(check); resolve(); }, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
function formatDisplayDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
const [year, month, day] = dateStr.split('-');
|
||||
return `${year}.${month}.${day}`;
|
||||
}
|
||||
|
||||
async function loadWorkStatus() {
|
||||
const selectedDate = document.getElementById('selectedDate').value;
|
||||
if (!selectedDate) return alert('날짜를 선택해주세요.');
|
||||
|
||||
try {
|
||||
// EARLY_LEAVE 유형 ID 조회 (최초 1회)
|
||||
if (!earlyLeaveTypeId) {
|
||||
try {
|
||||
const vtRes = await axios.get('/attendance/vacation-types');
|
||||
const earlyType = (vtRes.data.data || []).find(t => t.type_code === 'EARLY_LEAVE');
|
||||
earlyLeaveTypeId = earlyType?.id || null;
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
const [workersRes, recordsRes] = await Promise.all([
|
||||
axios.get('/workers?limit=100'),
|
||||
axios.get(`/attendance/daily-records?date=${selectedDate}`).catch(() => ({ data: { data: [] } }))
|
||||
]);
|
||||
|
||||
workers = (workersRes.data.data || []).filter(w => w.status === 'active' && (!w.employment_status || w.employment_status === 'employed'));
|
||||
const records = recordsRes.data.data || [];
|
||||
|
||||
hasCheckinData = records.length > 0;
|
||||
isAlreadySaved = records.some(r => r.attendance_type_id || r.total_work_hours > 0);
|
||||
document.getElementById('noCheckinWarning').style.display = hasCheckinData ? 'none' : 'block';
|
||||
|
||||
workStatus = {};
|
||||
workers.forEach(w => {
|
||||
const record = records.find(r => r.user_id === w.user_id);
|
||||
|
||||
// 입사일 이전인지 확인
|
||||
const joinDate = w.join_date ? w.join_date.split('T')[0] : null;
|
||||
const isBeforeJoin = joinDate && selectedDate < joinDate;
|
||||
|
||||
if (isBeforeJoin) {
|
||||
// 입사 전 날짜
|
||||
workStatus[w.user_id] = {
|
||||
isPresent: false,
|
||||
type: 'not_hired',
|
||||
hours: 0,
|
||||
overtimeHours: 0,
|
||||
isSaved: false,
|
||||
hasLeaveInfo: false,
|
||||
isNotHired: true,
|
||||
joinDate: joinDate
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (record) {
|
||||
let type = 'normal';
|
||||
let overtimeHours = 0;
|
||||
|
||||
// 1. 휴가 유형 확인 (vacation_type_id 또는 vacation_type_code)
|
||||
if (record.vacation_type_id || record.vacation_type_code) {
|
||||
const vacationCodeMap = {
|
||||
'ANNUAL_FULL': 'annual',
|
||||
'ANNUAL_HALF': 'half',
|
||||
'ANNUAL_QUARTER': 'quarter',
|
||||
1: 'annual',
|
||||
2: 'half',
|
||||
3: 'quarter'
|
||||
};
|
||||
type = vacationCodeMap[record.vacation_type_code] || vacationCodeMap[record.vacation_type_id] || 'annual';
|
||||
}
|
||||
// 2. 근태 유형 확인
|
||||
// work_attendance_types: 1=NORMAL, 2=LATE, 3=EARLY_LEAVE, 4=ABSENT, 5=VACATION
|
||||
else if (record.attendance_type_code || record.attendance_type_id) {
|
||||
const codeMap = {
|
||||
'NORMAL': 'normal',
|
||||
'REGULAR': 'normal',
|
||||
'VACATION': 'annual',
|
||||
'EARLY_LEAVE': 'early',
|
||||
'ABSENT': 'normal', // 결근은 화면에서 따로 처리
|
||||
1: 'normal', // NORMAL
|
||||
2: 'normal', // LATE (지각도 출근으로 처리)
|
||||
3: 'early', // EARLY_LEAVE
|
||||
4: 'normal', // ABSENT (결근은 화면에서 따로 처리)
|
||||
5: 'annual' // VACATION
|
||||
};
|
||||
type = codeMap[record.attendance_type_code] || codeMap[record.attendance_type_id] || 'normal';
|
||||
}
|
||||
// 3. 출근 안 한 경우 (is_present가 0이고 휴가 정보 없으면 결근 상태로 표시)
|
||||
else if (record.is_present === 0) {
|
||||
type = 'normal'; // 기본값, 사용자가 수정해야 함
|
||||
}
|
||||
|
||||
// 연장근로 확인
|
||||
if (record.total_work_hours > 8 && type === 'normal') {
|
||||
type = 'overtime';
|
||||
overtimeHours = record.total_work_hours - 8;
|
||||
}
|
||||
|
||||
const typeInfo = attendanceTypes.find(t => t.value === type);
|
||||
|
||||
workStatus[w.user_id] = {
|
||||
isPresent: record.is_present === 1 || typeInfo?.isLeave,
|
||||
type: type,
|
||||
hours: typeInfo !== undefined ? typeInfo.hours : 8,
|
||||
overtimeHours: overtimeHours,
|
||||
isSaved: record.attendance_type_id != null || record.total_work_hours > 0 || record.vacation_type_id != null,
|
||||
hasLeaveInfo: typeInfo?.isLeave || false
|
||||
};
|
||||
} else {
|
||||
// 출근 체크 기록이 없는 경우 - 결근 상태
|
||||
workStatus[w.user_id] = {
|
||||
isPresent: false,
|
||||
type: 'normal',
|
||||
hours: 8,
|
||||
overtimeHours: 0,
|
||||
isSaved: false,
|
||||
hasLeaveInfo: false
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
render();
|
||||
updateSummary();
|
||||
updateSaveStatus();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('데이터 로드 실패');
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
|
||||
if (isMobile) {
|
||||
renderMobile();
|
||||
} else {
|
||||
renderDesktop();
|
||||
}
|
||||
}
|
||||
|
||||
function renderMobile() {
|
||||
const tbody = document.getElementById('workerTableBody');
|
||||
|
||||
if (workers.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:#6b7280;padding:2rem;">작업자가 없습니다</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
// 모바일에서는 테이블을 숨기고 카드 뷰 사용
|
||||
const table = tbody.closest('table');
|
||||
table.style.display = 'none';
|
||||
|
||||
// 기존 모바일 컨테이너 제거
|
||||
let mobileContainer = document.getElementById('mobileWorkCards');
|
||||
if (!mobileContainer) {
|
||||
mobileContainer = document.createElement('div');
|
||||
mobileContainer.id = 'mobileWorkCards';
|
||||
table.parentNode.insertBefore(mobileContainer, table.nextSibling);
|
||||
}
|
||||
|
||||
mobileContainer.className = 'mobile-work-cards';
|
||||
mobileContainer.innerHTML = workers.map((w, idx) => {
|
||||
const s = workStatus[w.user_id];
|
||||
|
||||
if (s.isNotHired) {
|
||||
return `
|
||||
<div class="mobile-work-card not-hired">
|
||||
<div class="wc-left">
|
||||
<span class="wc-name">${w.worker_name} <span class="not-hired-tag">미입사</span></span>
|
||||
<span class="wc-status" style="color:#9ca3af;">입사일: ${formatDisplayDate(s.joinDate)}</span>
|
||||
</div>
|
||||
<div class="wc-right"><span class="wc-hours">-</span></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const typeInfo = attendanceTypes.find(t => t.value === s.type);
|
||||
const isLeaveType = typeInfo?.isLeave || false;
|
||||
const showOvertimeInput = s.type === 'overtime';
|
||||
const baseHours = s.hours;
|
||||
const totalHours = s.type === 'overtime' ? baseHours + s.overtimeHours : baseHours;
|
||||
|
||||
let rowClass = '';
|
||||
if (s.isSaved) rowClass = isLeaveType ? 'leave' : 'saved';
|
||||
else if (!s.isPresent) rowClass = isLeaveType ? 'leave' : 'absent-no-leave';
|
||||
|
||||
let statusText = '', statusClass = '';
|
||||
if (isLeaveType) { statusText = typeInfo.label; statusClass = 'color:#a16207;'; }
|
||||
else if (s.isPresent) { statusText = '출근'; statusClass = 'color:#10b981;'; }
|
||||
else { statusText = '⚠️ 결근'; statusClass = 'color:#dc2626;font-weight:600;'; }
|
||||
|
||||
let tag = '';
|
||||
if (s.isSaved) tag = '<span class="saved-tag">저장됨</span>';
|
||||
else if (isLeaveType) tag = '<span class="leave-tag">연차</span>';
|
||||
|
||||
return `
|
||||
<div class="mobile-work-card ${rowClass}">
|
||||
<div class="wc-left">
|
||||
<span class="wc-name">${w.worker_name} ${tag}</span>
|
||||
<span class="wc-status" style="${statusClass}">${statusText}</span>
|
||||
</div>
|
||||
<div class="wc-right">
|
||||
<select onchange="updateType(${w.user_id}, this.value)">
|
||||
${attendanceTypes.map(t => `
|
||||
<option value="${t.value}" ${s.type === t.value ? 'selected' : ''}>${t.label}</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
${showOvertimeInput ? `
|
||||
<input type="number" class="overtime-input" value="${s.overtimeHours}" min="0" max="8" step="0.5"
|
||||
onchange="updateOvertime(${w.user_id}, this.value)" style="width:60px;text-align:center;font-size:14px;">
|
||||
` : ''}
|
||||
<span class="wc-hours">${totalHours}h</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderDesktop() {
|
||||
const tbody = document.getElementById('workerTableBody');
|
||||
const table = tbody.closest('table');
|
||||
table.style.display = '';
|
||||
|
||||
// 모바일 컨테이너 숨기기
|
||||
const mobileContainer = document.getElementById('mobileWorkCards');
|
||||
if (mobileContainer) mobileContainer.remove();
|
||||
|
||||
if (workers.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:#6b7280;padding:2rem;">작업자가 없습니다</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = workers.map((w, idx) => {
|
||||
const s = workStatus[w.user_id];
|
||||
|
||||
if (s.isNotHired) {
|
||||
return `
|
||||
<tr class="not-hired">
|
||||
<td>${idx + 1}</td>
|
||||
<td>
|
||||
<span class="worker-name">${w.worker_name}</span>
|
||||
<span class="not-hired-tag">미입사</span>
|
||||
</td>
|
||||
<td class="status-not-hired">-</td>
|
||||
<td><span style="color:#9ca3af;font-size:0.8rem;">입사일: ${formatDisplayDate(s.joinDate)}</span></td>
|
||||
<td class="hours-cell">-</td>
|
||||
<td class="hours-cell">-</td>
|
||||
<td class="hours-cell">-</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
const typeInfo = attendanceTypes.find(t => t.value === s.type);
|
||||
const isLeaveType = typeInfo?.isLeave || false;
|
||||
const showOvertimeInput = s.type === 'overtime';
|
||||
const baseHours = s.hours;
|
||||
const totalHours = s.type === 'overtime' ? baseHours + s.overtimeHours : baseHours;
|
||||
|
||||
let rowClass = '';
|
||||
if (s.isSaved) rowClass = isLeaveType ? 'leave' : 'saved';
|
||||
else if (!s.isPresent) rowClass = isLeaveType ? 'leave' : 'absent-no-leave';
|
||||
|
||||
let statusText = '', statusClass = '';
|
||||
if (isLeaveType) { statusText = typeInfo.label; statusClass = 'status-leave'; }
|
||||
else if (s.isPresent) { statusText = '출근'; statusClass = 'status-present'; }
|
||||
else { statusText = '⚠️ 결근'; statusClass = 'status-absent-warning'; }
|
||||
|
||||
let tag = '';
|
||||
if (s.isSaved) tag = '<span class="saved-tag">저장됨</span>';
|
||||
else if (isLeaveType) tag = '<span class="leave-tag">연차</span>';
|
||||
|
||||
return `
|
||||
<tr class="${rowClass}">
|
||||
<td>${idx + 1}</td>
|
||||
<td>
|
||||
<span class="worker-name">${w.worker_name}</span>
|
||||
${tag}
|
||||
</td>
|
||||
<td class="${statusClass}">${statusText}</td>
|
||||
<td>
|
||||
<select class="type-select" onchange="updateType(${w.user_id}, this.value)">
|
||||
${attendanceTypes.map(t => `
|
||||
<option value="${t.value}" ${s.type === t.value ? 'selected' : ''}>${t.label}</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</td>
|
||||
<td class="hours-cell">${baseHours}h</td>
|
||||
<td class="hours-cell">
|
||||
${showOvertimeInput ? `
|
||||
<input type="number" class="overtime-input" value="${s.overtimeHours}" min="0" max="8" step="0.5"
|
||||
onchange="updateOvertime(${w.user_id}, this.value)">
|
||||
` : '-'}
|
||||
</td>
|
||||
<td class="hours-cell"><strong>${totalHours}h</strong></td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// 화면 크기 변경 시 재렌더링
|
||||
let resizeTimer;
|
||||
window.addEventListener('resize', () => {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(() => {
|
||||
if (workers.length > 0) render();
|
||||
}, 250);
|
||||
});
|
||||
|
||||
function updateType(workerId, value) {
|
||||
const typeInfo = attendanceTypes.find(t => t.value === value);
|
||||
workStatus[workerId].type = value;
|
||||
workStatus[workerId].hours = typeInfo !== undefined ? typeInfo.hours : 8;
|
||||
workStatus[workerId].hasLeaveInfo = typeInfo?.isLeave || false;
|
||||
|
||||
// 연차 유형 선택 시 출근 상태로 간주 (결근 아님)
|
||||
if (typeInfo?.isLeave) {
|
||||
workStatus[workerId].isPresent = true;
|
||||
}
|
||||
|
||||
if (value === 'overtime') {
|
||||
workStatus[workerId].overtimeHours = workStatus[workerId].overtimeHours || 2;
|
||||
} else {
|
||||
workStatus[workerId].overtimeHours = 0;
|
||||
}
|
||||
|
||||
render();
|
||||
updateSummary();
|
||||
}
|
||||
|
||||
function updateOvertime(workerId, value) {
|
||||
workStatus[workerId].overtimeHours = parseFloat(value) || 0;
|
||||
render();
|
||||
updateSummary();
|
||||
}
|
||||
|
||||
function setAllNormal() {
|
||||
workers.forEach(w => {
|
||||
workStatus[w.user_id].type = 'normal';
|
||||
workStatus[w.user_id].hours = 8;
|
||||
workStatus[w.user_id].overtimeHours = 0;
|
||||
});
|
||||
render();
|
||||
updateSummary();
|
||||
}
|
||||
|
||||
function updateSummary() {
|
||||
let normal = 0, annual = 0, half = 0, quarter = 0, early = 0, overtime = 0, absent = 0, notHired = 0;
|
||||
|
||||
Object.values(workStatus).forEach(s => {
|
||||
// 미입사자 제외
|
||||
if (s.isNotHired) {
|
||||
notHired++;
|
||||
return;
|
||||
}
|
||||
|
||||
const typeInfo = attendanceTypes.find(t => t.value === s.type);
|
||||
const isLeaveType = typeInfo?.isLeave || false;
|
||||
|
||||
// 출근 안 했고 연차 정보도 없으면 결근
|
||||
if (!s.isPresent && !isLeaveType) {
|
||||
absent++;
|
||||
}
|
||||
|
||||
switch (s.type) {
|
||||
case 'normal': if (s.isPresent) normal++; break;
|
||||
case 'annual': annual++; break;
|
||||
case 'half': half++; break;
|
||||
case 'quarter': quarter++; break;
|
||||
case 'early': early++; break;
|
||||
case 'overtime': overtime++; break;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('normalCount').textContent = normal;
|
||||
document.getElementById('annualCount').textContent = annual;
|
||||
document.getElementById('halfCount').textContent = half;
|
||||
document.getElementById('quarterCount').textContent = quarter;
|
||||
document.getElementById('earlyCount').textContent = early;
|
||||
document.getElementById('overtimeCount').textContent = overtime;
|
||||
document.getElementById('absentCount').textContent = absent;
|
||||
}
|
||||
|
||||
function updateSaveStatus() {
|
||||
const statusEl = document.getElementById('saveStatus');
|
||||
const saveBtn = document.getElementById('saveBtn');
|
||||
|
||||
if (isAlreadySaved) {
|
||||
statusEl.innerHTML = '이 날짜의 근무 현황이 저장되어 있습니다';
|
||||
statusEl.className = 'save-status saved';
|
||||
saveBtn.textContent = '수정 저장';
|
||||
} else {
|
||||
statusEl.innerHTML = '아직 저장되지 않았습니다';
|
||||
statusEl.className = 'save-status unsaved';
|
||||
saveBtn.textContent = '저장';
|
||||
}
|
||||
}
|
||||
|
||||
async function saveWorkStatus() {
|
||||
const date = document.getElementById('selectedDate').value;
|
||||
if (!date) return alert('날짜를 선택해주세요.');
|
||||
|
||||
if (isSaving) return;
|
||||
|
||||
const saveBtn = document.getElementById('saveBtn');
|
||||
|
||||
// work_attendance_types: 1=NORMAL, 2=LATE, 3=EARLY_LEAVE, 4=ABSENT, 5=VACATION
|
||||
const typeIdMap = {
|
||||
'normal': 1, // NORMAL
|
||||
'annual': 5, // VACATION
|
||||
'half': 5, // VACATION
|
||||
'quarter': 5, // VACATION
|
||||
'early': 3, // EARLY_LEAVE
|
||||
'overtime': 1 // NORMAL (시간으로 구분)
|
||||
};
|
||||
|
||||
// vacation_types: 1=ANNUAL_FULL, 2=ANNUAL_HALF, 3=ANNUAL_QUARTER, EARLY_LEAVE=동적
|
||||
const vacationTypeIdMap = {
|
||||
'annual': 1,
|
||||
'half': 2,
|
||||
'quarter': 3,
|
||||
'early': earlyLeaveTypeId,
|
||||
};
|
||||
|
||||
// 조퇴가 있는데 vacation_type_id가 없으면 저장 차단
|
||||
const hasEarlyWithoutType = workers.some(w => {
|
||||
const s = workStatus[w.user_id];
|
||||
return s && s.type === 'early' && !earlyLeaveTypeId;
|
||||
});
|
||||
if (hasEarlyWithoutType) {
|
||||
alert('조퇴 휴가 유형이 등록되지 않았습니다. 관리자에게 문의해주세요.');
|
||||
isSaving = false;
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = '저장';
|
||||
return;
|
||||
}
|
||||
|
||||
// 미입사자 제외하고 저장할 데이터 생성
|
||||
const recordsToSave = workers
|
||||
.filter(w => !workStatus[w.user_id]?.isNotHired)
|
||||
.map(w => {
|
||||
const s = workStatus[w.user_id];
|
||||
const totalHours = s.type === 'overtime' ? s.hours + s.overtimeHours : s.hours;
|
||||
|
||||
return {
|
||||
record_date: date,
|
||||
user_id: w.user_id,
|
||||
attendance_type_id: typeIdMap[s.type] || 1,
|
||||
vacation_type_id: vacationTypeIdMap[s.type] || null,
|
||||
total_work_hours: totalHours,
|
||||
overtime_approved: s.type === 'overtime',
|
||||
notes: s.type === 'overtime' ? `연장근로 ${s.overtimeHours}시간` : null
|
||||
};
|
||||
});
|
||||
|
||||
isSaving = true;
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = '저장 중...';
|
||||
|
||||
try {
|
||||
let ok = 0, fail = 0;
|
||||
for (const r of recordsToSave) {
|
||||
try {
|
||||
await axios.post('/attendance/records', r);
|
||||
ok++;
|
||||
} catch (e) {
|
||||
console.error('저장 실패:', e);
|
||||
fail++;
|
||||
}
|
||||
}
|
||||
|
||||
if (fail === 0) {
|
||||
alert(`${ok}명 저장 완료`);
|
||||
isAlreadySaved = true;
|
||||
workers.forEach(w => {
|
||||
if (workStatus[w.user_id]) {
|
||||
workStatus[w.user_id].isSaved = true;
|
||||
}
|
||||
});
|
||||
render();
|
||||
updateSaveStatus();
|
||||
} else if (ok > 0) {
|
||||
alert(`${ok}명 성공, ${fail}명 실패`);
|
||||
} else {
|
||||
alert('저장에 실패했습니다');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('저장 중 오류가 발생했습니다');
|
||||
} finally {
|
||||
isSaving = false;
|
||||
saveBtn.disabled = false;
|
||||
updateSaveStatus();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script>initAuth();</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user