Files
TK-FB-Project/web-ui/pages/attendance/checkin.html
Hyungi Ahn 7c38c555f5 fix: 출근체크/근무현황 페이지 버그 수정
- workers API 기본 limit 10 → 100 변경 (작업자 누락 문제 해결)
- 작업자 필터 조건 수정 (status='active' + employment_status 체크)
- 근태 기록 저장 시 컬럼명 불일치 수정 (attendance_type_id)
- 근무현황 페이지에 저장 상태 표시 추가 (✓저장됨)
- 디버그 로그 제거

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 20:58:30 +09:00

396 lines
12 KiB
HTML

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>출근 체크 | (주)테크니컬코리아</title>
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<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;
}
</style>
</head>
<body class="has-sidebar">
<div id="navbar-container"></div>
<div id="sidebar-container"></div>
<main class="main-content">
<div class="page-wrapper">
<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>
</main>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script type="module">
</script>
<script>
(function() {
const checkApiConfig = setInterval(() => {
if (window.API_BASE_URL) {
clearInterval(checkApiConfig);
axios.defaults.baseURL = window.API_BASE_URL;
const token = localStorage.getItem('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();
});
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.worker_id === w.worker_id);
const record = records.find(r => r.worker_id === w.worker_id);
if (checkin?.vacation_status === 'approved' || record?.vacation_type_id) {
checkinStatus[w.worker_id] = { status: 'vacation', vacationType: checkin?.vacation_type_name || record?.vacation_type_name || '연차' };
} else if (record && record.is_present === 0) {
checkinStatus[w.worker_id] = { status: 'absent' };
} else if (record && record.is_present === 1) {
checkinStatus[w.worker_id] = { status: 'present' };
} else {
// 기록이 없으면 기본 출근
checkinStatus[w.worker_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.worker_id] || { status: 'present' };
const label = s.status === 'present' ? '출근' : s.status === 'absent' ? '결근' : (s.vacationType || '연차');
return `<span class="worker-chip ${s.status}" onclick="toggle(${w.worker_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.worker_id]?.status !== 'vacation') {
checkinStatus[w.worker_id] = { status: 'present' };
}
});
render();
}
function setAllAbsent() {
workers.forEach(w => {
if (checkinStatus[w.worker_id]?.status !== 'vacation') {
checkinStatus[w.worker_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.worker_id]?.status !== 'vacation')
.map(w => ({
worker_id: w.worker_id,
is_present: checkinStatus[w.worker_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>
</body>
</html>