Files
tk-factory-services/system1-factory/web/pages/attendance/monthly.html
Hyungi Ahn 4388628788 refactor: TBM/작업보고 코드 통합 및 API 쿼리 버그 수정
- 공통 유틸리티 추출 (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>
2026-03-05 07:51:24 +09:00

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()">&times;</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>