feat: 일일순회점검 시스템 구축 및 관리 기능 개선
- 일일순회점검 시스템 신규 구현 - DB 테이블: patrol_checklist_items, daily_patrol_sessions, patrol_check_records, workplace_items, item_types - API: /api/patrol/* 엔드포인트 - 프론트엔드: 지도 기반 작업장 점검 UI - 설비 관리 기능 개선 - 구매 관련 필드 추가 (구매일, 가격, 공급업체 등) - 설비 코드 자동 생성 (TKP-XXX 형식) - 작업장 관리 개선 - 레이아웃 이미지 업로드 기능 - 마커 위치 저장 기능 - 부서 관리 기능 추가 - 사이드바 네비게이션 카테고리 재구성 - 이미지 401 오류 수정 (정적 파일 경로 처리) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,10 +12,9 @@
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
|
||||
<!-- 스크립트 -->
|
||||
<script type="module" src="/js/api-config.js"></script>
|
||||
<script type="module" src="/js/auth-check.js" defer></script>
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<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>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0"></script>
|
||||
<script type="module" src="/js/annual-vacation-overview.js" defer></script>
|
||||
</head>
|
||||
|
||||
394
web-ui/pages/attendance/checkin.html
Normal file
394
web-ui/pages/attendance/checkin.html
Normal file
@@ -0,0 +1,394 @@
|
||||
<!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'),
|
||||
axios.get(`/attendance/checkin-list?date=${selectedDate}`).catch(() => ({ data: { data: [] } })),
|
||||
axios.get(`/attendance/daily-records?date=${selectedDate}`).catch(() => ({ data: { data: [] } }))
|
||||
]);
|
||||
|
||||
workers = (workersRes.data.data || []).filter(w => 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>
|
||||
@@ -7,8 +7,9 @@
|
||||
<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/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
@@ -55,8 +56,6 @@
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module">
|
||||
import '/js/api-config.js?v=3';
|
||||
</script>
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
<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/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<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>
|
||||
.calendar-container {
|
||||
margin-top: 2rem;
|
||||
@@ -186,8 +187,6 @@
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module">
|
||||
import '/js/api-config.js?v=3';
|
||||
</script>
|
||||
|
||||
@@ -12,10 +12,9 @@
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
|
||||
<!-- 스크립트 -->
|
||||
<script type="module" src="/js/api-config.js"></script>
|
||||
<script type="module" src="/js/auth-check.js" defer></script>
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<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>
|
||||
<script type="module" src="/js/vacation-allocation.js" defer></script>
|
||||
</head>
|
||||
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
<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/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<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>
|
||||
.tabs {
|
||||
display: flex;
|
||||
@@ -109,8 +110,6 @@
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script src="/js/vacation-common.js"></script>
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module">
|
||||
import '/js/api-config.js?v=3';
|
||||
</script>
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
<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/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
@@ -109,8 +110,6 @@
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script src="/js/vacation-common.js"></script>
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module">
|
||||
import '/js/api-config.js?v=3';
|
||||
</script>
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
<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/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<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>
|
||||
.tabs {
|
||||
display: flex;
|
||||
@@ -191,8 +192,6 @@
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script src="/js/vacation-common.js"></script>
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module">
|
||||
import '/js/api-config.js?v=3';
|
||||
</script>
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
<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/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
@@ -103,8 +104,6 @@
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script src="/js/vacation-common.js"></script>
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module">
|
||||
import '/js/api-config.js?v=3';
|
||||
</script>
|
||||
|
||||
657
web-ui/pages/attendance/work-status.html
Normal file
657
web-ui/pages/attendance/work-status.html
Normal file
@@ -0,0 +1,657 @@
|
||||
<!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: 1400px;
|
||||
}
|
||||
.summary-cards {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.summary-card {
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
padding: 1rem;
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
text-align: center;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
.summary-card.normal { border-left: 4px solid #10b981; }
|
||||
.summary-card.annual { border-left: 4px solid #3b82f6; }
|
||||
.summary-card.half { border-left: 4px solid #22c55e; }
|
||||
.summary-card.quarter { border-left: 4px solid #eab308; }
|
||||
.summary-card.early { border-left: 4px solid #ef4444; }
|
||||
.summary-card.overtime { border-left: 4px solid #f97316; }
|
||||
.summary-value { font-size: 1.5rem; font-weight: 700; }
|
||||
.summary-label { font-size: 0.75rem; color: #6b7280; }
|
||||
|
||||
.status-table {
|
||||
width: 100%;
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
.status-table th {
|
||||
background: #f8fafc;
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
}
|
||||
.status-table td {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.status-table tr:hover { background: #f8fafc; }
|
||||
.status-table tr.absent { background: #fef2f2; }
|
||||
|
||||
.worker-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.worker-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.worker-dot.present { background: #10b981; }
|
||||
.worker-dot.absent { background: #ef4444; }
|
||||
|
||||
.type-select {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
min-width: 110px;
|
||||
}
|
||||
.overtime-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.overtime-input {
|
||||
width: 60px;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.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-save {
|
||||
display: block;
|
||||
margin: 1.5rem auto 0;
|
||||
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; }
|
||||
.btn-save.saving { background: #6b7280; }
|
||||
.no-checkin-warning {
|
||||
background: #fef3c7;
|
||||
border: 1px solid #fcd34d;
|
||||
color: #92400e;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 저장 상태 섹션 */
|
||||
.save-section {
|
||||
text-align: center;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.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;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.status-badge.saved {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
.status-badge.unsaved {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
/* 토스트 알림 */
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
z-index: 9999;
|
||||
animation: slideIn 0.3s ease, fadeOut 0.3s ease 2.7s;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
}
|
||||
.toast.success { background: #10b981; }
|
||||
.toast.error { background: #ef4444; }
|
||||
.toast.info { background: #3b82f6; }
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
@keyframes fadeOut {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
|
||||
/* 저장 성공 오버레이 */
|
||||
.save-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(16, 185, 129, 0.9);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9998;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
.save-overlay .checkmark {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 1rem;
|
||||
animation: scaleIn 0.4s ease;
|
||||
}
|
||||
.save-overlay .checkmark svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
stroke: #10b981;
|
||||
stroke-width: 3;
|
||||
}
|
||||
.save-overlay .message {
|
||||
color: white;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.save-overlay .sub-message {
|
||||
color: rgba(255,255,255,0.9);
|
||||
font-size: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes scaleIn {
|
||||
from { transform: scale(0); }
|
||||
to { transform: scale(1); }
|
||||
}
|
||||
</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 style="font-size: 1.5rem; font-weight: 700; margin-bottom: 0.5rem;">근무 현황</h1>
|
||||
<p style="color: #64748b; margin-bottom: 1.5rem;">휴가/조퇴 및 연장근무를 입력합니다</p>
|
||||
|
||||
<div class="controls">
|
||||
<input type="date" id="selectedDate">
|
||||
<button class="btn btn-primary" onclick="loadWorkStatus()">새로고침</button>
|
||||
</div>
|
||||
|
||||
<div id="noCheckinWarning" class="no-checkin-warning" style="display:none;">
|
||||
출근 체크가 완료되지 않았습니다. 먼저 <a href="/pages/attendance/checkin.html">출근 체크</a>를 진행해주세요.
|
||||
</div>
|
||||
|
||||
<div class="summary-cards">
|
||||
<div class="summary-card normal">
|
||||
<div class="summary-value" id="normalCount">0</div>
|
||||
<div class="summary-label">정시근무</div>
|
||||
</div>
|
||||
<div class="summary-card annual">
|
||||
<div class="summary-value" id="annualCount">0</div>
|
||||
<div class="summary-label">연차</div>
|
||||
</div>
|
||||
<div class="summary-card half">
|
||||
<div class="summary-value" id="halfCount">0</div>
|
||||
<div class="summary-label">반차</div>
|
||||
</div>
|
||||
<div class="summary-card quarter">
|
||||
<div class="summary-value" id="quarterCount">0</div>
|
||||
<div class="summary-label">반반차</div>
|
||||
</div>
|
||||
<div class="summary-card early">
|
||||
<div class="summary-value" id="earlyCount">0</div>
|
||||
<div class="summary-label">조퇴</div>
|
||||
</div>
|
||||
<div class="summary-card overtime">
|
||||
<div class="summary-value" id="overtimeCount">0</div>
|
||||
<div class="summary-label">연장근로</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="status-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 130px;">작업자</th>
|
||||
<th style="width: 80px;">출근</th>
|
||||
<th style="width: 130px;">근태 구분</th>
|
||||
<th style="width: 100px;">근무시간</th>
|
||||
<th style="width: 150px;">연장근로</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="statusTableBody">
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="save-section">
|
||||
<div id="saveStatus"></div>
|
||||
<button id="saveBtn" class="btn-save" onclick="saveWorkStatus()">근무 현황 저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 토스트 컨테이너 -->
|
||||
<div id="toastContainer"></div>
|
||||
|
||||
<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 workStatus = {};
|
||||
let hasCheckinData = false;
|
||||
let isAlreadySaved = false;
|
||||
let isSaving = false;
|
||||
|
||||
// 근태 구분 옵션
|
||||
const attendanceTypes = [
|
||||
{ value: 'normal', label: '정시근무', hours: 8 },
|
||||
{ value: 'annual', label: '연차', hours: 0 },
|
||||
{ value: 'half', label: '반차', hours: 4 },
|
||||
{ value: 'quarter', label: '반반차', hours: 6 },
|
||||
{ value: 'early', label: '조퇴', hours: 2 },
|
||||
{ value: 'overtime', label: '연장근로', hours: 8 }
|
||||
];
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadWorkStatus() {
|
||||
const selectedDate = document.getElementById('selectedDate').value;
|
||||
if (!selectedDate) return alert('날짜를 선택해주세요.');
|
||||
|
||||
try {
|
||||
const [workersRes, recordsRes] = await Promise.all([
|
||||
axios.get('/workers'),
|
||||
axios.get(`/attendance/daily-records?date=${selectedDate}`).catch(() => ({ data: { data: [] } }))
|
||||
]);
|
||||
|
||||
workers = (workersRes.data.data || []).filter(w => w.employment_status === 'employed');
|
||||
const records = recordsRes.data.data || [];
|
||||
|
||||
// 출근 체크 데이터가 있는지 확인
|
||||
hasCheckinData = records.length > 0;
|
||||
// 이미 저장된 근무 현황이 있는지 확인 (attendance_type_id가 설정된 경우)
|
||||
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.worker_id === w.worker_id);
|
||||
|
||||
if (record) {
|
||||
// 기존 데이터 기반으로 설정
|
||||
let type = 'normal';
|
||||
let overtimeHours = 0;
|
||||
|
||||
// is_present가 0이면 결근 → 연차로 기본 설정
|
||||
if (record.is_present === 0) {
|
||||
type = 'annual';
|
||||
} else {
|
||||
// 기존 저장된 타입이 있으면 사용
|
||||
if (record.attendance_type_code) {
|
||||
const codeMap = {
|
||||
'NORMAL': 'normal',
|
||||
'VACATION': 'annual',
|
||||
'HALF_LEAVE': 'half',
|
||||
'QUARTER_LEAVE': 'quarter',
|
||||
'EARLY_LEAVE': 'early'
|
||||
};
|
||||
type = codeMap[record.attendance_type_code] || 'normal';
|
||||
}
|
||||
// 연장근로 시간이 있으면 연장근로 타입으로
|
||||
if (record.total_work_hours > 8) {
|
||||
type = 'overtime';
|
||||
overtimeHours = record.total_work_hours - 8;
|
||||
}
|
||||
}
|
||||
|
||||
workStatus[w.worker_id] = {
|
||||
isPresent: record.is_present === 1,
|
||||
type: type,
|
||||
hours: attendanceTypes.find(t => t.value === type)?.hours || 8,
|
||||
overtimeHours: overtimeHours
|
||||
};
|
||||
} else {
|
||||
// 데이터 없으면 기본값 (출근, 정시근무)
|
||||
workStatus[w.worker_id] = {
|
||||
isPresent: true,
|
||||
type: 'normal',
|
||||
hours: 8,
|
||||
overtimeHours: 0
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
render();
|
||||
updateSummary();
|
||||
updateSaveStatus();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast('데이터 로드 실패', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
const tbody = document.getElementById('statusTableBody');
|
||||
|
||||
if (workers.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;padding:2rem;color:#6b7280;">작업자가 없습니다</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = workers.map(w => {
|
||||
const s = workStatus[w.worker_id];
|
||||
const isAbsent = !s.isPresent;
|
||||
const showOvertimeInput = s.type === 'overtime';
|
||||
|
||||
return `
|
||||
<tr class="${isAbsent ? 'absent' : ''}">
|
||||
<td>
|
||||
<div class="worker-cell">
|
||||
<span class="worker-dot ${s.isPresent ? 'present' : 'absent'}"></span>
|
||||
<span>${w.worker_name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>${s.isPresent ? '<span style="color:#10b981">출근</span>' : '<span style="color:#ef4444">결근</span>'}</td>
|
||||
<td>
|
||||
<select class="type-select" onchange="updateType(${w.worker_id}, this.value)">
|
||||
${attendanceTypes.map(t => `
|
||||
<option value="${t.value}" ${s.type === t.value ? 'selected' : ''}>${t.label}</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</td>
|
||||
<td>${s.type === 'overtime' ? (s.hours + s.overtimeHours) : s.hours}시간</td>
|
||||
<td>
|
||||
${showOvertimeInput ? `
|
||||
<div class="overtime-group">
|
||||
<input type="number" class="overtime-input" value="${s.overtimeHours}" min="0" max="8" step="0.5"
|
||||
onchange="updateOvertime(${w.worker_id}, this.value)">
|
||||
<span style="color:#6b7280;font-size:0.875rem;">시간</span>
|
||||
</div>
|
||||
` : '<span style="color:#9ca3af;">-</span>'}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function updateType(workerId, value) {
|
||||
const type = attendanceTypes.find(t => t.value === value);
|
||||
workStatus[workerId].type = value;
|
||||
workStatus[workerId].hours = type ? type.hours : 8;
|
||||
|
||||
// 연장근로 선택 시 기본 2시간
|
||||
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 updateSummary() {
|
||||
let normal = 0, annual = 0, half = 0, quarter = 0, early = 0, overtime = 0;
|
||||
|
||||
Object.values(workStatus).forEach(s => {
|
||||
switch (s.type) {
|
||||
case 'normal': 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;
|
||||
}
|
||||
|
||||
// 토스트 알림 표시
|
||||
function showToast(message, type = 'info') {
|
||||
const container = document.getElementById('toastContainer');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type}`;
|
||||
toast.textContent = message;
|
||||
container.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 3000);
|
||||
}
|
||||
|
||||
// 저장 성공 오버레이 표시
|
||||
function showSaveOverlay(count) {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'save-overlay';
|
||||
overlay.id = 'saveOverlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="checkmark">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<path d="M5 13l4 4L19 7" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="message">저장 완료!</div>
|
||||
<div class="sub-message">${count}명의 근무 현황이 저장되었습니다</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
setTimeout(() => {
|
||||
overlay.style.opacity = '0';
|
||||
overlay.style.transition = 'opacity 0.3s ease';
|
||||
setTimeout(() => overlay.remove(), 300);
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
// 저장 상태 업데이트
|
||||
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 saveWorkStatus() {
|
||||
const date = document.getElementById('selectedDate').value;
|
||||
if (!date) return showToast('날짜를 선택해주세요.', 'error');
|
||||
|
||||
if (isSaving) return;
|
||||
|
||||
const saveBtn = document.getElementById('saveBtn');
|
||||
|
||||
// attendance_type_id 매핑 (DB의 work_attendance_types 테이블)
|
||||
const typeIdMap = {
|
||||
'normal': 1, // NORMAL
|
||||
'annual': 5, // VACATION
|
||||
'half': 5, // VACATION (반차)
|
||||
'quarter': 5, // VACATION (반반차)
|
||||
'early': 3, // EARLY_LEAVE
|
||||
'overtime': 1 // NORMAL (연장근로는 정상출근 + 추가시간)
|
||||
};
|
||||
|
||||
// vacation_type_id 매핑 (필요한 경우)
|
||||
const vacationTypeIdMap = {
|
||||
'annual': 1, // ANNUAL
|
||||
'half': 2, // HALF_ANNUAL
|
||||
'quarter': null, // 반반차는 별도 처리 필요할 수 있음
|
||||
};
|
||||
|
||||
const recordsToSave = workers.map(w => {
|
||||
const s = workStatus[w.worker_id];
|
||||
const totalHours = s.type === 'overtime' ? s.hours + s.overtimeHours : s.hours;
|
||||
|
||||
return {
|
||||
record_date: date,
|
||||
worker_id: w.worker_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.classList.add('saving');
|
||||
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) {
|
||||
// 성공 - 오버레이 표시
|
||||
showSaveOverlay(ok);
|
||||
isAlreadySaved = true;
|
||||
updateSaveStatus();
|
||||
} else if (ok > 0) {
|
||||
showToast(`${ok}명 성공, ${fail}명 실패`, 'error');
|
||||
} else {
|
||||
showToast('저장에 실패했습니다', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast('저장 중 오류가 발생했습니다', 'error');
|
||||
} finally {
|
||||
isSaving = false;
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.classList.remove('saving');
|
||||
updateSaveStatus();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user