- 공통 유틸리티 추출 (common/utils.js, common/base-state.js) - TBM 모바일 인라인 JS/CSS 외부 파일로 분리 (tbm-mobile.js, tbm-mobile.css) - 미사용 코드 삭제 (index.js, work-report-*.js 등 5개 파일) - TBM/작업보고 state.js, utils.js를 공통 모듈 기반으로 전환 - 작업보고서 SSO 인증 호환 수정 (token/user 함수) - tbmModel.js: incomplete-reports 쿼리에서 users→sso_users 조인 수정, leader_name 조인 추가 - docker-compose.yml: system1-web 볼륨 마운트 추가 - 모바일 인계(handover) 기능 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1162 lines
34 KiB
HTML
1162 lines
34 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?v=2"></script>
|
|
<script src="/js/app-init.js?v=9" defer></script>
|
|
<script src="https://instant.page/5.2.0" type="module"></script>
|
|
<style>
|
|
/* 테이블 컨테이너 */
|
|
.table-container {
|
|
overflow-x: auto;
|
|
margin-top: 1rem;
|
|
}
|
|
|
|
/* 출근부 테이블 */
|
|
.attendance-table {
|
|
border-collapse: collapse;
|
|
font-size: 11px;
|
|
min-width: 100%;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.attendance-table th,
|
|
.attendance-table td {
|
|
border: 1px solid #9ca3af;
|
|
padding: 4px 6px;
|
|
text-align: center;
|
|
vertical-align: middle;
|
|
height: 28px;
|
|
}
|
|
|
|
/* 헤더 스타일 */
|
|
.attendance-table thead th {
|
|
background: #e5e7eb;
|
|
font-weight: 600;
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 10;
|
|
}
|
|
|
|
/* 고정 열 */
|
|
.attendance-table th.fixed,
|
|
.attendance-table td.fixed {
|
|
position: sticky;
|
|
background: #fff;
|
|
z-index: 5;
|
|
}
|
|
|
|
.attendance-table th.fixed-no,
|
|
.attendance-table td.fixed-no {
|
|
left: 0;
|
|
width: 35px;
|
|
min-width: 35px;
|
|
max-width: 35px;
|
|
background: #f3f4f6;
|
|
}
|
|
|
|
.attendance-table th.fixed-name,
|
|
.attendance-table td.fixed-name {
|
|
left: 35px;
|
|
width: 60px;
|
|
min-width: 60px;
|
|
max-width: 60px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.attendance-table th.fixed-job,
|
|
.attendance-table td.fixed-job {
|
|
left: 95px;
|
|
width: 50px;
|
|
min-width: 50px;
|
|
max-width: 50px;
|
|
color: #4b5563;
|
|
box-shadow: 2px 0 4px rgba(0,0,0,0.1);
|
|
}
|
|
|
|
.attendance-table thead th.fixed {
|
|
z-index: 20;
|
|
background: #e5e7eb;
|
|
}
|
|
|
|
.attendance-table thead th.fixed-no {
|
|
background: #e5e7eb;
|
|
z-index: 20;
|
|
}
|
|
|
|
.attendance-table thead th.fixed-name {
|
|
background: #e5e7eb;
|
|
z-index: 20;
|
|
}
|
|
|
|
.attendance-table thead th.fixed-job {
|
|
background: #e5e7eb;
|
|
z-index: 20;
|
|
box-shadow: 2px 0 4px rgba(0,0,0,0.1);
|
|
}
|
|
|
|
/* 날짜 열 - 반반차 텍스트가 들어갈 수 있도록 너비 확보 */
|
|
.attendance-table .day-col {
|
|
width: 40px;
|
|
min-width: 40px;
|
|
}
|
|
|
|
/* 날짜 셀 내용 */
|
|
.attendance-table td.day-cell {
|
|
width: 40px;
|
|
min-width: 40px;
|
|
font-size: 10px;
|
|
padding: 2px;
|
|
}
|
|
|
|
/* 토요일 헤더 */
|
|
.attendance-table thead th.saturday {
|
|
color: #2563eb;
|
|
}
|
|
|
|
/* 일요일 헤더 */
|
|
.attendance-table thead th.sunday {
|
|
color: #dc2626;
|
|
}
|
|
|
|
/* === 셀 상태별 스타일 === */
|
|
|
|
/* 주말/공휴일/휴무 - 분홍색 계열 */
|
|
.attendance-table td.cell-holiday {
|
|
background-color: #e8b4b4;
|
|
color: #7f1d1d;
|
|
}
|
|
|
|
/* 정시근무 (8h) - 녹색 배경만 */
|
|
.attendance-table td.cell-normal {
|
|
background-color: #86efac;
|
|
}
|
|
|
|
/* 연차/반차/반반차/조퇴 - 노란색 계열 */
|
|
.attendance-table td.cell-leave {
|
|
background-color: #fde047;
|
|
color: #713f12;
|
|
}
|
|
|
|
/* 연장근무 - 파란색 계열 */
|
|
.attendance-table td.cell-overtime {
|
|
background-color: #93c5fd;
|
|
color: #1e3a8a;
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* 미입력 근무일 - 회색 */
|
|
.attendance-table td.cell-empty {
|
|
background-color: #d1d5db;
|
|
color: #6b7280;
|
|
}
|
|
|
|
/* 미입사 - 연한 회색 */
|
|
.attendance-table td.cell-not-hired {
|
|
background-color: #f3f4f6;
|
|
color: #9ca3af;
|
|
font-size: 9px;
|
|
}
|
|
|
|
/* 총시간 열 */
|
|
.attendance-table .total-col {
|
|
min-width: 50px;
|
|
font-weight: 600;
|
|
background-color: #f3f4f6 !important;
|
|
}
|
|
|
|
.attendance-table thead th.total-col {
|
|
background-color: #d1d5db !important;
|
|
}
|
|
|
|
/* 범례 */
|
|
.legend-container {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 1.5rem;
|
|
margin-top: 1.5rem;
|
|
padding: 1rem;
|
|
background: #f9fafb;
|
|
border-radius: 0.5rem;
|
|
border: 1px solid #e5e7eb;
|
|
}
|
|
|
|
.legend-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.legend-color {
|
|
width: 28px;
|
|
height: 18px;
|
|
border: 1px solid #9ca3af;
|
|
border-radius: 2px;
|
|
}
|
|
|
|
.legend-holiday { background-color: #e8b4b4; }
|
|
.legend-normal { background-color: #86efac; }
|
|
.legend-leave { background-color: #fde047; }
|
|
.legend-overtime { background-color: #93c5fd; }
|
|
.legend-empty { background-color: #d1d5db; }
|
|
.legend-not-hired { background-color: #f3f4f6; }
|
|
|
|
/* 로딩 */
|
|
.loading-overlay {
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
padding: 3rem;
|
|
}
|
|
|
|
.spinner {
|
|
width: 40px;
|
|
height: 40px;
|
|
border: 3px solid #e5e7eb;
|
|
border-top-color: #3b82f6;
|
|
border-radius: 50%;
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
/* 요약 카드 */
|
|
.summary-row {
|
|
display: flex;
|
|
gap: 1rem;
|
|
margin-bottom: 1.5rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.summary-card {
|
|
background: white;
|
|
padding: 0.75rem 1.25rem;
|
|
border-radius: 0.5rem;
|
|
border: 1px solid #e5e7eb;
|
|
text-align: center;
|
|
min-width: 100px;
|
|
}
|
|
|
|
.summary-value {
|
|
font-size: 1.25rem;
|
|
font-weight: 700;
|
|
color: #111827;
|
|
}
|
|
|
|
.summary-label {
|
|
font-size: 0.7rem;
|
|
color: #6b7280;
|
|
margin-top: 0.125rem;
|
|
}
|
|
|
|
/* 유급휴무 설정 모달 */
|
|
.modal-overlay {
|
|
display: none;
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(0, 0, 0, 0.5);
|
|
z-index: 1000;
|
|
justify-content: center;
|
|
align-items: center;
|
|
}
|
|
|
|
.modal-overlay.active {
|
|
display: flex;
|
|
}
|
|
|
|
.modal-content {
|
|
background: white;
|
|
border-radius: 0.75rem;
|
|
max-width: 500px;
|
|
width: 90%;
|
|
max-height: 80vh;
|
|
overflow-y: auto;
|
|
padding: 1.5rem;
|
|
}
|
|
|
|
.modal-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 1rem;
|
|
padding-bottom: 0.75rem;
|
|
border-bottom: 1px solid #e5e7eb;
|
|
}
|
|
|
|
.modal-title {
|
|
font-size: 1.125rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.modal-close {
|
|
background: none;
|
|
border: none;
|
|
font-size: 1.5rem;
|
|
cursor: pointer;
|
|
color: #6b7280;
|
|
line-height: 1;
|
|
}
|
|
|
|
.holiday-list {
|
|
max-height: 300px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.holiday-item {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0.5rem;
|
|
border-bottom: 1px solid #f3f4f6;
|
|
}
|
|
|
|
.holiday-item:hover {
|
|
background: #f9fafb;
|
|
}
|
|
|
|
.holiday-date {
|
|
font-weight: 500;
|
|
}
|
|
|
|
.holiday-name {
|
|
color: #6b7280;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.holiday-toggle {
|
|
cursor: pointer;
|
|
}
|
|
|
|
.add-holiday-form {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
margin-top: 1rem;
|
|
padding-top: 1rem;
|
|
border-top: 1px solid #e5e7eb;
|
|
}
|
|
|
|
.add-holiday-form input {
|
|
flex: 1;
|
|
padding: 0.5rem;
|
|
border: 1px solid #d1d5db;
|
|
border-radius: 0.375rem;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<!-- 네비게이션 바 -->
|
|
<div id="navbar-container"></div>
|
|
|
|
<!-- 메인 콘텐츠 -->
|
|
<main class="main-content">
|
|
<div class="dashboard-main">
|
|
<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="month" id="selectedMonth" class="form-control" style="width: auto;">
|
|
<button class="btn btn-primary" onclick="loadMonthlyData()">
|
|
조회
|
|
</button>
|
|
<button class="btn btn-secondary" onclick="openHolidayModal()">
|
|
휴무일 설정
|
|
</button>
|
|
<button class="btn btn-secondary" onclick="exportToExcel()">
|
|
Excel 내보내기
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 요약 통계 -->
|
|
<div class="content-section">
|
|
<div class="summary-row" id="summaryRow">
|
|
<!-- 동적 렌더링 -->
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 출근부 -->
|
|
<div class="content-section">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h2 class="card-title" id="tableTitle">2026년 2월 출근부</h2>
|
|
</div>
|
|
<div class="card-body" style="padding: 0;">
|
|
<div class="table-container" id="tableContainer">
|
|
<div class="loading-overlay">
|
|
<div class="spinner"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 범례 -->
|
|
<div class="content-section">
|
|
<div class="legend-container">
|
|
<div class="legend-item">
|
|
<div class="legend-color legend-holiday"></div>
|
|
<span>휴무 (주말/공휴일)</span>
|
|
</div>
|
|
<div class="legend-item">
|
|
<div class="legend-color legend-normal"></div>
|
|
<span>정시근무 (8h)</span>
|
|
</div>
|
|
<div class="legend-item">
|
|
<div class="legend-color legend-leave"></div>
|
|
<span>연차/반차/반반차/조퇴</span>
|
|
</div>
|
|
<div class="legend-item">
|
|
<div class="legend-color legend-overtime"></div>
|
|
<span>연장근무 (초과시간 표시)</span>
|
|
</div>
|
|
<div class="legend-item">
|
|
<div class="legend-color legend-empty"></div>
|
|
<span>미입력</span>
|
|
</div>
|
|
<div class="legend-item">
|
|
<div class="legend-color legend-not-hired"></div>
|
|
<span>미입사</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
<!-- 휴무일 설정 모달 -->
|
|
<div class="modal-overlay" id="holidayModal">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h3 class="modal-title">휴무일 설정</h3>
|
|
<button class="modal-close" onclick="closeHolidayModal()">×</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<p style="font-size: 0.875rem; color: #6b7280; margin-bottom: 1rem;">
|
|
공휴일 외 추가 유급휴무일을 설정할 수 있습니다.
|
|
</p>
|
|
<div class="holiday-list" id="holidayList">
|
|
<!-- 동적 렌더링 -->
|
|
</div>
|
|
<div class="add-holiday-form">
|
|
<input type="date" id="newHolidayDate" placeholder="날짜">
|
|
<input type="text" id="newHolidayName" placeholder="휴무 사유">
|
|
<button class="btn btn-primary btn-sm" onclick="addCompanyHoliday()">추가</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
|
<script type="module">
|
|
import '/js/api-config.js?v=3';
|
|
</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 attendanceData = {}; // { workerId: { date: record } }
|
|
let currentYear = 2026;
|
|
let currentMonth = 2;
|
|
|
|
// 요일 이름
|
|
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
|
|
|
|
// 한국 공휴일 (2026년 기준, 매년 업데이트 필요)
|
|
const koreanHolidays = {
|
|
'2026': {
|
|
'01-01': '신정',
|
|
'02-16': '설날 전날',
|
|
'02-17': '설날',
|
|
'02-18': '설날 다음날',
|
|
'03-01': '삼일절',
|
|
'05-05': '어린이날',
|
|
'05-24': '부처님오신날',
|
|
'06-06': '현충일',
|
|
'08-15': '광복절',
|
|
'10-03': '개천절',
|
|
'10-05': '추석 전날',
|
|
'10-06': '추석',
|
|
'10-07': '추석 다음날',
|
|
'10-09': '한글날',
|
|
'12-25': '크리스마스'
|
|
},
|
|
'2025': {
|
|
'01-01': '신정',
|
|
'01-28': '설날 전날',
|
|
'01-29': '설날',
|
|
'01-30': '설날 다음날',
|
|
'03-01': '삼일절',
|
|
'05-05': '어린이날',
|
|
'05-05': '부처님오신날',
|
|
'06-06': '현충일',
|
|
'08-15': '광복절',
|
|
'10-03': '개천절',
|
|
'10-05': '추석 전날',
|
|
'10-06': '추석',
|
|
'10-07': '추석 다음날',
|
|
'10-09': '한글날',
|
|
'12-25': '크리스마스'
|
|
}
|
|
};
|
|
|
|
// 회사 유급휴무 (로컬스토리지에서 관리)
|
|
let companyHolidays = {};
|
|
|
|
// 페이지 로드 시 초기화
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
await waitForAxiosConfig();
|
|
loadCompanyHolidays();
|
|
initializePage();
|
|
});
|
|
|
|
function waitForAxiosConfig() {
|
|
return new Promise((resolve) => {
|
|
const check = setInterval(() => {
|
|
if (axios.defaults.baseURL) {
|
|
clearInterval(check);
|
|
resolve();
|
|
}
|
|
}, 50);
|
|
setTimeout(() => {
|
|
clearInterval(check);
|
|
resolve();
|
|
}, 5000);
|
|
});
|
|
}
|
|
|
|
function loadCompanyHolidays() {
|
|
const saved = localStorage.getItem('companyHolidays');
|
|
if (saved) {
|
|
companyHolidays = JSON.parse(saved);
|
|
}
|
|
}
|
|
|
|
function saveCompanyHolidays() {
|
|
localStorage.setItem('companyHolidays', JSON.stringify(companyHolidays));
|
|
}
|
|
|
|
async function initializePage() {
|
|
// 현재 년월 설정
|
|
const now = new Date();
|
|
currentYear = now.getFullYear();
|
|
currentMonth = now.getMonth() + 1;
|
|
const yearMonth = `${currentYear}-${String(currentMonth).padStart(2, '0')}`;
|
|
document.getElementById('selectedMonth').value = yearMonth;
|
|
|
|
await loadMonthlyData();
|
|
}
|
|
|
|
// 공휴일 또는 회사 휴무일인지 확인
|
|
function isHoliday(dateStr) {
|
|
const [year, month, day] = dateStr.split('-');
|
|
const monthDay = `${month}-${day}`;
|
|
|
|
// 한국 공휴일 확인
|
|
if (koreanHolidays[year] && koreanHolidays[year][monthDay]) {
|
|
return { isHoliday: true, name: koreanHolidays[year][monthDay], type: 'public' };
|
|
}
|
|
|
|
// 회사 유급휴무 확인
|
|
if (companyHolidays[dateStr]) {
|
|
return { isHoliday: true, name: companyHolidays[dateStr], type: 'company' };
|
|
}
|
|
|
|
return { isHoliday: false };
|
|
}
|
|
|
|
async function loadMonthlyData() {
|
|
const selectedMonth = document.getElementById('selectedMonth').value;
|
|
|
|
if (!selectedMonth) {
|
|
alert('월을 선택해주세요.');
|
|
return;
|
|
}
|
|
|
|
[currentYear, currentMonth] = selectedMonth.split('-').map(Number);
|
|
|
|
// 제목 업데이트
|
|
document.getElementById('tableTitle').textContent = `${currentYear}년 ${currentMonth}월 출근부`;
|
|
|
|
// 로딩 표시
|
|
document.getElementById('tableContainer').innerHTML = `
|
|
<div class="loading-overlay">
|
|
<div class="spinner"></div>
|
|
</div>
|
|
`;
|
|
|
|
try {
|
|
// 작업자 목록 로드
|
|
const workersResponse = await axios.get('/workers?limit=100');
|
|
if (workersResponse.data.success) {
|
|
workers = workersResponse.data.data.filter(w => w.employment_status === 'employed');
|
|
// 이름순 정렬
|
|
workers.sort((a, b) => a.worker_name.localeCompare(b.worker_name, 'ko'));
|
|
}
|
|
|
|
// 날짜 범위 계산
|
|
const startDate = `${currentYear}-${String(currentMonth).padStart(2, '0')}-01`;
|
|
const lastDay = new Date(currentYear, currentMonth, 0).getDate();
|
|
const endDate = `${currentYear}-${String(currentMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
|
|
|
|
// 일별 근태 데이터 로드 (전체 작업자 - 한번에 조회)
|
|
attendanceData = {};
|
|
|
|
try {
|
|
const response = await axios.get('/attendance/records', {
|
|
params: {
|
|
start_date: startDate,
|
|
end_date: endDate
|
|
}
|
|
});
|
|
|
|
if (response.data.success && response.data.data) {
|
|
const records = response.data.data;
|
|
// 작업자별로 데이터 정리
|
|
records.forEach(record => {
|
|
const workerId = record.worker_id;
|
|
// 날짜 형식 정규화 (다양한 형식 처리)
|
|
let dateKey = record.record_date;
|
|
if (dateKey) {
|
|
if (typeof dateKey === 'string') {
|
|
// ISO 형식 또는 날짜 문자열 처리
|
|
dateKey = dateKey.split('T')[0].split(' ')[0];
|
|
} else if (dateKey instanceof Date) {
|
|
dateKey = dateKey.toISOString().split('T')[0];
|
|
}
|
|
}
|
|
|
|
if (!attendanceData[workerId]) {
|
|
attendanceData[workerId] = {};
|
|
}
|
|
attendanceData[workerId][dateKey] = record;
|
|
});
|
|
|
|
console.log('로드된 근태 데이터:', Object.keys(attendanceData).length, '명');
|
|
}
|
|
} catch (err) {
|
|
console.error('근태 데이터 조회 오류:', err);
|
|
// 작업자별 빈 데이터 초기화
|
|
workers.forEach(worker => {
|
|
attendanceData[worker.worker_id] = {};
|
|
});
|
|
}
|
|
|
|
renderTable();
|
|
renderSummary();
|
|
|
|
} catch (error) {
|
|
console.error('데이터 로드 오류:', error);
|
|
document.getElementById('tableContainer').innerHTML = `
|
|
<div class="loading-overlay">
|
|
<p style="color: #ef4444;">데이터를 불러오는 중 오류가 발생했습니다.</p>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
function renderTable() {
|
|
const container = document.getElementById('tableContainer');
|
|
const lastDay = new Date(currentYear, currentMonth, 0).getDate();
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
|
|
// 날짜별 정보 계산
|
|
const days = [];
|
|
for (let d = 1; d <= lastDay; d++) {
|
|
const date = new Date(currentYear, currentMonth - 1, d);
|
|
const dateStr = `${currentYear}-${String(currentMonth).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
|
const holidayInfo = isHoliday(dateStr);
|
|
|
|
days.push({
|
|
day: d,
|
|
dayOfWeek: date.getDay(),
|
|
dayName: dayNames[date.getDay()],
|
|
isWeekend: date.getDay() === 0 || date.getDay() === 6,
|
|
isHoliday: holidayInfo.isHoliday,
|
|
holidayName: holidayInfo.name,
|
|
dateStr: dateStr,
|
|
isFuture: date > today,
|
|
isPast: date < today,
|
|
isToday: date.getTime() === today.getTime()
|
|
});
|
|
}
|
|
|
|
// 테이블 HTML 생성
|
|
let html = `<table class="attendance-table" id="attendanceTable">`;
|
|
|
|
// 헤더 - 날짜 행
|
|
html += `<thead>`;
|
|
html += `<tr>`;
|
|
html += `<th class="fixed fixed-no" rowspan="2">No</th>`;
|
|
html += `<th class="fixed fixed-name" rowspan="2">이름</th>`;
|
|
html += `<th class="fixed fixed-job" rowspan="2">담당</th>`;
|
|
|
|
days.forEach(d => {
|
|
let headerClass = 'day-col';
|
|
if (d.dayOfWeek === 0) headerClass += ' sunday';
|
|
else if (d.dayOfWeek === 6) headerClass += ' saturday';
|
|
html += `<th class="${headerClass}">${d.day}</th>`;
|
|
});
|
|
|
|
html += `<th class="total-col" rowspan="2">연장</th>`;
|
|
html += `</tr>`;
|
|
|
|
// 헤더 - 요일 행
|
|
html += `<tr>`;
|
|
days.forEach(d => {
|
|
let headerClass = 'day-col';
|
|
if (d.dayOfWeek === 0) headerClass += ' sunday';
|
|
else if (d.dayOfWeek === 6) headerClass += ' saturday';
|
|
html += `<th class="${headerClass}">${d.dayName}</th>`;
|
|
});
|
|
html += `</tr>`;
|
|
html += `</thead>`;
|
|
|
|
// 바디
|
|
html += `<tbody>`;
|
|
|
|
workers.forEach((worker, index) => {
|
|
const workerRecords = attendanceData[worker.worker_id] || {};
|
|
let totalOvertimeHours = 0;
|
|
|
|
// 입사일 파싱
|
|
const joinDate = worker.join_date ? worker.join_date.split('T')[0].split(' ')[0] : null;
|
|
|
|
html += `<tr>`;
|
|
html += `<td class="fixed fixed-no">${index + 1}</td>`;
|
|
html += `<td class="fixed fixed-name">${worker.worker_name}</td>`;
|
|
html += `<td class="fixed fixed-job">${getJobTypeShort(worker.job_type)}</td>`;
|
|
|
|
days.forEach(d => {
|
|
const record = workerRecords[d.dateStr];
|
|
const cellData = getCellData(record, d, joinDate);
|
|
|
|
// 연장근무 시간만 더하기 (8시간 초과분)
|
|
if (cellData.hours > 8) {
|
|
totalOvertimeHours += (cellData.hours - 8);
|
|
}
|
|
|
|
html += `<td class="day-cell ${cellData.className}" title="${cellData.tooltip}">${cellData.display}</td>`;
|
|
});
|
|
|
|
html += `<td class="total-col">${totalOvertimeHours.toFixed(1)}</td>`;
|
|
html += `</tr>`;
|
|
});
|
|
|
|
html += `</tbody>`;
|
|
html += `</table>`;
|
|
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
function getJobTypeShort(jobType) {
|
|
const jobMap = {
|
|
'배관사': '배관',
|
|
'용접사': '용접',
|
|
'비계공': '비계',
|
|
'전기공': '전기',
|
|
'기계공': '기계',
|
|
'보조': '보조',
|
|
'관리자': '관리'
|
|
};
|
|
return jobMap[jobType] || jobType?.substring(0, 2) || '-';
|
|
}
|
|
|
|
function getCellData(record, dayInfo, joinDate = null) {
|
|
const { isWeekend, isHoliday, holidayName, isFuture, dateStr } = dayInfo;
|
|
|
|
// 0. 입사일 이전 → 미입사
|
|
if (joinDate && dateStr < joinDate) {
|
|
return {
|
|
display: '-',
|
|
className: 'cell-not-hired',
|
|
hours: 0,
|
|
tooltip: '미입사'
|
|
};
|
|
}
|
|
|
|
// 1. 주말/공휴일 → 분홍색 휴무
|
|
if (isWeekend || isHoliday) {
|
|
// 주말/공휴일인데 근무 기록이 있으면 연장근무로 처리
|
|
if (record && parseFloat(record.total_work_hours) > 0) {
|
|
const hours = parseFloat(record.total_work_hours);
|
|
return {
|
|
display: hours.toFixed(1),
|
|
className: 'cell-overtime',
|
|
hours: hours,
|
|
tooltip: `휴일근무 ${hours}시간`
|
|
};
|
|
}
|
|
return {
|
|
display: '휴무',
|
|
className: 'cell-holiday',
|
|
hours: 0,
|
|
tooltip: holidayName || (dayInfo.dayOfWeek === 0 ? '일요일' : '토요일')
|
|
};
|
|
}
|
|
|
|
// 2. 기록이 없는 경우
|
|
if (!record) {
|
|
// 미래 날짜 → 회색
|
|
if (isFuture) {
|
|
return {
|
|
display: '',
|
|
className: 'cell-empty',
|
|
hours: 0,
|
|
tooltip: '미입력'
|
|
};
|
|
}
|
|
// 과거 날짜인데 기록 없음 → 회색 (미입력)
|
|
return {
|
|
display: '',
|
|
className: 'cell-empty',
|
|
hours: 0,
|
|
tooltip: '미입력'
|
|
};
|
|
}
|
|
|
|
const hours = parseFloat(record.total_work_hours) || 0;
|
|
const attendanceTypeId = record.attendance_type_id;
|
|
const vacationTypeId = record.vacation_type_id;
|
|
|
|
// 3. 휴가 처리 - vacation_type_id가 있거나 attendance_type_id가 4(VACATION) 또는 5인 경우
|
|
// vacation_types: 1=연차, 2=반차, 3=반반차/병가
|
|
if (vacationTypeId || attendanceTypeId === 4 || attendanceTypeId === 5) {
|
|
let displayText = '연차';
|
|
if (vacationTypeId === 2) displayText = '반차';
|
|
else if (vacationTypeId === 3) displayText = '반반차';
|
|
|
|
return {
|
|
display: displayText,
|
|
className: 'cell-leave',
|
|
hours: hours,
|
|
tooltip: displayText + (hours > 0 ? ` (${hours}h 근무)` : '')
|
|
};
|
|
}
|
|
|
|
// 4. 조퇴 (attendance_type_id = 3) → 노란색
|
|
if (attendanceTypeId === 3) {
|
|
return {
|
|
display: '조퇴',
|
|
className: 'cell-leave',
|
|
hours: hours,
|
|
tooltip: `조퇴 (${hours}h 근무)`
|
|
};
|
|
}
|
|
|
|
// 5. 근무 시간이 0이고 기록이 있는 경우 → 결근으로 표시
|
|
if (hours === 0 && record.is_present === 0) {
|
|
return {
|
|
display: '결근',
|
|
className: 'cell-empty',
|
|
hours: 0,
|
|
tooltip: '결근'
|
|
};
|
|
}
|
|
|
|
// 6. 근무 시간이 0이면 미입력
|
|
if (hours === 0) {
|
|
return {
|
|
display: '',
|
|
className: 'cell-empty',
|
|
hours: 0,
|
|
tooltip: '미입력'
|
|
};
|
|
}
|
|
|
|
// 7. 연장근무 (8시간 초과) → 파란색 + 초과시간만 표시
|
|
if (hours > 8 || record.is_overtime_approved) {
|
|
const overtimeHours = hours - 8;
|
|
return {
|
|
display: overtimeHours > 0 ? `+${overtimeHours.toFixed(1)}` : '',
|
|
className: 'cell-overtime',
|
|
hours: hours,
|
|
tooltip: `총 ${hours}h (연장 ${overtimeHours.toFixed(1)}h)`
|
|
};
|
|
}
|
|
|
|
// 8. 정시근무 (8시간) → 녹색 배경만
|
|
if (hours === 8) {
|
|
return {
|
|
display: '',
|
|
className: 'cell-normal',
|
|
hours: hours,
|
|
tooltip: '정시근무 8h'
|
|
};
|
|
}
|
|
|
|
// 9. 8시간 미만 근무 → 녹색 + 시간 표시
|
|
return {
|
|
display: hours.toFixed(1),
|
|
className: 'cell-normal',
|
|
hours: hours,
|
|
tooltip: `${hours}h 근무`
|
|
};
|
|
}
|
|
|
|
function renderSummary() {
|
|
const summaryRow = document.getElementById('summaryRow');
|
|
|
|
// 전체 통계 계산
|
|
let totalWorkers = workers.length;
|
|
let totalWorkHours = 0;
|
|
let totalLeaveDays = 0;
|
|
let totalOvertimeDays = 0;
|
|
let totalNormalDays = 0;
|
|
|
|
workers.forEach(worker => {
|
|
const records = attendanceData[worker.worker_id] || {};
|
|
Object.values(records).forEach(record => {
|
|
const hours = parseFloat(record.total_work_hours) || 0;
|
|
totalWorkHours += hours;
|
|
|
|
if (record.attendance_type_id === 5 || record.attendance_type_id === 3) {
|
|
totalLeaveDays++;
|
|
} else if (hours > 8 || record.is_overtime_approved) {
|
|
totalOvertimeDays++;
|
|
} else if (hours === 8) {
|
|
totalNormalDays++;
|
|
}
|
|
});
|
|
});
|
|
|
|
summaryRow.innerHTML = `
|
|
<div class="summary-card">
|
|
<div class="summary-value">${totalWorkers}</div>
|
|
<div class="summary-label">전체 인원</div>
|
|
</div>
|
|
<div class="summary-card">
|
|
<div class="summary-value">${totalWorkHours.toFixed(0)}</div>
|
|
<div class="summary-label">총 근무시간</div>
|
|
</div>
|
|
<div class="summary-card">
|
|
<div class="summary-value" style="color: #16a34a;">${totalNormalDays}</div>
|
|
<div class="summary-label">정시근무</div>
|
|
</div>
|
|
<div class="summary-card">
|
|
<div class="summary-value" style="color: #2563eb;">${totalOvertimeDays}</div>
|
|
<div class="summary-label">연장근무</div>
|
|
</div>
|
|
<div class="summary-card">
|
|
<div class="summary-value" style="color: #ca8a04;">${totalLeaveDays}</div>
|
|
<div class="summary-label">휴가/조퇴</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// ===== 휴무일 설정 모달 =====
|
|
function openHolidayModal() {
|
|
renderHolidayList();
|
|
document.getElementById('holidayModal').classList.add('active');
|
|
|
|
// 새 휴무일 입력 필드 초기화
|
|
const monthStr = `${currentYear}-${String(currentMonth).padStart(2, '0')}`;
|
|
document.getElementById('newHolidayDate').value = '';
|
|
document.getElementById('newHolidayDate').min = `${monthStr}-01`;
|
|
document.getElementById('newHolidayDate').max = `${monthStr}-${new Date(currentYear, currentMonth, 0).getDate()}`;
|
|
document.getElementById('newHolidayName').value = '';
|
|
}
|
|
|
|
function closeHolidayModal() {
|
|
document.getElementById('holidayModal').classList.remove('active');
|
|
}
|
|
|
|
function renderHolidayList() {
|
|
const container = document.getElementById('holidayList');
|
|
const lastDay = new Date(currentYear, currentMonth, 0).getDate();
|
|
|
|
let html = '<h4 style="margin-bottom: 0.5rem; font-size: 0.875rem; color: #374151;">공휴일 (자동 적용)</h4>';
|
|
|
|
// 해당 월의 공휴일 표시
|
|
const yearHolidays = koreanHolidays[currentYear] || {};
|
|
const monthStr = String(currentMonth).padStart(2, '0');
|
|
|
|
let hasPublicHoliday = false;
|
|
Object.entries(yearHolidays).forEach(([monthDay, name]) => {
|
|
if (monthDay.startsWith(monthStr)) {
|
|
hasPublicHoliday = true;
|
|
html += `
|
|
<div class="holiday-item">
|
|
<div>
|
|
<span class="holiday-date">${currentMonth}/${monthDay.split('-')[1]}</span>
|
|
<span class="holiday-name">${name}</span>
|
|
</div>
|
|
<span style="color: #6b7280; font-size: 0.75rem;">공휴일</span>
|
|
</div>
|
|
`;
|
|
}
|
|
});
|
|
|
|
if (!hasPublicHoliday) {
|
|
html += '<p style="color: #9ca3af; font-size: 0.875rem; padding: 0.5rem;">이번 달 공휴일이 없습니다.</p>';
|
|
}
|
|
|
|
html += '<h4 style="margin: 1rem 0 0.5rem; font-size: 0.875rem; color: #374151;">회사 유급휴무</h4>';
|
|
|
|
// 해당 월의 회사 휴무일 표시
|
|
let hasCompanyHoliday = false;
|
|
Object.entries(companyHolidays).forEach(([dateStr, name]) => {
|
|
if (dateStr.startsWith(`${currentYear}-${monthStr}`)) {
|
|
hasCompanyHoliday = true;
|
|
const day = dateStr.split('-')[2];
|
|
html += `
|
|
<div class="holiday-item">
|
|
<div>
|
|
<span class="holiday-date">${currentMonth}/${day}</span>
|
|
<span class="holiday-name">${name}</span>
|
|
</div>
|
|
<button class="btn btn-sm" style="padding: 0.25rem 0.5rem; font-size: 0.75rem; background: #fee2e2; color: #991b1b; border: none;" onclick="removeCompanyHoliday('${dateStr}')">삭제</button>
|
|
</div>
|
|
`;
|
|
}
|
|
});
|
|
|
|
if (!hasCompanyHoliday) {
|
|
html += '<p style="color: #9ca3af; font-size: 0.875rem; padding: 0.5rem;">설정된 유급휴무가 없습니다.</p>';
|
|
}
|
|
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
function addCompanyHoliday() {
|
|
const dateInput = document.getElementById('newHolidayDate');
|
|
const nameInput = document.getElementById('newHolidayName');
|
|
|
|
const date = dateInput.value;
|
|
const name = nameInput.value.trim() || '유급휴무';
|
|
|
|
if (!date) {
|
|
alert('날짜를 선택해주세요.');
|
|
return;
|
|
}
|
|
|
|
companyHolidays[date] = name;
|
|
saveCompanyHolidays();
|
|
renderHolidayList();
|
|
|
|
// 입력 초기화
|
|
dateInput.value = '';
|
|
nameInput.value = '';
|
|
|
|
// 테이블 다시 렌더링
|
|
renderTable();
|
|
}
|
|
|
|
function removeCompanyHoliday(dateStr) {
|
|
delete companyHolidays[dateStr];
|
|
saveCompanyHolidays();
|
|
renderHolidayList();
|
|
renderTable();
|
|
}
|
|
|
|
// 모달 외부 클릭 시 닫기
|
|
document.getElementById('holidayModal').addEventListener('click', function(e) {
|
|
if (e.target === this) {
|
|
closeHolidayModal();
|
|
}
|
|
});
|
|
|
|
// ESC 키로 모달 닫기
|
|
document.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Escape') {
|
|
closeHolidayModal();
|
|
}
|
|
});
|
|
|
|
function exportToExcel() {
|
|
const table = document.getElementById('attendanceTable');
|
|
if (!table) {
|
|
alert('내보낼 데이터가 없습니다.');
|
|
return;
|
|
}
|
|
|
|
// 테이블을 CSV로 변환
|
|
let csv = '\uFEFF'; // BOM for Excel UTF-8
|
|
|
|
// 헤더
|
|
const headerRows = table.querySelectorAll('thead tr');
|
|
headerRows.forEach(row => {
|
|
const cells = row.querySelectorAll('th');
|
|
const rowData = [];
|
|
cells.forEach(cell => {
|
|
rowData.push(cell.textContent);
|
|
});
|
|
csv += rowData.join(',') + '\n';
|
|
});
|
|
|
|
// 바디
|
|
const bodyRows = table.querySelectorAll('tbody tr');
|
|
bodyRows.forEach(row => {
|
|
const cells = row.querySelectorAll('td');
|
|
const rowData = [];
|
|
cells.forEach(cell => {
|
|
let value = cell.textContent || cell.getAttribute('title') || '';
|
|
// 쉼표가 있으면 따옴표로 감싸기
|
|
if (value.includes(',')) {
|
|
value = `"${value}"`;
|
|
}
|
|
rowData.push(value);
|
|
});
|
|
csv += rowData.join(',') + '\n';
|
|
});
|
|
|
|
// 다운로드
|
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
|
const link = document.createElement('a');
|
|
link.href = URL.createObjectURL(blob);
|
|
link.download = `출근부_${currentYear}년${currentMonth}월.csv`;
|
|
link.click();
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|