카카오톡 인앱 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>
840 lines
30 KiB
HTML
840 lines
30 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: 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>
|