카카오톡 인앱 WebView는 서브도메인 간 쿠키를 공유하지 않아 tkds에서 로그인 후 tkfb로 리다이렉트 시 인증이 풀리는 문제. - sso-relay.js: URL hash의 _sso= 토큰을 로컬 쿠키+localStorage로 설정 - gateway dashboard: 로그인 후 redirect URL에 #_sso=<token> 추가 - 전 서비스 HTML: core JS 직전에 sso-relay.js 로드 (81개 파일) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
429 lines
15 KiB
HTML
429 lines
15 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>출근 체크 - TK 공장관리</title>
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
|
<link rel="stylesheet" href="/static/css/tkfb.css?v=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>
|