feat: 대시보드 작업장 현황 지도 구현

- 실시간 작업장 현황을 지도로 시각화
- 작업장 관리 페이지에서 정의한 구역 정보 활용
- TBM 작업자 및 방문자 현황 표시

주요 변경사항:
- dashboard.html: 작업장 현황 섹션 추가 (기존 작업 현황 테이블 제거)
- workplace-status.js: 지도 렌더링 및 데이터 통합 로직 구현
- modern-dashboard.js: 삭제된 DOM 요소 조건부 체크 추가

시각화 방식:
- 인원 없음: 회색 테두리 + 작업장 이름
- 내부 작업자: 파란색 영역 + 인원 수
- 외부 방문자: 보라색 영역 + 인원 수
- 둘 다: 초록색 영역 + 총 인원 수

기술 구현:
- Canvas API 기반 사각형 영역 렌더링
- map-regions API를 통한 데이터 일관성 보장
- 클릭 이벤트로 상세 정보 모달 표시

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-01-29 15:46:47 +09:00
parent e1227a69fe
commit b6485e3140
87 changed files with 17509 additions and 698 deletions

View File

@@ -116,8 +116,7 @@
<select id="userRole" class="form-control" required>
<option value="">역할 선택</option>
<option value="admin">관리자</option>
<option value="leader">그룹장</option>
<option value="user">작업자</option>
<option value="user">사용자</option>
</select>
</div>
@@ -130,6 +129,15 @@
<label class="form-label">전화번호</label>
<input type="tel" id="userPhone" class="form-control">
</div>
<!-- 페이지 권한 설정 (사용자 편집 시에만 표시) -->
<div class="form-group" id="pageAccessGroup" style="display: none;">
<label class="form-label">페이지 접근 권한</label>
<small class="form-help">관리자는 모든 페이지에 자동으로 접근 가능합니다</small>
<div id="pageAccessList" class="page-access-list">
<!-- 페이지 체크박스 목록이 동적으로 생성됩니다 -->
</div>
</div>
</form>
</div>
@@ -163,12 +171,45 @@
</div>
</div>
<!-- 페이지 권한 관리 모달 -->
<div id="pageAccessModal" class="modal-overlay" style="display: none;">
<div class="modal-container">
<div class="modal-header">
<h2 id="pageAccessModalTitle">페이지 권한 관리</h2>
<button class="modal-close-btn" onclick="closePageAccessModal()">×</button>
</div>
<div class="modal-body">
<div class="page-access-user-info">
<div class="user-avatar-small" id="pageAccessUserAvatar">U</div>
<div>
<h3 id="pageAccessUserName">사용자</h3>
<p id="pageAccessUserRole">역할</p>
</div>
</div>
<div class="form-group">
<label class="form-label">접근 가능한 페이지</label>
<small class="form-help">체크된 페이지에만 접근할 수 있습니다</small>
<div id="pageAccessModalList" class="page-access-list">
<!-- 페이지 체크박스 목록 -->
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closePageAccessModal()">취소</button>
<button type="button" class="btn btn-primary" id="savePageAccessBtn">저장</button>
</div>
</div>
</div>
<!-- 토스트 알림 -->
<div class="toast-container" id="toastContainer"></div>
<!-- JavaScript -->
<script type="module" src="/js/api-config.js?v=13"></script>
<script type="module" src="/js/load-navbar.js?v=4"></script>
<script src="/js/admin-settings.js?v=5"></script>
<script src="/js/admin-settings.js?v=8"></script>
</body>
</html>

View File

@@ -0,0 +1,493 @@
<!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/auth-check.js?v=1" defer></script>
<script type="module" src="/js/api-config.js?v=3"></script>
<style>
.comparison-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
margin-bottom: 2rem;
}
.comparison-card {
background: white;
padding: 1.5rem;
border-radius: 0.5rem;
border: 1px solid #e5e7eb;
}
.comparison-title {
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 1rem;
color: #111827;
}
.discrepancy-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 0.25rem;
font-size: 0.875rem;
font-weight: 500;
}
.badge-match {
background-color: #d1fae5;
color: #065f46;
}
.badge-mismatch {
background-color: #fee2e2;
color: #991b1b;
}
.badge-missing-attendance {
background-color: #fef3c7;
color: #92400e;
}
.badge-missing-report {
background-color: #dbeafe;
color: #1e40af;
}
.filter-section {
display: flex;
gap: 1rem;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.summary-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
padding: 1rem;
border-radius: 0.5rem;
border: 1px solid #e5e7eb;
text-align: center;
}
.stat-label {
font-size: 0.875rem;
color: #6b7280;
margin-bottom: 0.25rem;
}
.stat-value {
font-size: 1.75rem;
font-weight: 600;
color: #111827;
}
.detail-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
padding: 0.5rem;
border-bottom: 1px solid #e5e7eb;
}
.detail-label {
font-weight: 500;
color: #6b7280;
}
.detail-value {
color: #111827;
}
</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">
<span class="title-icon">🔍</span>
출퇴근-작업보고서 대조
</h1>
<p class="page-description">출퇴근 기록과 작업보고서를 비교 분석합니다</p>
</div>
<div class="page-actions">
<button class="btn btn-primary" onclick="loadComparisonData()">
<span>🔄 새로고침</span>
</button>
</div>
</div>
<!-- 필터 섹션 -->
<div class="content-section">
<div class="card">
<div class="card-body">
<div class="filter-section">
<div style="flex: 1; min-width: 200px;">
<label for="startDate">시작일</label>
<input type="date" id="startDate" class="form-control">
</div>
<div style="flex: 1; min-width: 200px;">
<label for="endDate">종료일</label>
<input type="date" id="endDate" class="form-control">
</div>
<div style="flex: 1; min-width: 200px;">
<label for="workerFilter">작업자</label>
<select id="workerFilter" class="form-control">
<option value="">전체</option>
</select>
</div>
<div style="flex: 1; min-width: 200px;">
<label for="statusFilter">상태</label>
<select id="statusFilter" class="form-control">
<option value="">전체</option>
<option value="match">일치</option>
<option value="mismatch">불일치</option>
<option value="missing-attendance">출퇴근 누락</option>
<option value="missing-report">보고서 누락</option>
</select>
</div>
<div style="align-self: flex-end;">
<button class="btn btn-primary" onclick="loadComparisonData()">
<span>🔍 조회</span>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- 요약 통계 -->
<div class="content-section">
<div class="summary-stats" id="summaryStats">
<!-- 요약 통계가 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
<!-- 대조 결과 -->
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">대조 결과</h2>
<p class="text-muted">출퇴근 기록과 작업보고서의 시간을 비교합니다</p>
</div>
<div class="card-body">
<div id="comparisonList" class="data-table-container">
<!-- 대조 결과가 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
</div>
</div>
</div>
</main>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script type="module" src="/js/load-navbar.js?v=5"></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('token');
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
axios.interceptors.request.use(
config => {
const token = localStorage.getItem('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 comparisonData = [];
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', async () => {
await waitForAxiosConfig();
initializePage();
});
function waitForAxiosConfig() {
return new Promise((resolve) => {
const check = setInterval(() => {
if (axios.defaults.baseURL) {
clearInterval(check);
resolve();
}
}, 50);
setTimeout(() => {
clearInterval(check);
resolve();
}, 5000);
});
}
async function initializePage() {
// 기본 날짜 설정 (이번 주)
const today = new Date();
const weekAgo = new Date(today);
weekAgo.setDate(today.getDate() - 7);
document.getElementById('startDate').value = weekAgo.toISOString().split('T')[0];
document.getElementById('endDate').value = today.toISOString().split('T')[0];
try {
await loadWorkers();
await loadComparisonData();
} catch (error) {
console.error('초기화 오류:', error);
alert('페이지 초기화 중 오류가 발생했습니다.');
}
}
async function loadWorkers() {
try {
const response = await axios.get('/workers');
if (response.data.success) {
workers = response.data.data.filter(w => w.employment_status === 'employed');
const select = document.getElementById('workerFilter');
workers.forEach(worker => {
const option = document.createElement('option');
option.value = worker.worker_id;
option.textContent = worker.worker_name;
select.appendChild(option);
});
}
} catch (error) {
console.error('작업자 목록 로드 오류:', error);
}
}
async function loadComparisonData() {
const startDate = document.getElementById('startDate').value;
const endDate = document.getElementById('endDate').value;
const workerId = document.getElementById('workerFilter').value;
if (!startDate || !endDate) {
alert('시작일과 종료일을 선택해주세요.');
return;
}
try {
// 출퇴근 기록 로드
const attendanceResponse = await axios.get('/attendance/records', {
params: {
start_date: startDate,
end_date: endDate,
worker_id: workerId || undefined
}
});
// 작업 보고서 로드
const reportsResponse = await axios.get('/daily-work-reports', {
params: {
start_date: startDate,
end_date: endDate,
worker_id: workerId || undefined
}
});
const attendanceRecords = attendanceResponse.data.success ? attendanceResponse.data.data : [];
const workReports = reportsResponse.data.success ? reportsResponse.data.data : [];
// 데이터 비교
comparisonData = compareData(attendanceRecords, workReports);
// 필터 적용
const statusFilter = document.getElementById('statusFilter').value;
if (statusFilter) {
comparisonData = comparisonData.filter(item => item.status === statusFilter);
}
renderSummary();
renderComparisonList();
} catch (error) {
console.error('데이터 로드 오류:', error);
document.getElementById('comparisonList').innerHTML = `
<div class="empty-state">
<p>데이터를 불러오는 중 오류가 발생했습니다.</p>
</div>
`;
}
}
function compareData(attendanceRecords, workReports) {
const results = [];
const dateWorkerMap = new Map();
// 출퇴근 기록 맵핑
attendanceRecords.forEach(record => {
const key = `${record.attendance_date}_${record.worker_id}`;
dateWorkerMap.set(key, {
date: record.attendance_date,
worker_id: record.worker_id,
worker_name: record.worker_name,
attendance: record,
reports: []
});
});
// 작업 보고서 맵핑
workReports.forEach(report => {
const key = `${report.report_date}_${report.worker_id}`;
if (dateWorkerMap.has(key)) {
dateWorkerMap.get(key).reports.push(report);
} else {
dateWorkerMap.set(key, {
date: report.report_date,
worker_id: report.worker_id,
worker_name: report.worker_name,
attendance: null,
reports: [report]
});
}
});
// 비교 분석
dateWorkerMap.forEach(item => {
const attendanceHours = item.attendance?.total_hours || 0;
const reportTotalHours = item.reports.reduce((sum, r) => sum + (r.total_hours || 0), 0);
let status = 'match';
let message = '일치';
if (!item.attendance && item.reports.length > 0) {
status = 'missing-attendance';
message = '출퇴근 기록 누락';
} else if (item.attendance && item.reports.length === 0) {
status = 'missing-report';
message = '작업보고서 누락';
} else if (Math.abs(attendanceHours - reportTotalHours) > 0.5) {
status = 'mismatch';
message = `시간 불일치 (차이: ${Math.abs(attendanceHours - reportTotalHours).toFixed(1)}시간)`;
}
results.push({
...item,
attendanceHours,
reportTotalHours,
status,
message
});
});
// 날짜 역순 정렬
return results.sort((a, b) => b.date.localeCompare(a.date));
}
function renderSummary() {
const summaryStats = document.getElementById('summaryStats');
const total = comparisonData.length;
const matches = comparisonData.filter(item => item.status === 'match').length;
const mismatches = comparisonData.filter(item => item.status === 'mismatch').length;
const missingAttendance = comparisonData.filter(item => item.status === 'missing-attendance').length;
const missingReport = comparisonData.filter(item => item.status === 'missing-report').length;
summaryStats.innerHTML = `
<div class="stat-card">
<div class="stat-label">전체</div>
<div class="stat-value">${total}</div>
</div>
<div class="stat-card">
<div class="stat-label">일치</div>
<div class="stat-value" style="color: #059669;">${matches}</div>
</div>
<div class="stat-card">
<div class="stat-label">불일치</div>
<div class="stat-value" style="color: #dc2626;">${mismatches}</div>
</div>
<div class="stat-card">
<div class="stat-label">출퇴근 누락</div>
<div class="stat-value" style="color: #d97706;">${missingAttendance}</div>
</div>
<div class="stat-card">
<div class="stat-label">보고서 누락</div>
<div class="stat-value" style="color: #2563eb;">${missingReport}</div>
</div>
`;
}
function renderComparisonList() {
const container = document.getElementById('comparisonList');
if (comparisonData.length === 0) {
container.innerHTML = `
<div class="empty-state">
<p>비교 결과가 없습니다.</p>
</div>
`;
return;
}
const tableHTML = `
<table class="data-table">
<thead>
<tr>
<th>날짜</th>
<th>작업자</th>
<th>출퇴근 시간</th>
<th>보고서 시간</th>
<th>차이</th>
<th>상태</th>
</tr>
</thead>
<tbody>
${comparisonData.map(item => {
const diff = Math.abs(item.attendanceHours - item.reportTotalHours);
const badgeClass = `badge-${item.status}`;
return `
<tr>
<td>${item.date}</td>
<td><strong>${item.worker_name}</strong></td>
<td>${item.attendanceHours.toFixed(1)}시간</td>
<td>${item.reportTotalHours.toFixed(1)}시간</td>
<td>${diff.toFixed(1)}시간</td>
<td>
<span class="discrepancy-badge ${badgeClass}">
${item.message}
</span>
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
`;
container.innerHTML = tableHTML;
}
</script>
</body>
</html>

View File

@@ -41,6 +41,12 @@
<span class="menu-text">작업장 관리</span>
</a>
</li>
<li class="menu-item">
<a href="/pages/admin/equipments.html">
<span class="menu-icon">⚙️</span>
<span class="menu-text">설비 관리</span>
</a>
</li>
<li class="menu-item">
<a href="/pages/admin/tasks.html">
<span class="menu-icon">📋</span>

View File

@@ -213,7 +213,60 @@
</div>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="/js/navbar-loader.js?v=5"></script>
<script src="/js/equipment-management.js?v=1"></script>
<script type="module" src="/js/load-navbar.js?v=5"></script>
<script type="module">
// API 설정 먼저 로드
import '/js/api-config.js?v=3';
</script>
<script>
// axios 기본 설정
(function() {
// api-config.js가 로드될 때까지 대기
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}`;
}
// axios 요청 인터셉터 추가 (모든 요청에 토큰 자동 추가)
axios.interceptors.request.use(
config => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
console.log('📤 요청:', config.method.toUpperCase(), config.url);
return config;
},
error => {
return Promise.reject(error);
}
);
// axios 응답 인터셉터 추가 (에러 처리)
axios.interceptors.response.use(
response => {
console.log('✅ 응답:', response.status, response.config.url);
return response;
},
error => {
console.error('❌ 에러:', error.response?.status, error.config?.url, error.message);
if (error.response?.status === 401) {
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
window.location.href = '/pages/login.html';
}
return Promise.reject(error);
}
);
console.log('✅ Axios 설정 완료:', axios.defaults.baseURL);
}
}, 50);
})();
</script>
<script src="/js/equipment-management.js?v=4"></script>
</body>
</html>

View File

@@ -40,6 +40,12 @@
<span class="menu-text">작업장 관리</span>
</a>
</li>
<li class="menu-item">
<a href="/pages/admin/equipments.html">
<span class="menu-icon">⚙️</span>
<span class="menu-text">설비 관리</span>
</a>
</li>
<li class="menu-item">
<a href="/pages/admin/tasks.html">
<span class="menu-icon">📋</span>

View File

@@ -0,0 +1,291 @@
<!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/common.css?v=2">
<link rel="stylesheet" href="/css/project-management.css?v=3">
<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>
<style>
.status-tabs {
display: flex;
gap: 8px;
margin-bottom: 24px;
border-bottom: 2px solid var(--gray-200);
}
.status-tab {
padding: 12px 24px;
background: none;
border: none;
border-bottom: 3px solid transparent;
cursor: pointer;
font-weight: 600;
color: var(--gray-600);
transition: all var(--transition-fast);
}
.status-tab:hover {
color: var(--primary-600);
}
.status-tab.active {
color: var(--primary-600);
border-bottom-color: var(--primary-600);
}
.request-table {
width: 100%;
border-collapse: collapse;
background: white;
border-radius: var(--radius-md);
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.request-table th {
background: var(--gray-50);
padding: 12px;
text-align: left;
font-weight: 600;
color: var(--gray-700);
border-bottom: 2px solid var(--gray-200);
}
.request-table td {
padding: 12px;
border-bottom: 1px solid var(--gray-200);
}
.request-table tr:hover {
background: var(--gray-50);
}
.status-badge {
padding: 4px 12px;
border-radius: var(--radius-full);
font-size: var(--text-sm);
font-weight: 600;
}
.status-badge.pending {
background: var(--yellow-100);
color: var(--yellow-700);
}
.status-badge.approved {
background: var(--green-100);
color: var(--green-700);
}
.status-badge.rejected {
background: var(--red-100);
color: var(--red-700);
}
.status-badge.training_completed {
background: var(--blue-100);
color: var(--blue-700);
}
.action-buttons {
display: flex;
gap: 8px;
}
.btn-sm {
padding: 6px 12px;
font-size: var(--text-sm);
}
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal-content {
background: white;
border-radius: var(--radius-lg);
padding: 32px;
max-width: 600px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
box-shadow: var(--shadow-2xl);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.modal-header h2 {
margin: 0;
}
.detail-grid {
display: grid;
grid-template-columns: 120px 1fr;
gap: 12px;
margin-bottom: 16px;
}
.detail-label {
font-weight: 600;
color: var(--gray-600);
}
.detail-value {
color: var(--gray-900);
}
.empty-state {
text-align: center;
padding: 64px 32px;
color: var(--gray-500);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 32px;
}
.stat-card {
padding: 20px;
background: white;
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
border-left: 4px solid var(--primary-500);
}
.stat-label {
font-size: var(--text-sm);
color: var(--gray-600);
margin-bottom: 8px;
}
.stat-value {
font-size: var(--text-3xl);
font-weight: 700;
color: var(--gray-900);
}
</style>
</head>
<body>
<div class="work-report-container">
<!-- 네비게이션 바 -->
<div id="navbar-container"></div>
<!-- 메인 콘텐츠 -->
<main class="work-report-main">
<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>
<!-- 통계 -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">승인 대기</div>
<div class="stat-value" id="statPending">0</div>
</div>
<div class="stat-card" style="border-left-color: var(--green-500);">
<div class="stat-label">승인 완료</div>
<div class="stat-value" id="statApproved">0</div>
</div>
<div class="stat-card" style="border-left-color: var(--blue-500);">
<div class="stat-label">교육 완료</div>
<div class="stat-value" id="statTrainingCompleted">0</div>
</div>
<div class="stat-card" style="border-left-color: var(--red-500);">
<div class="stat-label">반려</div>
<div class="stat-value" id="statRejected">0</div>
</div>
</div>
<!---->
<div class="code-section">
<div class="status-tabs">
<button class="status-tab active" data-status="pending" onclick="switchTab('pending')">
승인 대기
</button>
<button class="status-tab" data-status="approved" onclick="switchTab('approved')">
승인 완료
</button>
<button class="status-tab" data-status="training_completed" onclick="switchTab('training_completed')">
교육 완료
</button>
<button class="status-tab" data-status="rejected" onclick="switchTab('rejected')">
반려
</button>
<button class="status-tab" data-status="all" onclick="switchTab('all')">
전체
</button>
</div>
<!-- 테이블 -->
<div id="requestTableContainer">
<!-- 동적으로 로드됨 -->
</div>
</div>
</div>
</main>
</div>
<!-- 상세보기 모달 -->
<div id="detailModal" class="modal-overlay">
<div class="modal-content">
<div class="modal-header">
<h2>출입 신청 상세</h2>
<button class="btn btn-secondary btn-sm" onclick="closeDetailModal()">닫기</button>
</div>
<div id="detailContent">
<!-- 동적으로 로드됨 -->
</div>
</div>
</div>
<!-- 반려 사유 입력 모달 -->
<div id="rejectModal" class="modal-overlay">
<div class="modal-content">
<div class="modal-header">
<h2>반려 사유 입력</h2>
<button class="btn btn-secondary btn-sm" onclick="closeRejectModal()">취소</button>
</div>
<div>
<div class="form-group">
<label for="rejectionReason">반려 사유 *</label>
<textarea id="rejectionReason" rows="4" style="width: 100%; padding: 12px; border: 1px solid var(--gray-300); border-radius: var(--radius-md);" placeholder="반려 사유를 입력하세요"></textarea>
</div>
<div style="display: flex; justify-content: flex-end; gap: 12px; margin-top: 24px;">
<button class="btn btn-secondary" onclick="closeRejectModal()">취소</button>
<button class="btn btn-danger" onclick="confirmReject()">반려 확정</button>
</div>
</div>
</div>
</div>
<!-- Scripts -->
<script type="module" src="/js/load-navbar.js?v=5"></script>
<script src="/js/safety-management.js"></script>
</body>
</html>

View File

@@ -0,0 +1,327 @@
<!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/common.css?v=2">
<link rel="stylesheet" href="/css/project-management.css?v=3">
<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>
<style>
.training-container {
max-width: 1000px;
margin: 0 auto;
}
.request-info-card {
background: white;
border-radius: var(--radius-lg);
padding: 24px;
margin-bottom: 24px;
box-shadow: var(--shadow-sm);
border-left: 4px solid var(--primary-500);
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 16px;
}
.info-item {
display: flex;
flex-direction: column;
}
.info-label {
font-size: var(--text-sm);
color: var(--gray-600);
margin-bottom: 4px;
}
.info-value {
font-size: var(--text-base);
font-weight: 600;
color: var(--gray-900);
}
.checklist-section {
background: white;
border-radius: var(--radius-lg);
padding: 24px;
margin-bottom: 24px;
box-shadow: var(--shadow-sm);
}
.checklist-item {
display: flex;
align-items: center;
padding: 16px;
margin-bottom: 12px;
background: var(--gray-50);
border-radius: var(--radius-md);
border: 2px solid transparent;
transition: all var(--transition-fast);
}
.checklist-item:hover {
border-color: var(--primary-300);
background: var(--primary-50);
}
.checklist-item input[type="checkbox"] {
width: 24px;
height: 24px;
margin-right: 16px;
cursor: pointer;
}
.checklist-item label {
flex: 1;
font-size: var(--text-base);
cursor: pointer;
user-select: none;
}
.checklist-item input[type="checkbox"]:checked + label {
color: var(--gray-500);
text-decoration: line-through;
}
.signature-section {
background: white;
border-radius: var(--radius-lg);
padding: 24px;
margin-bottom: 24px;
box-shadow: var(--shadow-sm);
}
.signature-canvas-container {
border: 2px solid var(--gray-300);
border-radius: var(--radius-md);
background: white;
margin-top: 16px;
position: relative;
touch-action: none;
}
.signature-canvas {
display: block;
cursor: crosshair;
touch-action: none;
}
.signature-actions {
display: flex;
justify-content: space-between;
margin-top: 12px;
}
.signature-placeholder {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--gray-400);
font-size: var(--text-base);
pointer-events: none;
}
.form-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.warning-box {
background: var(--yellow-50);
border: 2px solid var(--yellow-300);
border-radius: var(--radius-md);
padding: 16px;
margin-bottom: 24px;
display: flex;
align-items: start;
gap: 12px;
}
.warning-icon {
font-size: 24px;
}
.warning-text {
flex: 1;
color: var(--yellow-800);
font-size: var(--text-sm);
}
.saved-signature-card {
background: var(--gray-50);
border: 2px solid var(--gray-300);
border-radius: var(--radius-md);
padding: 16px;
margin-bottom: 12px;
display: flex;
gap: 16px;
align-items: center;
}
.saved-signature-card img {
max-width: 300px;
height: auto;
border: 1px solid var(--gray-300);
border-radius: var(--radius-sm);
background: white;
}
.saved-signature-info {
flex: 1;
}
.saved-signature-number {
font-size: var(--text-lg);
font-weight: 700;
color: var(--primary-600);
margin-bottom: 8px;
}
.saved-signature-date {
font-size: var(--text-sm);
color: var(--gray-600);
}
.saved-signature-actions {
display: flex;
gap: 8px;
}
</style>
</head>
<body>
<div class="work-report-container">
<!-- 네비게이션 바 -->
<div id="navbar-container"></div>
<!-- 메인 콘텐츠 -->
<main class="work-report-main">
<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>
<div class="training-container">
<!-- 출입 신청 정보 -->
<div class="request-info-card">
<h2 class="section-title" style="margin-bottom: 16px;">출입 신청 정보</h2>
<div id="requestInfo" class="info-grid">
<!-- 동적으로 로드됨 -->
</div>
</div>
<!-- 안전교육 체크리스트 -->
<div class="checklist-section">
<h2 class="section-title" style="margin-bottom: 16px;">안전교육 체크리스트</h2>
<p style="color: var(--gray-600); margin-bottom: 20px;">
방문자에게 다음 안전 사항을 교육하고 체크해주세요.
</p>
<div id="checklistContainer">
<div class="checklist-item">
<input type="checkbox" id="check1" name="safety-check" value="개인보호구 착용" onchange="updateCompleteButton()">
<label for="check1">개인보호구(안전모, 안전화, 안전복) 착용 방법 교육</label>
</div>
<div class="checklist-item">
<input type="checkbox" id="check2" name="safety-check" value="작업장 위험요소" onchange="updateCompleteButton()">
<label for="check2">작업장 내 위험요소 및 주의사항 안내</label>
</div>
<div class="checklist-item">
<input type="checkbox" id="check3" name="safety-check" value="비상대피로" onchange="updateCompleteButton()">
<label for="check3">비상대피로 및 비상연락망 안내</label>
</div>
<div class="checklist-item">
<input type="checkbox" id="check4" name="safety-check" value="출입통제구역" onchange="updateCompleteButton()">
<label for="check4">출입통제구역 및 금지사항 안내</label>
</div>
<div class="checklist-item">
<input type="checkbox" id="check5" name="safety-check" value="사고발생시 대응" onchange="updateCompleteButton()">
<label for="check5">사고 발생 시 대응 절차 교육</label>
</div>
<div class="checklist-item">
<input type="checkbox" id="check6" name="safety-check" value="안전수칙 준수" onchange="updateCompleteButton()">
<label for="check6">현장 안전수칙 준수 서약</label>
</div>
</div>
</div>
<!-- 경고 -->
<div class="warning-box">
<div class="warning-icon">⚠️</div>
<div class="warning-text">
<strong>중요:</strong> 모든 체크리스트 항목을 완료하고 방문자의 서명을 받은 후 교육 완료 처리를 해주세요.
교육 완료 후에는 수정할 수 없습니다.
</div>
</div>
<!-- 서명 섹션 -->
<div class="signature-section">
<h2 class="section-title" style="margin-bottom: 16px;">방문자 서명 (<span id="signatureCount">0</span>명)</h2>
<p style="color: var(--gray-600); margin-bottom: 20px;">
각 방문자가 왼쪽에 이름을 쓰고 오른쪽에 서명한 후 "저장" 버튼을 눌러주세요.
</p>
<div class="signature-canvas-container" style="position: relative;">
<!-- 이름과 서명 구분선 및 라벨 -->
<div style="position: absolute; top: 10px; left: 10px; right: 10px; display: flex; justify-content: space-between; z-index: 1; pointer-events: none;">
<span style="font-size: var(--text-sm); color: var(--gray-500); font-weight: 600;">이름</span>
<span style="position: absolute; left: 250px; top: 0; bottom: 0; width: 2px; background: var(--gray-300);"></span>
<span style="font-size: var(--text-sm); color: var(--gray-500); font-weight: 600); margin-left: auto;">서명</span>
</div>
<canvas id="signatureCanvas" class="signature-canvas" width="800" height="300"></canvas>
<div id="signaturePlaceholder" class="signature-placeholder" style="display: flex; flex-direction: column; align-items: center; gap: 8px;">
<div>왼쪽에 이름을 쓰고, 오른쪽에 서명해주세요</div>
<div style="font-size: var(--text-sm); color: var(--gray-400);">(마우스, 터치, 또는 Apple Pencil 사용)</div>
</div>
</div>
<div class="signature-actions">
<button type="button" class="btn btn-secondary" onclick="clearSignature()">
서명 지우기
</button>
<button type="button" class="btn btn-primary" onclick="saveSignature()">
서명 저장
</button>
</div>
<div style="font-size: var(--text-sm); color: var(--gray-600); margin-top: 12px;">
서명 날짜: <span id="signatureDate"></span>
</div>
<!-- 저장된 서명 목록 -->
<div id="savedSignatures" style="margin-top: 24px;">
<!-- 동적으로 추가됨 -->
</div>
</div>
<!-- 제출 버튼 -->
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="goBack()">
취소
</button>
<button type="button" class="btn btn-primary" onclick="completeTraining()" id="completeBtn" disabled>
교육 완료 처리
</button>
</div>
</div>
</div>
</main>
</div>
<!-- Scripts -->
<script type="module" src="/js/load-navbar.js?v=5"></script>
<script src="/js/safety-training-conduct.js"></script>
</body>
</html>

View File

@@ -41,6 +41,12 @@
<span class="menu-text">작업장 관리</span>
</a>
</li>
<li class="menu-item">
<a href="/pages/admin/equipments.html">
<span class="menu-icon">⚙️</span>
<span class="menu-text">설비 관리</span>
</a>
</li>
<li class="menu-item active">
<a href="/pages/admin/tasks.html">
<span class="menu-icon">📋</span>

View File

@@ -41,6 +41,12 @@
<span class="menu-text">작업장 관리</span>
</a>
</li>
<li class="menu-item">
<a href="/pages/admin/equipments.html">
<span class="menu-icon">⚙️</span>
<span class="menu-text">설비 관리</span>
</a>
</li>
<li class="menu-item">
<a href="/pages/admin/tasks.html">
<span class="menu-icon">📋</span>

View File

@@ -41,6 +41,12 @@
<span class="menu-text">작업장 관리</span>
</a>
</li>
<li class="menu-item">
<a href="/pages/admin/equipments.html">
<span class="menu-icon">⚙️</span>
<span class="menu-text">설비 관리</span>
</a>
</li>
<li class="menu-item">
<a href="/pages/admin/tasks.html">
<span class="menu-icon">📋</span>

View File

@@ -0,0 +1,143 @@
<!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/annual-vacation-overview.css">
<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 src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0"></script>
<script type="module" src="/js/annual-vacation-overview.js" defer></script>
</head>
<body>
<!-- 메인 컨테이너 -->
<div class="page-container">
<!-- 네비게이션 헤더 -->
<div id="navbar-container"></div>
<!-- 메인 콘텐츠 -->
<main class="main-content">
<div class="content-wrapper">
<!-- 페이지 헤더 -->
<div class="page-header">
<h1 class="page-title">📊 연간 연차 현황</h1>
<p class="page-description">모든 작업자의 연간 휴가 현황을 차트로 확인합니다</p>
</div>
<!-- 필터 섹션 -->
<section class="filter-section">
<div class="card">
<div class="card-body">
<div class="filter-controls">
<div class="form-group">
<label for="yearSelect">조회 연도</label>
<select id="yearSelect" class="form-select">
<!-- JavaScript로 동적 생성 -->
</select>
</div>
<button id="refreshBtn" class="btn btn-primary">
조회
</button>
</div>
</div>
</div>
</section>
<!-- 탭 네비게이션 -->
<section class="tabs-section">
<div class="tabs-nav">
<button class="tab-btn active" data-tab="annualUsage">연간 사용 기록</button>
<button class="tab-btn" data-tab="monthlyDetails">월별 상세 기록</button>
</div>
</section>
<!-- 탭 1: 연간 사용 기록 -->
<section id="annualUsageTab" class="tab-content active">
<div class="card">
<div class="card-header">
<h2 class="card-title">월별 휴가 사용 현황</h2>
</div>
<div class="card-body">
<div class="chart-container">
<canvas id="annualUsageChart"></canvas>
</div>
</div>
</div>
</section>
<!-- 탭 2: 월별 상세 기록 -->
<section id="monthlyDetailsTab" class="tab-content">
<div class="card">
<div class="card-header">
<h2 class="card-title">월별 상세 기록</h2>
<div class="month-controls">
<select id="monthSelect" class="form-select">
<option value="1">1월</option>
<option value="2">2월</option>
<option value="3">3월</option>
<option value="4">4월</option>
<option value="5">5월</option>
<option value="6">6월</option>
<option value="7">7월</option>
<option value="8">8월</option>
<option value="9">9월</option>
<option value="10">10월</option>
<option value="11">11월</option>
<option value="12">12월</option>
</select>
<button id="exportExcelBtn" class="btn btn-sm btn-secondary">
📥 엑셀 다운로드
</button>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>작업자명</th>
<th>휴가 유형</th>
<th>시작일</th>
<th>종료일</th>
<th>사용 일수</th>
<th>사유</th>
<th>상태</th>
</tr>
</thead>
<tbody id="monthlyTableBody">
<tr>
<td colspan="7" class="loading-state">
<div class="spinner"></div>
<p>데이터를 불러오는 중...</p>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
</div>
</main>
</div>
<!-- 알림 토스트 -->
<div class="toast-container" id="toastContainer"></div>
</body>
</html>

View File

@@ -0,0 +1,395 @@
<!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/auth-check.js?v=1" defer></script>
<script type="module" src="/js/api-config.js?v=3"></script>
</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">
<span class="title-icon">📅</span>
일일 출퇴근 입력
</h1>
<p class="page-description">오늘 출근한 작업자들의 출퇴근 기록을 입력합니다</p>
</div>
<div class="page-actions">
<input type="date" id="selectedDate" class="form-control" style="width: auto;">
<button class="btn btn-primary" onclick="loadAttendanceRecords()">
<span>🔄 새로고침</span>
</button>
</div>
</div>
<!-- 출퇴근 입력 폼 -->
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">작업자 출퇴근 기록</h2>
<p class="text-muted">근태 구분을 선택하면 근무시간이 자동 입력됩니다. 연장근로는 별도로 입력 가능합니다. (조퇴: 2시간, 반반차: 6시간, 반차: 4시간, 정시: 8시간, 주말근무: 0시간)</p>
</div>
<div class="card-body">
<div id="attendanceList" class="data-table-container">
<!-- 출퇴근 기록 목록이 여기에 동적으로 렌더링됩니다 -->
</div>
<div style="text-align: center; margin-top: 2rem;">
<button class="btn btn-primary" onclick="saveAllAttendance()" style="padding: 1rem 3rem; font-size: 1.1rem;">
<span>💾 전체 저장</span>
</button>
</div>
</div>
</div>
</div>
</div>
</main>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script type="module" src="/js/load-navbar.js?v=5"></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('token');
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
axios.interceptors.request.use(
config => {
const token = localStorage.getItem('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 attendanceRecords = [];
// 근태 구분 옵션 (근무시간 자동 설정, 연장근로는 별도 입력)
const attendanceTypes = [
{ value: 'on_time', label: '정시', hours: 8 },
{ value: 'half_leave', label: '반차', hours: 4 },
{ value: 'quarter_leave', label: '반반차', hours: 6 },
{ value: 'early_leave', label: '조퇴', hours: 2 },
{ value: 'weekend_work', label: '주말근무', hours: 0 },
{ value: 'annual_leave', label: '연차', hours: 0 },
{ value: 'custom', label: '특이사항', hours: 0 }
];
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', async () => {
await waitForAxiosConfig();
initializePage();
});
function waitForAxiosConfig() {
return new Promise((resolve) => {
const check = setInterval(() => {
if (axios.defaults.baseURL) {
clearInterval(check);
resolve();
}
}, 50);
setTimeout(() => {
clearInterval(check);
resolve();
}, 5000);
});
}
async function initializePage() {
// 오늘 날짜 설정
const today = new Date().toISOString().split('T')[0];
document.getElementById('selectedDate').value = today;
try {
await loadWorkers();
await loadAttendanceRecords();
} catch (error) {
console.error('초기화 오류:', error);
alert('페이지 초기화 중 오류가 발생했습니다.');
}
}
async function loadWorkers() {
try {
const response = await axios.get('/workers');
if (response.data.success) {
workers = response.data.data.filter(w => w.employment_status === 'employed');
}
} catch (error) {
console.error('작업자 목록 로드 오류:', error);
}
}
async function loadAttendanceRecords() {
const selectedDate = document.getElementById('selectedDate').value;
if (!selectedDate) {
alert('날짜를 선택해주세요.');
return;
}
try {
// 출퇴근 기록과 체크인 목록(휴가 정보 포함)을 동시에 가져오기
const [recordsResponse, checkinResponse] = await Promise.all([
axios.get(`/attendance/records?date=${selectedDate}`).catch(() => ({ data: { success: false, data: [] } })),
axios.get(`/attendance/checkin-list?date=${selectedDate}`).catch(() => ({ data: { success: false, data: [] } }))
]);
const existingRecords = recordsResponse.data.success ? recordsResponse.data.data : [];
const checkinList = checkinResponse.data.success ? checkinResponse.data.data : [];
// 체크인 목록을 기준으로 출퇴근 기록 생성 (연차 정보 포함)
attendanceRecords = checkinList.map(worker => {
const existingRecord = existingRecords.find(r => r.worker_id === worker.worker_id);
const isOnVacation = worker.vacation_status === 'approved';
// 기존 기록이 있으면 사용, 없으면 초기화
if (existingRecord) {
return existingRecord;
} else {
return {
worker_id: worker.worker_id,
worker_name: worker.worker_name,
attendance_date: selectedDate,
total_hours: isOnVacation ? 0 : 8,
overtime_hours: 0,
attendance_type: isOnVacation ? 'annual_leave' : 'on_time',
is_custom: false,
is_new: true,
is_on_vacation: isOnVacation,
vacation_type_name: worker.vacation_type_name
};
}
});
renderAttendanceList();
} catch (error) {
console.error('출퇴근 기록 로드 오류:', error);
alert('출퇴근 기록 조회 중 오류가 발생했습니다.');
}
}
function initializeAttendanceRecords() {
const selectedDate = document.getElementById('selectedDate').value;
attendanceRecords = workers.map(worker => ({
worker_id: worker.worker_id,
worker_name: worker.worker_name,
attendance_date: selectedDate,
total_hours: 8,
overtime_hours: 0,
attendance_type: 'on_time',
is_custom: false,
is_new: true
}));
renderAttendanceList();
}
function renderAttendanceList() {
const container = document.getElementById('attendanceList');
if (attendanceRecords.length === 0) {
container.innerHTML = `
<div class="empty-state">
<p>등록된 작업자가 없거나 출퇴근 기록이 없습니다.</p>
<button class="btn btn-primary" onclick="initializeAttendanceRecords()">
작업자 목록으로 초기화
</button>
</div>
`;
return;
}
const tableHTML = `
<table class="data-table" style="font-size: 0.95rem;">
<thead style="background-color: #f8f9fa;">
<tr>
<th style="width: 120px;">작업자</th>
<th style="width: 180px;">근태 구분</th>
<th style="width: 100px;">근무시간</th>
<th style="width: 120px;">연장근로</th>
<th style="width: 100px;">특이사항</th>
</tr>
</thead>
<tbody>
${attendanceRecords.map((record, index) => {
const isCustom = record.is_custom || record.attendance_type === 'custom';
const isHoursReadonly = !isCustom; // 특이사항이 아니면 근무시간은 읽기 전용
const isOnVacation = record.is_on_vacation || false;
const vacationLabel = record.vacation_type_name ? ` (${record.vacation_type_name})` : '';
return `
<tr style="border-bottom: 1px solid #e5e7eb; ${isOnVacation ? 'background-color: #f0f9ff;' : ''}">
<td style="padding: 0.75rem; font-weight: 600;">
${record.worker_name}
${isOnVacation ? `<span style="margin-left: 0.5rem; display: inline-block; padding: 0.125rem 0.5rem; background-color: #dbeafe; color: #1e40af; border-radius: 0.25rem; font-size: 0.75rem; font-weight: 500;">연차${vacationLabel}</span>` : ''}
</td>
<td style="padding: 0.75rem;">
<select class="form-control"
onchange="updateAttendanceType(${index}, this.value)"
style="width: 160px; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 0.375rem;">
${attendanceTypes.map(type => `
<option value="${type.value}" ${record.attendance_type === type.value ? 'selected' : ''}>
${type.label}
</option>
`).join('')}
</select>
</td>
<td style="padding: 0.75rem;">
<input type="number" class="form-control"
id="hours_${index}"
value="${record.total_hours || 0}"
min="0" max="24" step="0.5"
${isHoursReadonly ? 'readonly' : ''}
onchange="updateTotalHours(${index}, this.value)"
style="width: 90px; padding: 0.5rem; border: 1px solid ${isHoursReadonly ? '#e5e7eb' : '#d1d5db'};
border-radius: 0.375rem; background-color: ${isHoursReadonly ? '#f9fafb' : 'white'}; text-align: center;">
</td>
<td style="padding: 0.75rem;">
<input type="number" class="form-control"
id="overtime_${index}"
value="${record.overtime_hours || 0}"
min="0" max="12" step="0.5"
onchange="updateOvertimeHours(${index}, this.value)"
style="width: 90px; padding: 0.5rem; border: 1px solid #d1d5db;
border-radius: 0.375rem; background-color: white; text-align: center;">
</td>
<td style="padding: 0.75rem; text-align: center;">
${isCustom ?
'<span style="color: #dc2626; font-weight: 600;">✓</span>' :
'<span style="color: #9ca3af;">-</span>'}
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
`;
container.innerHTML = tableHTML;
}
function updateTotalHours(index, value) {
attendanceRecords[index].total_hours = parseFloat(value) || 0;
}
function updateOvertimeHours(index, value) {
attendanceRecords[index].overtime_hours = parseFloat(value) || 0;
}
function updateAttendanceType(index, value) {
const record = attendanceRecords[index];
record.attendance_type = value;
// 근태 구분에 따라 자동으로 근무시간 설정
const attendanceType = attendanceTypes.find(t => t.value === value);
if (value === 'custom') {
// 특이사항 선택 시 수동 입력 가능
record.is_custom = true;
// 기존 값 유지, 수동 입력 가능
} else if (attendanceType) {
// 다른 근태 구분 선택 시 근무시간만 자동 설정
record.is_custom = false;
record.total_hours = attendanceType.hours;
// 연장근로는 유지 (별도 입력 가능)
}
// UI 다시 렌더링
renderAttendanceList();
}
async function saveAllAttendance() {
const selectedDate = document.getElementById('selectedDate').value;
if (!selectedDate) {
alert('날짜를 선택해주세요.');
return;
}
if (attendanceRecords.length === 0) {
alert('저장할 출퇴근 기록이 없습니다.');
return;
}
// 모든 기록을 API 형식에 맞게 변환
const recordsToSave = attendanceRecords.map(record => ({
worker_id: record.worker_id,
attendance_date: selectedDate,
total_hours: record.total_hours || 0,
overtime_hours: record.overtime_hours || 0,
attendance_type: record.attendance_type || 'on_time',
is_custom: record.is_custom || false
}));
try {
// 각 기록을 순차적으로 저장
let successCount = 0;
let errorCount = 0;
for (const data of recordsToSave) {
try {
const response = await axios.post('/attendance/records', data);
if (response.data.success) {
successCount++;
}
} catch (error) {
console.error(`작업자 ${data.worker_id} 저장 오류:`, error);
errorCount++;
}
}
if (errorCount === 0) {
alert(`${successCount}건의 출퇴근 기록이 모두 저장되었습니다.`);
await loadAttendanceRecords(); // 저장 후 새로고침
} else {
alert(`${successCount}건 저장 완료, ${errorCount}건 저장 실패\n자세한 내용은 콘솔을 확인해주세요.`);
}
} catch (error) {
console.error('전체 저장 오류:', error);
alert('저장 중 오류가 발생했습니다.');
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,490 @@
<!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/auth-check.js?v=1" defer></script>
<script type="module" src="/js/api-config.js?v=3"></script>
<style>
.calendar-container {
margin-top: 2rem;
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1px;
background-color: #e5e7eb;
border: 1px solid #e5e7eb;
}
.calendar-header {
background-color: #f3f4f6;
padding: 1rem;
text-align: center;
font-weight: 600;
color: #374151;
}
.calendar-day {
background-color: white;
padding: 0.5rem;
min-height: 100px;
position: relative;
}
.calendar-day.today {
background-color: #fef3c7;
}
.calendar-day.other-month {
background-color: #f9fafb;
color: #9ca3af;
}
.day-number {
font-weight: 600;
margin-bottom: 0.5rem;
}
.day-info {
font-size: 0.75rem;
margin-top: 0.25rem;
}
.attendance-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
}
.badge-normal {
background-color: #d1fae5;
color: #065f46;
}
.badge-overtime {
background-color: #fef3c7;
color: #92400e;
}
.badge-annual {
background-color: #dbeafe;
color: #1e40af;
}
.badge-half {
background-color: #e0e7ff;
color: #3730a3;
}
.badge-quarter {
background-color: #f3e8ff;
color: #5b21b6;
}
.badge-early {
background-color: #fce7f3;
color: #9f1239;
}
.worker-selector {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.summary-card {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.summary-item {
background: white;
padding: 1rem;
border-radius: 0.5rem;
border: 1px solid #e5e7eb;
}
.summary-label {
font-size: 0.875rem;
color: #6b7280;
margin-bottom: 0.25rem;
}
.summary-value {
font-size: 1.5rem;
font-weight: 600;
color: #111827;
}
</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">
<span class="title-icon">📆</span>
월별 출퇴근 현황
</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()">
<span>🔄 새로고침</span>
</button>
</div>
</div>
<!-- 작업자 선택 -->
<div class="content-section">
<div class="card">
<div class="card-body">
<div class="worker-selector">
<label for="workerSelect" style="font-weight: 600;">작업자:</label>
<select id="workerSelect" class="form-control" style="width: 300px;" onchange="loadMonthlyData()">
<option value="">작업자를 선택하세요</option>
</select>
</div>
</div>
</div>
</div>
<!-- 월별 요약 통계 -->
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">월별 요약</h2>
</div>
<div class="card-body">
<div class="summary-card" id="summarySection">
<!-- 요약 통계가 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
</div>
</div>
<!-- 달력 -->
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title" id="calendarTitle">출퇴근 달력</h2>
</div>
<div class="card-body">
<div class="calendar-container">
<div class="calendar-grid">
<div class="calendar-header"></div>
<div class="calendar-header"></div>
<div class="calendar-header"></div>
<div class="calendar-header"></div>
<div class="calendar-header"></div>
<div class="calendar-header"></div>
<div class="calendar-header"></div>
<div id="calendarDays">
<!-- 달력 날짜가 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script type="module" src="/js/load-navbar.js?v=5"></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('token');
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
axios.interceptors.request.use(
config => {
const token = localStorage.getItem('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 attendanceRecords = [];
let currentUser = null;
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', async () => {
await waitForAxiosConfig();
initializePage();
});
function waitForAxiosConfig() {
return new Promise((resolve) => {
const check = setInterval(() => {
if (axios.defaults.baseURL) {
clearInterval(check);
resolve();
}
}, 50);
setTimeout(() => {
clearInterval(check);
resolve();
}, 5000);
});
}
async function initializePage() {
// 현재 년월 설정
const now = new Date();
const yearMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
document.getElementById('selectedMonth').value = yearMonth;
// 현재 사용자 정보 가져오기
try {
const userInfo = JSON.parse(localStorage.getItem('user'));
currentUser = userInfo;
await loadWorkers();
// 관리자가 아니면 자동으로 자신 선택
if (currentUser.access_level !== 'system' && currentUser.access_level !== 'admin') {
const workerSelect = document.getElementById('workerSelect');
if (currentUser.worker_id) {
workerSelect.value = currentUser.worker_id;
}
}
await loadMonthlyData();
} catch (error) {
console.error('초기화 오류:', error);
alert('페이지 초기화 중 오류가 발생했습니다.');
}
}
async function loadWorkers() {
try {
const response = await axios.get('/workers');
if (response.data.success) {
workers = response.data.data.filter(w => w.employment_status === 'employed');
const select = document.getElementById('workerSelect');
select.innerHTML = '<option value="">작업자를 선택하세요</option>';
workers.forEach(worker => {
const option = document.createElement('option');
option.value = worker.worker_id;
option.textContent = worker.worker_name;
select.appendChild(option);
});
}
} catch (error) {
console.error('작업자 목록 로드 오류:', error);
}
}
async function loadMonthlyData() {
const selectedMonth = document.getElementById('selectedMonth').value;
const workerId = document.getElementById('workerSelect').value;
if (!selectedMonth) {
alert('월을 선택해주세요.');
return;
}
if (!workerId) {
alert('작업자를 선택해주세요.');
return;
}
try {
// 선택한 월의 첫날과 마지막 날 계산
const [year, month] = selectedMonth.split('-');
const startDate = `${year}-${month}-01`;
const endDate = new Date(year, month, 0).getDate();
const endDateStr = `${year}-${month}-${String(endDate).padStart(2, '0')}`;
// 출퇴근 기록 로드
const response = await axios.get(`/attendance/records`, {
params: {
worker_id: workerId,
start_date: startDate,
end_date: endDateStr
}
});
if (response.data.success) {
attendanceRecords = response.data.data || [];
renderCalendar();
renderSummary();
}
} catch (error) {
console.error('월별 데이터 로드 오류:', error);
if (error.response?.status === 404) {
attendanceRecords = [];
renderCalendar();
renderSummary();
}
}
}
function renderCalendar() {
const selectedMonth = document.getElementById('selectedMonth').value;
const [year, month] = selectedMonth.split('-');
const firstDay = new Date(year, month - 1, 1);
const lastDay = new Date(year, month, 0);
const today = new Date();
// 달력 제목 업데이트
document.getElementById('calendarTitle').textContent = `${year}${month}월 출퇴근 달력`;
// 달력 그리드 생성
const calendarDays = document.getElementById('calendarDays');
calendarDays.innerHTML = '';
// 이전 달의 빈 칸
for (let i = 0; i < firstDay.getDay(); i++) {
const emptyDay = document.createElement('div');
emptyDay.className = 'calendar-day other-month';
calendarDays.appendChild(emptyDay);
}
// 현재 달의 날짜
for (let day = 1; day <= lastDay.getDate(); day++) {
const dayElement = document.createElement('div');
const currentDate = new Date(year, month - 1, day);
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
dayElement.className = 'calendar-day';
// 오늘 날짜 표시
if (currentDate.toDateString() === today.toDateString()) {
dayElement.classList.add('today');
}
// 날짜 번호
const dayNumber = document.createElement('div');
dayNumber.className = 'day-number';
dayNumber.textContent = day;
dayElement.appendChild(dayNumber);
// 출퇴근 기록 찾기
const record = attendanceRecords.find(r => r.attendance_date === dateStr);
if (record) {
const infoDiv = document.createElement('div');
infoDiv.className = 'day-info';
// 근무시간
const hoursSpan = document.createElement('div');
hoursSpan.textContent = `${record.total_hours}시간`;
infoDiv.appendChild(hoursSpan);
// 야근 표시
if (record.is_overtime) {
const overtimeBadge = document.createElement('span');
overtimeBadge.className = 'attendance-badge badge-overtime';
overtimeBadge.textContent = '야근';
infoDiv.appendChild(overtimeBadge);
}
// 근태 구분
if (record.attendance_type && record.attendance_type !== 'normal') {
const typeBadge = document.createElement('span');
typeBadge.className = 'attendance-badge';
switch (record.attendance_type) {
case 'annual_leave':
typeBadge.classList.add('badge-annual');
typeBadge.textContent = '연차';
break;
case 'half_leave':
typeBadge.classList.add('badge-half');
typeBadge.textContent = '반차';
break;
case 'quarter_leave':
typeBadge.classList.add('badge-quarter');
typeBadge.textContent = '반반차';
break;
case 'early_leave':
typeBadge.classList.add('badge-early');
typeBadge.textContent = '조퇴';
break;
default:
typeBadge.classList.add('badge-normal');
typeBadge.textContent = '정상';
}
infoDiv.appendChild(typeBadge);
}
dayElement.appendChild(infoDiv);
}
calendarDays.appendChild(dayElement);
}
}
function renderSummary() {
const summarySection = document.getElementById('summarySection');
// 통계 계산
const totalDays = attendanceRecords.length;
const totalHours = attendanceRecords.reduce((sum, r) => sum + (r.total_hours || 0), 0);
const overtimeDays = attendanceRecords.filter(r => r.is_overtime).length;
const annualLeaveDays = attendanceRecords.filter(r => r.attendance_type === 'annual_leave').length;
const halfLeaveDays = attendanceRecords.filter(r => r.attendance_type === 'half_leave').length;
const quarterLeaveDays = attendanceRecords.filter(r => r.attendance_type === 'quarter_leave').length;
summarySection.innerHTML = `
<div class="summary-item">
<div class="summary-label">총 근무일수</div>
<div class="summary-value">${totalDays}일</div>
</div>
<div class="summary-item">
<div class="summary-label">총 근무시간</div>
<div class="summary-value">${totalHours.toFixed(1)}시간</div>
</div>
<div class="summary-item">
<div class="summary-label">야근일수</div>
<div class="summary-value">${overtimeDays}일</div>
</div>
<div class="summary-item">
<div class="summary-label">연차 사용</div>
<div class="summary-value">${annualLeaveDays}일</div>
</div>
<div class="summary-item">
<div class="summary-label">반차 사용</div>
<div class="summary-value">${halfLeaveDays}일</div>
</div>
<div class="summary-item">
<div class="summary-label">반반차 사용</div>
<div class="summary-value">${quarterLeaveDays}일</div>
</div>
`;
}
</script>
</body>
</html>

View File

@@ -0,0 +1,354 @@
<!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/vacation-allocation.css">
<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/vacation-allocation.js" defer></script>
</head>
<body>
<!-- 메인 컨테이너 -->
<div class="page-container">
<!-- 네비게이션 헤더 -->
<div id="navbar-container"></div>
<!-- 메인 콘텐츠 -->
<main class="main-content">
<div class="content-wrapper">
<!-- 페이지 헤더 -->
<div class="page-header">
<h1 class="page-title"> 휴가 발생 입력</h1>
<p class="page-description">작업자별 휴가를 입력하고 특별 휴가를 관리합니다</p>
</div>
<!-- 탭 네비게이션 -->
<div class="tab-navigation">
<button class="tab-button active" data-tab="individual">개별 입력</button>
<button class="tab-button" data-tab="bulk">일괄 입력</button>
<button class="tab-button" data-tab="special">특별 휴가 관리</button>
</div>
<!-- 탭 1: 개별 입력 -->
<section id="tab-individual" class="tab-content active">
<div class="card">
<div class="card-header">
<h2 class="card-title">개별 작업자 휴가 입력</h2>
</div>
<div class="card-body">
<!-- 입력 폼 -->
<div class="form-section">
<div class="form-row">
<div class="form-group">
<label for="individualWorker">작업자 선택 <span class="required">*</span></label>
<select id="individualWorker" class="form-select" required>
<option value="">선택하세요</option>
<!-- JavaScript로 동적 생성 -->
</select>
</div>
<div class="form-group">
<label for="individualYear">연도 <span class="required">*</span></label>
<select id="individualYear" class="form-select" required>
<!-- JavaScript로 동적 생성 -->
</select>
</div>
<div class="form-group">
<label for="individualVacationType">휴가 유형 <span class="required">*</span></label>
<select id="individualVacationType" class="form-select" required>
<option value="">선택하세요</option>
<!-- JavaScript로 동적 생성 -->
</select>
</div>
</div>
<!-- 자동 계산 섹션 -->
<div class="auto-calculate-section">
<div class="section-header">
<h3>자동 계산 (연차만 해당)</h3>
<button id="autoCalculateBtn" class="btn btn-secondary btn-sm">
🔄 입사일 기준 자동 계산
</button>
</div>
<div id="autoCalculateResult" class="alert alert-info" style="display: none;">
<!-- 계산 결과 표시 -->
</div>
</div>
<!-- 수동 입력 -->
<div class="form-row">
<div class="form-group">
<label for="individualTotalDays">총 부여 일수 <span class="required">*</span></label>
<input type="number" id="individualTotalDays" class="form-input" min="0" step="0.5" required>
</div>
<div class="form-group">
<label for="individualUsedDays">사용 일수</label>
<input type="number" id="individualUsedDays" class="form-input" min="0" step="0.5" value="0">
</div>
<div class="form-group full-width">
<label for="individualNotes">비고</label>
<input type="text" id="individualNotes" class="form-input" placeholder="예: 2026년 연차">
</div>
</div>
<div class="form-actions">
<button id="individualSubmitBtn" class="btn btn-primary">
저장
</button>
<button id="individualResetBtn" class="btn btn-secondary">
초기화
</button>
</div>
</div>
<!-- 기존 데이터 테이블 -->
<div class="existing-data-section">
<h3>기존 입력 내역</h3>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>작업자</th>
<th>연도</th>
<th>휴가 유형</th>
<th>총 일수</th>
<th>사용 일수</th>
<th>잔여 일수</th>
<th>비고</th>
<th>작업</th>
</tr>
</thead>
<tbody id="individualTableBody">
<tr>
<td colspan="8" class="loading-state">
<p>작업자를 선택하세요</p>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</section>
<!-- 탭 2: 일괄 입력 -->
<section id="tab-bulk" class="tab-content">
<div class="card">
<div class="card-header">
<h2 class="card-title">근속년수별 연차 일괄 생성</h2>
</div>
<div class="card-body">
<div class="alert alert-warning">
<strong>주의:</strong> 이 기능은 연차(ANNUAL) 휴가 유형만 생성합니다. 각 작업자의 입사일을 기준으로 근속년수를 계산하여 자동으로 연차를 부여합니다.
</div>
<div class="form-section">
<div class="form-row">
<div class="form-group">
<label for="bulkYear">대상 연도 <span class="required">*</span></label>
<select id="bulkYear" class="form-select" required>
<!-- JavaScript로 동적 생성 -->
</select>
</div>
<div class="form-group">
<label for="bulkEmploymentStatus">재직 상태</label>
<select id="bulkEmploymentStatus" class="form-select">
<option value="employed">재직 중만</option>
<option value="all">전체</option>
</select>
</div>
</div>
<div class="form-actions">
<button id="bulkPreviewBtn" class="btn btn-secondary">
미리보기
</button>
<button id="bulkSubmitBtn" class="btn btn-primary" disabled>
일괄 생성
</button>
</div>
</div>
<!-- 미리보기 테이블 -->
<div id="bulkPreviewSection" class="preview-section" style="display: none;">
<h3>생성 예정 내역</h3>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>작업자</th>
<th>입사일</th>
<th>근속년수</th>
<th>부여 연차</th>
<th>계산 근거</th>
<th>상태</th>
</tr>
</thead>
<tbody id="bulkPreviewTableBody">
<!-- JavaScript로 동적 생성 -->
</tbody>
</table>
</div>
</div>
</div>
</div>
</section>
<!-- 탭 3: 특별 휴가 관리 -->
<section id="tab-special" class="tab-content">
<div class="card">
<div class="card-header">
<h2 class="card-title">특별 휴가 유형 관리</h2>
<button id="addSpecialTypeBtn" class="btn btn-primary btn-sm">
+ 새 휴가 유형 추가
</button>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>유형명</th>
<th>코드</th>
<th>우선순위</th>
<th>특별 휴가</th>
<th>시스템 유형</th>
<th>설명</th>
<th>작업</th>
</tr>
</thead>
<tbody id="specialTypesTableBody">
<tr>
<td colspan="7" class="loading-state">
<div class="spinner"></div>
<p>데이터를 불러오는 중...</p>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
</div>
</main>
</div>
<!-- 알림 토스트 -->
<div class="toast-container" id="toastContainer"></div>
<!-- 모달: 휴가 유형 추가/수정 -->
<div id="vacationTypeModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 id="modalTitle">휴가 유형 추가</h3>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
<form id="vacationTypeForm">
<input type="hidden" id="modalTypeId">
<div class="form-group">
<label for="modalTypeName">유형명 <span class="required">*</span></label>
<input type="text" id="modalTypeName" class="form-input" required>
</div>
<div class="form-group">
<label for="modalTypeCode">코드 <span class="required">*</span></label>
<input type="text" id="modalTypeCode" class="form-input" required>
<small>예: ANNUAL, SICK, MATERNITY (영문 대문자)</small>
</div>
<div class="form-row">
<div class="form-group">
<label for="modalPriority">우선순위 <span class="required">*</span></label>
<input type="number" id="modalPriority" class="form-input" min="1" required>
<small>낮을수록 먼저 차감</small>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="modalIsSpecial">
특별 휴가
</label>
</div>
</div>
<div class="form-group">
<label for="modalDescription">설명</label>
<textarea id="modalDescription" class="form-input" rows="3"></textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">저장</button>
<button type="button" class="btn btn-secondary modal-close">취소</button>
</div>
</form>
</div>
</div>
</div>
<!-- 모달: 휴가 수정 -->
<div id="editBalanceModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>휴가 수정</h3>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
<form id="editBalanceForm">
<input type="hidden" id="editBalanceId">
<div class="form-group">
<label for="editTotalDays">총 일수 <span class="required">*</span></label>
<input type="number" id="editTotalDays" class="form-input" min="0" step="0.5" required>
</div>
<div class="form-group">
<label for="editUsedDays">사용 일수 <span class="required">*</span></label>
<input type="number" id="editUsedDays" class="form-input" min="0" step="0.5" required>
</div>
<div class="form-group">
<label for="editNotes">비고</label>
<input type="text" id="editNotes" class="form-input">
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">저장</button>
<button type="button" class="btn btn-secondary modal-close">취소</button>
</div>
</form>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,267 @@
<!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/auth-check.js?v=1" defer></script>
<script type="module" src="/js/api-config.js?v=3"></script>
<style>
.tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
border-bottom: 2px solid #e5e7eb;
}
.tab {
padding: 0.75rem 1.5rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-weight: 500;
color: #6b7280;
transition: all 0.2s;
}
.tab:hover {
color: #111827;
}
.tab.active {
color: #3b82f6;
border-bottom-color: #3b82f6;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
</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">
<span class="title-icon"></span>
휴가 승인 관리
</h1>
<p class="page-description">휴가 신청을 승인하거나 거부합니다</p>
</div>
</div>
<!-- 탭 메뉴 -->
<div class="tabs">
<button class="tab active" onclick="switchTab('pending')">승인 대기 목록</button>
<button class="tab" onclick="switchTab('all')">전체 신청 내역</button>
</div>
<!-- 승인 대기 목록 탭 -->
<div id="pendingTab" class="tab-content active">
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">승인 대기 목록</h2>
<p class="text-muted">대기 중인 휴가 신청을 승인하거나 거부할 수 있습니다</p>
</div>
<div class="card-body">
<div id="pendingRequestsList" class="data-table-container">
<!-- 승인 대기 목록이 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
</div>
</div>
</div>
<!-- 전체 신청 내역 탭 -->
<div id="allTab" class="tab-content">
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">전체 신청 내역</h2>
<div class="page-actions">
<input type="date" id="filterStartDate" class="form-control" style="width: auto;">
<span style="margin: 0 0.5rem;">~</span>
<input type="date" id="filterEndDate" class="form-control" style="width: auto;">
<button class="btn btn-secondary" onclick="filterAllRequests()">
<span>🔍 조회</span>
</button>
<button class="btn btn-secondary" onclick="resetFilter()">
<span>🔄 전체</span>
</button>
</div>
</div>
<div class="card-body">
<div id="allRequestsList" class="data-table-container">
<!-- 전체 신청 내역이 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<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?v=5"></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('token');
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
axios.interceptors.request.use(
config => {
const token = localStorage.getItem('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 allRequestsData = [];
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', async () => {
await waitForAxiosConfig();
initializePage();
});
async function initializePage() {
try {
const currentUser = getCurrentUser();
// 관리자 권한 체크
if (currentUser.access_level !== 'system' && currentUser.access_level !== 'admin') {
alert('관리자만 접근할 수 있습니다.');
window.location.href = '/pages/common/my-vacation.html';
return;
}
await loadPendingRequests();
await loadAllRequests();
// 휴가 업데이트 이벤트 리스너
window.addEventListener('vacation-updated', () => {
loadPendingRequests();
loadAllRequests();
});
// 날짜 필터 초기화 (최근 3개월)
const today = new Date();
const threeMonthsAgo = new Date();
threeMonthsAgo.setMonth(today.getMonth() - 3);
document.getElementById('filterStartDate').value = threeMonthsAgo.toISOString().split('T')[0];
document.getElementById('filterEndDate').value = today.toISOString().split('T')[0];
} catch (error) {
console.error('초기화 오류:', error);
alert('페이지 초기화 중 오류가 발생했습니다.');
}
}
function switchTab(tabName) {
// 모든 탭과 컨텐츠 비활성화
document.querySelectorAll('.tab').forEach(tab => tab.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
// 선택한 탭 활성화
if (tabName === 'pending') {
document.querySelector('.tab:nth-child(1)').classList.add('active');
document.getElementById('pendingTab').classList.add('active');
} else if (tabName === 'all') {
document.querySelector('.tab:nth-child(2)').classList.add('active');
document.getElementById('allTab').classList.add('active');
}
}
async function loadPendingRequests() {
try {
const response = await axios.get('/vacation-requests/pending');
if (response.data.success) {
renderVacationRequests(response.data.data, 'pendingRequestsList', true, 'approval');
}
} catch (error) {
console.error('승인 대기 목록 로드 오류:', error);
document.getElementById('pendingRequestsList').innerHTML = `
<div class="empty-state">
<p>승인 대기 중인 신청이 없습니다.</p>
</div>
`;
}
}
async function loadAllRequests() {
try {
const response = await axios.get('/vacation-requests');
if (response.data.success) {
allRequestsData = response.data.data;
renderVacationRequests(allRequestsData, 'allRequestsList', false);
}
} catch (error) {
console.error('전체 신청 내역 로드 오류:', error);
document.getElementById('allRequestsList').innerHTML = `
<div class="empty-state">
<p>신청 내역이 없습니다.</p>
</div>
`;
}
}
function filterAllRequests() {
const startDate = document.getElementById('filterStartDate').value;
const endDate = document.getElementById('filterEndDate').value;
if (!startDate || !endDate) {
alert('시작일과 종료일을 모두 선택해주세요.');
return;
}
const filtered = allRequestsData.filter(req => {
return req.start_date >= startDate && req.start_date <= endDate;
});
renderVacationRequests(filtered, 'allRequestsList', false);
}
function resetFilter() {
renderVacationRequests(allRequestsData, 'allRequestsList', false);
}
</script>
</body>
</html>

View File

@@ -0,0 +1,294 @@
<!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/auth-check.js?v=1" defer></script>
<script type="module" src="/js/api-config.js?v=3"></script>
</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">
<span class="title-icon">📝</span>
휴가 직접 입력
</h1>
<p class="page-description">관리자 권한으로 작업자의 휴가 정보를 직접 입력합니다</p>
</div>
</div>
<!-- 휴가 직접 입력 폼 -->
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">휴가 정보 입력</h2>
<p class="text-muted">승인 절차 없이 휴가 정보를 직접 입력합니다. 입력 즉시 승인 상태로 저장됩니다.</p>
</div>
<div class="card-body">
<form id="vacationInputForm" onsubmit="submitVacationInput(event)">
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem;">
<div class="form-group">
<label for="inputWorker">작업자 *</label>
<select id="inputWorker" class="form-control" required onchange="updateVacationBalance()">
<option value="">선택하세요</option>
</select>
</div>
<div class="form-group">
<label for="inputVacationType">휴가 유형 *</label>
<select id="inputVacationType" class="form-control" required>
<option value="">선택하세요</option>
</select>
</div>
<div class="form-group">
<label for="inputStartDate">시작일 *</label>
<input type="date" id="inputStartDate" class="form-control" required>
</div>
<div class="form-group">
<label for="inputEndDate">종료일 *</label>
<input type="date" id="inputEndDate" class="form-control" required>
</div>
<div class="form-group">
<label for="inputDaysUsed">사용 일수 *</label>
<input type="number" id="inputDaysUsed" class="form-control" min="0.5" step="0.5" value="1.0" required>
</div>
<div class="form-group">
<label>작업자 휴가 잔여</label>
<div id="workerVacationBalance" style="padding: 0.75rem; background-color: #f9fafb; border-radius: 0.375rem; border: 1px solid #e5e7eb;">
<span class="text-muted">작업자를 선택하세요</span>
</div>
</div>
<div class="form-group" style="grid-column: 1 / -1;">
<label for="inputReason">사유</label>
<textarea id="inputReason" class="form-control" rows="3" placeholder="휴가 사유를 입력하세요 (선택)"></textarea>
</div>
</div>
<div style="text-align: center; margin-top: 2rem;">
<button type="submit" class="btn btn-primary" style="padding: 1rem 3rem;">
<span>💾 즉시 입력 (자동 승인)</span>
</button>
</div>
</form>
</div>
</div>
</div>
<!-- 최근 입력 내역 -->
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">최근 입력 내역</h2>
<div class="page-actions">
<button class="btn btn-secondary" onclick="loadRecentInputs()">
<span>🔄 새로고침</span>
</button>
</div>
</div>
<div class="card-body">
<div id="recentInputsList" class="data-table-container">
<!-- 최근 입력 내역이 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
</div>
</div>
</div>
</main>
<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?v=5"></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('token');
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
axios.interceptors.request.use(
config => {
const token = localStorage.getItem('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>
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', async () => {
await waitForAxiosConfig();
initializePage();
});
async function initializePage() {
try {
const currentUser = getCurrentUser();
// 관리자 권한 체크
if (currentUser.access_level !== 'system' && currentUser.access_level !== 'admin') {
alert('관리자만 접근할 수 있습니다.');
window.location.href = '/pages/common/my-vacation.html';
return;
}
await loadWorkers();
await loadVacationTypes();
await loadRecentInputs();
// 휴가 업데이트 이벤트 리스너
window.addEventListener('vacation-updated', loadRecentInputs);
} catch (error) {
console.error('초기화 오류:', error);
alert('페이지 초기화 중 오류가 발생했습니다.');
}
}
async function updateVacationBalance() {
const workerId = document.getElementById('inputWorker').value;
const balanceDiv = document.getElementById('workerVacationBalance');
if (!workerId) {
balanceDiv.innerHTML = '<span class="text-muted">작업자를 선택하세요</span>';
return;
}
try {
const response = await axios.get(`/attendance/vacation-balance/${workerId}`);
if (response.data.success) {
const balance = response.data.data;
if (!balance || Object.keys(balance).length === 0) {
balanceDiv.innerHTML = '<span class="text-muted">휴가 잔여 정보가 없습니다</span>';
return;
}
const balanceHTML = Object.keys(balance).map(key => {
const info = balance[key];
return `
<div style="display: inline-block; margin-right: 1rem;">
<span style="color: #6b7280; font-size: 0.875rem;">${key}:</span>
<strong style="color: #111827; font-size: 1rem; margin-left: 0.25rem;">${info.remaining || 0}일</strong>
<span style="color: #9ca3af; font-size: 0.75rem; margin-left: 0.25rem;">(전체: ${info.total || 0}일)</span>
</div>
`;
}).join('');
balanceDiv.innerHTML = balanceHTML;
}
} catch (error) {
console.error('휴가 잔여 조회 오류:', error);
balanceDiv.innerHTML = '<span class="text-muted" style="color: #dc2626;">조회 실패</span>';
}
}
async function submitVacationInput(event) {
event.preventDefault();
const data = {
worker_id: parseInt(document.getElementById('inputWorker').value),
vacation_type_id: parseInt(document.getElementById('inputVacationType').value),
start_date: document.getElementById('inputStartDate').value,
end_date: document.getElementById('inputEndDate').value,
days_used: parseFloat(document.getElementById('inputDaysUsed').value),
reason: document.getElementById('inputReason').value || null,
auto_approve: true // 자동 승인 플래그
};
if (!confirm(`${document.getElementById('inputWorker').selectedOptions[0].text}의 휴가를 즉시 입력하시겠습니까?\n\n입력 즉시 승인 상태로 저장됩니다.`)) {
return;
}
try {
// 휴가 신청 생성
const response = await axios.post('/vacation-requests', data);
if (response.data.success) {
const requestId = response.data.data.request_id;
// 즉시 승인 처리
try {
const approveResponse = await axios.patch(`/vacation-requests/${requestId}/approve`);
if (approveResponse.data.success) {
alert('휴가 정보가 입력되고 자동 승인되었습니다.');
document.getElementById('vacationInputForm').reset();
document.getElementById('workerVacationBalance').innerHTML = '<span class="text-muted">작업자를 선택하세요</span>';
window.dispatchEvent(new Event('vacation-updated'));
}
} catch (approveError) {
console.error('자동 승인 오류:', approveError);
alert('휴가 정보는 입력되었으나 자동 승인에 실패했습니다. 승인 관리 페이지에서 수동으로 승인해주세요.');
}
}
} catch (error) {
console.error('휴가 입력 오류:', error);
alert(error.response?.data?.message || '휴가 입력 중 오류가 발생했습니다.');
}
}
async function loadRecentInputs() {
try {
const response = await axios.get('/vacation-requests');
if (response.data.success) {
// 최근 30일 이내 승인된 항목만 표시
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const thirtyDaysAgoStr = thirtyDaysAgo.toISOString().split('T')[0];
const recentApproved = response.data.data.filter(req =>
req.status === 'approved' &&
req.created_at >= thirtyDaysAgoStr
).slice(0, 20); // 최근 20개만
renderVacationRequests(recentApproved, 'recentInputsList', false);
}
} catch (error) {
console.error('최근 입력 내역 로드 오류:', error);
document.getElementById('recentInputsList').innerHTML = `
<div class="empty-state">
<p>최근 입력 내역이 없습니다.</p>
</div>
`;
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,461 @@
<!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/auth-check.js?v=1" defer></script>
<script type="module" src="/js/api-config.js?v=3"></script>
<style>
.tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
border-bottom: 2px solid #e5e7eb;
}
.tab {
padding: 0.75rem 1.5rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-weight: 500;
color: #6b7280;
transition: all 0.2s;
}
.tab:hover {
color: #111827;
}
.tab.active {
color: #3b82f6;
border-bottom-color: #3b82f6;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
</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">
<span class="title-icon">🏖️</span>
휴가 관리
</h1>
<p class="page-description">휴가 신청을 승인하고 작업자 휴가 정보를 관리합니다</p>
</div>
</div>
<!-- 탭 메뉴 -->
<div class="tabs">
<button class="tab active" onclick="switchTab('approval')">승인 대기 목록</button>
<button class="tab" onclick="switchTab('input')">직접 입력</button>
<button class="tab" onclick="switchTab('all')">전체 신청 내역</button>
</div>
<!-- 승인 대기 목록 탭 -->
<div id="approvalTab" class="tab-content active">
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">승인 대기 목록</h2>
<p class="text-muted">대기 중인 휴가 신청을 승인하거나 거부할 수 있습니다</p>
</div>
<div class="card-body">
<div id="pendingRequestsList" class="data-table-container">
<!-- 승인 대기 목록이 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
</div>
</div>
</div>
<!-- 직접 입력 탭 -->
<div id="inputTab" class="tab-content">
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">휴가 정보 직접 입력</h2>
<p class="text-muted">승인 절차 없이 휴가 정보를 직접 입력합니다. 입력 즉시 승인 상태로 저장됩니다.</p>
</div>
<div class="card-body">
<form id="vacationInputForm" onsubmit="submitVacationInput(event)">
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem;">
<div class="form-group">
<label for="inputWorker">작업자 *</label>
<select id="inputWorker" class="form-control" required onchange="updateVacationBalance()">
<option value="">선택하세요</option>
</select>
</div>
<div class="form-group">
<label for="inputVacationType">휴가 유형 *</label>
<select id="inputVacationType" class="form-control" required>
<option value="">선택하세요</option>
</select>
</div>
<div class="form-group">
<label for="inputStartDate">시작일 *</label>
<input type="date" id="inputStartDate" class="form-control" required>
</div>
<div class="form-group">
<label for="inputEndDate">종료일 *</label>
<input type="date" id="inputEndDate" class="form-control" required>
</div>
<div class="form-group">
<label for="inputDaysUsed">사용 일수 *</label>
<input type="number" id="inputDaysUsed" class="form-control" min="0.5" step="0.5" value="1.0" required>
</div>
<div class="form-group">
<label>작업자 휴가 잔여</label>
<div id="workerVacationBalance" style="padding: 0.75rem; background-color: #f9fafb; border-radius: 0.375rem; border: 1px solid #e5e7eb;">
<span class="text-muted">작업자를 선택하세요</span>
</div>
</div>
<div class="form-group" style="grid-column: 1 / -1;">
<label for="inputReason">사유</label>
<textarea id="inputReason" class="form-control" rows="3" placeholder="휴가 사유를 입력하세요 (선택)"></textarea>
</div>
</div>
<div style="text-align: center; margin-top: 2rem;">
<button type="submit" class="btn btn-primary" style="padding: 1rem 3rem;">
<span>💾 즉시 입력 (자동 승인)</span>
</button>
</div>
</form>
</div>
</div>
<!-- 최근 입력 내역 -->
<div class="card" style="margin-top: 2rem;">
<div class="card-header">
<h2 class="card-title">최근 입력 내역</h2>
<div class="page-actions">
<button class="btn btn-secondary" onclick="loadRecentInputs()">
<span>🔄 새로고침</span>
</button>
</div>
</div>
<div class="card-body">
<div id="recentInputsList" class="data-table-container">
<!-- 최근 입력 내역이 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
</div>
</div>
</div>
<!-- 전체 신청 내역 탭 -->
<div id="allTab" class="tab-content">
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">전체 신청 내역</h2>
<div class="page-actions">
<input type="date" id="filterStartDate" class="form-control" style="width: auto;">
<span style="margin: 0 0.5rem;">~</span>
<input type="date" id="filterEndDate" class="form-control" style="width: auto;">
<button class="btn btn-secondary" onclick="filterAllRequests()">
<span>🔍 조회</span>
</button>
<button class="btn btn-secondary" onclick="resetFilter()">
<span>🔄 전체</span>
</button>
</div>
</div>
<div class="card-body">
<div id="allRequestsList" class="data-table-container">
<!-- 전체 신청 내역이 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<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?v=5"></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('token');
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
axios.interceptors.request.use(
config => {
const token = localStorage.getItem('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 allRequestsData = [];
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', async () => {
await waitForAxiosConfig();
initializePage();
});
async function initializePage() {
try {
const currentUser = getCurrentUser();
// 관리자 권한 체크
if (currentUser.access_level !== 'system' && currentUser.access_level !== 'admin') {
alert('관리자만 접근할 수 있습니다.');
window.location.href = '/pages/common/vacation-request.html';
return;
}
await loadWorkers();
await loadVacationTypes();
await loadPendingRequests();
await loadAllRequests();
// 휴가 업데이트 이벤트 리스너
window.addEventListener('vacation-updated', () => {
loadPendingRequests();
loadAllRequests();
});
// 날짜 필터 초기화 (최근 3개월)
const today = new Date();
const threeMonthsAgo = new Date();
threeMonthsAgo.setMonth(today.getMonth() - 3);
document.getElementById('filterStartDate').value = threeMonthsAgo.toISOString().split('T')[0];
document.getElementById('filterEndDate').value = today.toISOString().split('T')[0];
} catch (error) {
console.error('초기화 오류:', error);
alert('페이지 초기화 중 오류가 발생했습니다.');
}
}
function switchTab(tabName) {
// 모든 탭과 컨텐츠 비활성화
document.querySelectorAll('.tab').forEach(tab => tab.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
// 선택한 탭 활성화
if (tabName === 'approval') {
document.querySelector('.tab:nth-child(1)').classList.add('active');
document.getElementById('approvalTab').classList.add('active');
} else if (tabName === 'input') {
document.querySelector('.tab:nth-child(2)').classList.add('active');
document.getElementById('inputTab').classList.add('active');
} else if (tabName === 'all') {
document.querySelector('.tab:nth-child(3)').classList.add('active');
document.getElementById('allTab').classList.add('active');
}
}
async function loadPendingRequests() {
try {
const response = await axios.get('/vacation-requests/pending');
if (response.data.success) {
renderVacationRequests(response.data.data, 'pendingRequestsList', true, 'approval');
}
} catch (error) {
console.error('승인 대기 목록 로드 오류:', error);
document.getElementById('pendingRequestsList').innerHTML = `
<div class="empty-state">
<p>승인 대기 중인 신청이 없습니다.</p>
</div>
`;
}
}
async function loadAllRequests() {
try {
const response = await axios.get('/vacation-requests');
if (response.data.success) {
allRequestsData = response.data.data;
renderVacationRequests(allRequestsData, 'allRequestsList', false);
}
} catch (error) {
console.error('전체 신청 내역 로드 오류:', error);
document.getElementById('allRequestsList').innerHTML = `
<div class="empty-state">
<p>신청 내역이 없습니다.</p>
</div>
`;
}
}
async function updateVacationBalance() {
const workerId = document.getElementById('inputWorker').value;
const balanceDiv = document.getElementById('workerVacationBalance');
if (!workerId) {
balanceDiv.innerHTML = '<span class="text-muted">작업자를 선택하세요</span>';
return;
}
try {
const response = await axios.get(`/attendance/vacation-balance/${workerId}`);
if (response.data.success) {
const balance = response.data.data;
if (!balance || Object.keys(balance).length === 0) {
balanceDiv.innerHTML = '<span class="text-muted">휴가 잔여 정보가 없습니다</span>';
return;
}
const balanceHTML = Object.keys(balance).map(key => {
const info = balance[key];
return `
<div style="display: inline-block; margin-right: 1rem;">
<span style="color: #6b7280; font-size: 0.875rem;">${key}:</span>
<strong style="color: #111827; font-size: 1rem; margin-left: 0.25rem;">${info.remaining || 0}일</strong>
<span style="color: #9ca3af; font-size: 0.75rem; margin-left: 0.25rem;">(전체: ${info.total || 0}일)</span>
</div>
`;
}).join('');
balanceDiv.innerHTML = balanceHTML;
}
} catch (error) {
console.error('휴가 잔여 조회 오류:', error);
balanceDiv.innerHTML = '<span class="text-muted" style="color: #dc2626;">조회 실패</span>';
}
}
async function submitVacationInput(event) {
event.preventDefault();
const data = {
worker_id: parseInt(document.getElementById('inputWorker').value),
vacation_type_id: parseInt(document.getElementById('inputVacationType').value),
start_date: document.getElementById('inputStartDate').value,
end_date: document.getElementById('inputEndDate').value,
days_used: parseFloat(document.getElementById('inputDaysUsed').value),
reason: document.getElementById('inputReason').value || null
};
if (!confirm(`${document.getElementById('inputWorker').selectedOptions[0].text}의 휴가를 즉시 입력하시겠습니까?\n\n입력 즉시 승인 상태로 저장됩니다.`)) {
return;
}
try {
// 휴가 신청 생성
const response = await axios.post('/vacation-requests', data);
if (response.data.success) {
const requestId = response.data.data.request_id;
// 즉시 승인 처리
try {
const approveResponse = await axios.patch(`/vacation-requests/${requestId}/approve`);
if (approveResponse.data.success) {
alert('휴가 정보가 입력되고 자동 승인되었습니다.');
document.getElementById('vacationInputForm').reset();
document.getElementById('workerVacationBalance').innerHTML = '<span class="text-muted">작업자를 선택하세요</span>';
window.dispatchEvent(new Event('vacation-updated'));
loadRecentInputs();
}
} catch (approveError) {
console.error('자동 승인 오류:', approveError);
alert('휴가 정보는 입력되었으나 자동 승인에 실패했습니다. 승인 관리 탭에서 수동으로 승인해주세요.');
}
}
} catch (error) {
console.error('휴가 입력 오류:', error);
alert(error.response?.data?.message || '휴가 입력 중 오류가 발생했습니다.');
}
}
async function loadRecentInputs() {
try {
const response = await axios.get('/vacation-requests');
if (response.data.success) {
// 최근 30일 이내 승인된 항목만 표시
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const thirtyDaysAgoStr = thirtyDaysAgo.toISOString().split('T')[0];
const recentApproved = response.data.data.filter(req =>
req.status === 'approved' &&
req.created_at >= thirtyDaysAgoStr
).slice(0, 20); // 최근 20개만
renderVacationRequests(recentApproved, 'recentInputsList', false);
}
} catch (error) {
console.error('최근 입력 내역 로드 오류:', error);
document.getElementById('recentInputsList').innerHTML = `
<div class="empty-state">
<p>최근 입력 내역이 없습니다.</p>
</div>
`;
}
}
function filterAllRequests() {
const startDate = document.getElementById('filterStartDate').value;
const endDate = document.getElementById('filterEndDate').value;
if (!startDate || !endDate) {
alert('시작일과 종료일을 모두 선택해주세요.');
return;
}
const filtered = allRequestsData.filter(req => {
return req.start_date >= startDate && req.start_date <= endDate;
});
renderVacationRequests(filtered, 'allRequestsList', false);
}
function resetFilter() {
renderVacationRequests(allRequestsData, 'allRequestsList', false);
}
</script>
</body>
</html>

View File

@@ -0,0 +1,272 @@
<!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/auth-check.js?v=1" defer></script>
<script type="module" src="/js/api-config.js?v=3"></script>
</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">
<span class="title-icon">📝</span>
휴가 신청
</h1>
<p class="page-description">휴가를 신청하고 신청 내역을 확인합니다</p>
</div>
</div>
<!-- 휴가 잔여 현황 -->
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">휴가 잔여 현황</h2>
</div>
<div class="card-body">
<div id="vacationBalance" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem;">
<!-- 휴가 잔여 정보가 여기에 표시됩니다 -->
</div>
</div>
</div>
</div>
<!-- 휴가 신청 -->
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">휴가 신청</h2>
</div>
<div class="card-body">
<form id="vacationRequestForm" onsubmit="submitVacationRequest(event)">
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem;">
<div class="form-group">
<label for="vacationType">휴가 유형 *</label>
<select id="vacationType" class="form-control" required>
<option value="">선택하세요</option>
</select>
</div>
<div class="form-group">
<label for="daysUsed">사용 일수 *</label>
<input type="number" id="daysUsed" class="form-control" min="0.5" step="0.5" value="1.0" required>
</div>
<div class="form-group">
<label for="startDate">시작일 *</label>
<input type="date" id="startDate" class="form-control" required>
</div>
<div class="form-group">
<label for="endDate">종료일 *</label>
<input type="date" id="endDate" class="form-control" required>
</div>
<div class="form-group" style="grid-column: 1 / -1;">
<label for="reason">사유</label>
<textarea id="reason" class="form-control" rows="3" placeholder="휴가 사유를 입력하세요 (선택)"></textarea>
</div>
</div>
<div style="text-align: center; margin-top: 2rem;">
<button type="submit" class="btn btn-primary" style="padding: 1rem 3rem;">
<span>📝 신청하기</span>
</button>
</div>
</form>
</div>
</div>
</div>
<!-- 내 신청 내역 -->
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">내 신청 내역</h2>
</div>
<div class="card-body">
<div id="myRequestsList" class="data-table-container">
<!-- 내 신청 내역이 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
</div>
</div>
</div>
</main>
<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?v=5"></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('token');
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
axios.interceptors.request.use(
config => {
const token = localStorage.getItem('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>
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', async () => {
await waitForAxiosConfig();
initializePage();
});
async function initializePage() {
try {
const currentUser = getCurrentUser();
if (!currentUser || !currentUser.worker_id) {
alert('작업자 정보가 없습니다. 관리자에게 문의하세요.');
return;
}
await loadVacationTypes();
await loadVacationBalance();
await loadMyRequests();
// 휴가 업데이트 이벤트 리스너
window.addEventListener('vacation-updated', () => {
loadVacationBalance();
loadMyRequests();
});
} catch (error) {
console.error('초기화 오류:', error);
alert('페이지 초기화 중 오류가 발생했습니다.');
}
}
async function loadVacationBalance() {
const currentUser = getCurrentUser();
try {
const response = await axios.get(`/attendance/vacation-balance/${currentUser.worker_id}`);
if (response.data.success) {
renderVacationBalance(response.data.data);
}
} catch (error) {
console.error('휴가 잔여 조회 오류:', error);
document.getElementById('vacationBalance').innerHTML = `
<p class="text-muted">휴가 잔여 정보를 불러올 수 없습니다.</p>
`;
}
}
function renderVacationBalance(balance) {
const container = document.getElementById('vacationBalance');
if (!balance || Object.keys(balance).length === 0) {
container.innerHTML = '<p class="text-muted">휴가 잔여 정보가 없습니다.</p>';
return;
}
const balanceHTML = Object.keys(balance).map(key => {
const info = balance[key];
return `
<div style="padding: 1.5rem; background-color: #f9fafb; border-radius: 0.5rem; border: 1px solid #e5e7eb;">
<div style="font-size: 0.875rem; color: #6b7280; margin-bottom: 0.5rem;">${key}</div>
<div style="font-size: 1.5rem; font-weight: 700; color: #111827;">
${info.remaining || 0}
</div>
<div style="font-size: 0.75rem; color: #9ca3af; margin-top: 0.25rem;">
사용: ${info.used || 0}일 / 전체: ${info.total || 0}
</div>
</div>
`;
}).join('');
container.innerHTML = balanceHTML;
}
async function submitVacationRequest(event) {
event.preventDefault();
const currentUser = getCurrentUser();
const data = {
worker_id: currentUser.worker_id,
vacation_type_id: parseInt(document.getElementById('vacationType').value),
start_date: document.getElementById('startDate').value,
end_date: document.getElementById('endDate').value,
days_used: parseFloat(document.getElementById('daysUsed').value),
reason: document.getElementById('reason').value || null
};
try {
const response = await axios.post('/vacation-requests', data);
if (response.data.success) {
alert('휴가 신청이 완료되었습니다.');
document.getElementById('vacationRequestForm').reset();
window.dispatchEvent(new Event('vacation-updated'));
}
} catch (error) {
console.error('휴가 신청 오류:', error);
alert(error.response?.data?.message || '휴가 신청 중 오류가 발생했습니다.');
}
}
async function loadMyRequests() {
const currentUser = getCurrentUser();
try {
const response = await axios.get('/vacation-requests');
if (response.data.success) {
// 내 신청만 필터링
const myRequests = response.data.data.filter(req =>
req.requested_by === currentUser.user_id || req.worker_id === currentUser.worker_id
);
renderVacationRequests(myRequests, 'myRequestsList', true, 'delete');
}
} catch (error) {
console.error('내 신청 내역 로드 오류:', error);
document.getElementById('myRequestsList').innerHTML = `
<div class="empty-state">
<p>신청 내역이 없습니다.</p>
</div>
`;
}
}
</script>
</body>
</html>

View File

@@ -17,6 +17,7 @@
<script type="module" src="/js/load-navbar.js"></script>
<script type="module" src="/js/modern-dashboard.js?v=10" defer></script>
<script type="module" src="/js/group-leader-dashboard.js?v=1" defer></script>
<script src="/js/workplace-status.js" defer></script>
</head>
<body>
@@ -46,6 +47,22 @@
<div class="action-arrow" style="color: white;"></div>
</a>
<a href="/pages/work/visit-request.html" class="quick-action-card" style="background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); color: white;">
<div class="action-content">
<h3 style="color: white;">🚪 출입 신청</h3>
<p style="color: rgba(255, 255, 255, 0.9);">작업장 출입 및 안전교육을 신청합니다</p>
</div>
<div class="action-arrow" style="color: white;"></div>
</a>
<a href="/pages/admin/safety-management.html" class="quick-action-card admin-only" style="background: linear-gradient(135deg, #ec4899 0%, #db2777 100%); color: white;">
<div class="action-content">
<h3 style="color: white;">🛡️ 안전관리</h3>
<p style="color: rgba(255, 255, 255, 0.9);">출입 신청 승인 및 안전교육 관리</p>
</div>
<div class="action-arrow" style="color: white;"></div>
</a>
<a href="/pages/work/report-create.html" class="quick-action-card">
<div class="action-content">
<h3>작업 보고서 작성</h3>
@@ -77,46 +94,111 @@
</div>
<div class="action-arrow"></div>
</a>
<a href="/pages/common/daily-attendance.html" class="quick-action-card" style="background: linear-gradient(135deg, #06b6d4 0%, #0891b2 100%); color: white;">
<div class="action-content">
<h3 style="color: white;">📅 일일 출퇴근 입력</h3>
<p style="color: rgba(255, 255, 255, 0.9);">오늘의 출퇴근 기록을 입력합니다</p>
</div>
<div class="action-arrow" style="color: white;"></div>
</a>
<a href="/pages/common/monthly-attendance.html" class="quick-action-card">
<div class="action-content">
<h3>📆 월별 출퇴근 현황</h3>
<p>이번 달 출퇴근 현황을 조회합니다</p>
</div>
<div class="action-arrow"></div>
</a>
<a href="/pages/common/vacation-request.html" class="quick-action-card">
<div class="action-content">
<h3>📝 휴가 신청</h3>
<p>휴가를 신청하고 신청 내역을 확인합니다</p>
</div>
<div class="action-arrow"></div>
</a>
<a href="/pages/common/vacation-management.html" class="quick-action-card admin-only">
<div class="action-content">
<h3>🏖️ 휴가 관리</h3>
<p>휴가 승인, 직접 입력, 전체 내역을 관리합니다</p>
</div>
<div class="action-arrow"></div>
</a>
<a href="/pages/common/annual-vacation-overview.html" class="quick-action-card admin-only">
<div class="action-content">
<h3>📊 연간 연차 현황</h3>
<p>모든 작업자의 연간 휴가 현황을 차트로 확인합니다</p>
</div>
<div class="action-arrow"></div>
</a>
<a href="/pages/common/vacation-allocation.html" class="quick-action-card admin-only">
<div class="action-content">
<h3> 휴가 발생 입력</h3>
<p>작업자별 휴가를 입력하고 특별 휴가를 관리합니다</p>
</div>
<div class="action-arrow"></div>
</a>
<a href="/pages/admin/attendance-report-comparison.html" class="quick-action-card admin-only">
<div class="action-content">
<h3>🔍 출퇴근-작업보고서 대조</h3>
<p>출퇴근 기록과 작업보고서를 비교 분석합니다</p>
</div>
<div class="action-arrow"></div>
</a>
</div>
</div>
</div>
</section>
<!-- 오늘의 작업 현황 -->
<section class="work-status-section">
<!-- 작업 현황 -->
<section class="workplace-status-section">
<div class="card">
<div class="card-header">
<div class="flex justify-between items-center">
<h2 class="card-title">오늘의 작업 현황</h2>
<div class="date-selector">
<input type="date" id="selectedDate" class="date-input">
<button class="btn btn-primary btn-sm" id="refreshBtn">
<h2 class="card-title">작업 현황</h2>
<div class="flex items-center" style="gap: 12px;">
<select id="categorySelect" class="form-select" style="width: 200px;">
<option value="">공장을 선택하세요</option>
</select>
<button class="btn btn-primary btn-sm" id="refreshMapBtn">
새로고침
</button>
</div>
</div>
</div>
<div class="card-body">
<div class="work-status-table-container">
<table class="work-status-table">
<thead>
<tr>
<th>작업자</th>
<th>상태</th>
<th>작업시간</th>
<th>작업건수</th>
<th>액션</th>
</tr>
</thead>
<tbody id="workStatusTableBody">
<tr>
<td colspan="5" class="loading-state">
<div class="spinner"></div>
<p>작업 현황을 불러오는 중...</p>
</td>
</tr>
</tbody>
</table>
<!-- 지도 영역 -->
<div id="workplaceMapContainer" style="position: relative; min-height: 500px; display: none;">
<canvas id="workplaceMapCanvas" style="width: 100%; max-width: 100%; cursor: pointer; border: 2px solid var(--gray-300); border-radius: var(--radius-md);"></canvas>
<div id="mapLegend" style="position: absolute; top: 16px; right: 16px; background: white; padding: 16px; border-radius: var(--radius-md); box-shadow: var(--shadow-md);">
<h4 style="font-size: var(--text-sm); font-weight: 700; margin-bottom: 12px;">범례</h4>
<div style="display: flex; flex-direction: column; gap: 8px;">
<div style="display: flex; align-items: center; gap: 8px;">
<div style="width: 16px; height: 16px; background: rgba(59, 130, 246, 0.3); border: 2px solid rgb(59, 130, 246); border-radius: 4px;"></div>
<span style="font-size: var(--text-sm);">작업 중 (내부 작업자)</span>
</div>
<div style="display: flex; align-items: center; gap: 8px;">
<div style="width: 16px; height: 16px; background: rgba(168, 85, 247, 0.3); border: 2px solid rgb(168, 85, 247); border-radius: 4px;"></div>
<span style="font-size: var(--text-sm);">방문 예정 (외부 인원)</span>
</div>
<div style="display: flex; align-items: center; gap: 8px;">
<div style="width: 16px; height: 16px; background: rgba(34, 197, 94, 0.3); border: 2px solid rgb(34, 197, 94); border-radius: 4px;"></div>
<span style="font-size: var(--text-sm);">작업 + 방문</span>
</div>
</div>
</div>
</div>
<!-- 안내 메시지 -->
<div id="mapPlaceholder" style="display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 400px; color: var(--gray-500);">
<div style="font-size: 48px; margin-bottom: 16px;">🏭</div>
<h3 style="margin-bottom: 8px;">공장을 선택하세요</h3>
<p style="font-size: var(--text-sm);">위에서 공장을 선택하면 작업장 현황을 확인할 수 있습니다.</p>
</div>
</div>
</div>
@@ -144,6 +226,28 @@
<!-- 알림 토스트 -->
<div class="toast-container" id="toastContainer"></div>
<!-- 작업장 상세 정보 모달 -->
<div id="workplaceDetailModal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.5); z-index: 1000; align-items: center; justify-content: center;">
<div style="background: white; border-radius: var(--radius-lg); padding: 32px; max-width: 800px; width: 90%; max-height: 80vh; overflow-y: auto; box-shadow: var(--shadow-2xl);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px;">
<h2 id="modalWorkplaceName" style="margin: 0; font-size: var(--text-2xl); font-weight: 700;"></h2>
<button class="btn btn-secondary btn-sm" onclick="closeWorkplaceModal()">닫기</button>
</div>
<!-- 내부 작업자 -->
<div id="internalWorkersSection" style="margin-bottom: 24px;">
<h3 style="font-size: var(--text-lg); font-weight: 600; margin-bottom: 16px; color: var(--primary-600);">👷 내부 작업자</h3>
<div id="internalWorkersList"></div>
</div>
<!-- 외부 방문자 -->
<div id="externalVisitorsSection">
<h3 style="font-size: var(--text-lg); font-weight: 600; margin-bottom: 16px; color: var(--purple-600);">🚪 외부 방문자</h3>
<div id="externalVisitorsList"></div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -5,7 +5,7 @@
<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/daily-work-report.css?v=9">
<link rel="stylesheet" href="/css/daily-work-report.css?v=11">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/auth-check.js" defer></script>
</head>
@@ -175,6 +175,6 @@
<!-- 스크립트 -->
<script type="module" src="/js/api-config.js?v=3"></script>
<script type="module" src="/js/load-navbar.js?v=5"></script>
<script type="module" src="/js/daily-work-report.js?v=24"></script>
<script type="module" src="/js/daily-work-report.js?v=25"></script>
</body>
</html>

View File

@@ -141,7 +141,7 @@
<!-- TBM 생성/수정 모달 -->
<div id="tbmModal" class="modal-overlay" style="display: none;">
<div class="modal-container" style="max-width: 800px;">
<div class="modal-container" style="max-width: 1000px;">
<div class="modal-header">
<h2 id="modalTitle">새 TBM 시작</h2>
<button class="modal-close-btn" onclick="closeTbmModal()">×</button>
@@ -151,68 +151,252 @@
<form id="tbmForm" onsubmit="event.preventDefault(); saveTbmSession();">
<input type="hidden" id="sessionId">
<div class="form-row">
<div class="form-group">
<label class="form-label">TBM 날짜 *</label>
<input type="date" id="sessionDate" class="form-control" required>
</div>
<div class="form-group">
<label class="form-label">팀장 *</label>
<select id="leaderId" class="form-control" required>
<option value="">팀장 선택...</option>
</select>
<!-- 고정 정보 섹션 -->
<div class="form-section" style="background: #f9fafb; padding: 1rem; border-radius: 0.5rem; margin-bottom: 1.5rem;">
<div class="form-row">
<div class="form-group">
<label class="form-label">TBM 날짜 *</label>
<input type="date" id="sessionDate" class="form-control" required readonly style="background: #e5e7eb;">
</div>
<div class="form-group">
<label class="form-label">입력자 *</label>
<input type="text" id="leaderName" class="form-control" readonly style="background: #e5e7eb;">
<input type="hidden" id="leaderId">
</div>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">프로젝트</label>
<select id="projectId" class="form-control">
<option value="">프로젝트 선택...</option>
</select>
<!-- 작업자 및 작업 정보 섹션 -->
<div class="form-section">
<div class="section-header" style="margin-bottom: 1rem;">
<h3 style="font-size: 1.1rem; font-weight: 600; color: #1f2937;">
<span style="margin-right: 0.5rem;">👷</span>
작업자 및 작업 정보
</h3>
<div style="display: flex; gap: 0.5rem;">
<button type="button" class="btn btn-sm btn-secondary" onclick="openBulkSettingModal()" style="display: flex; align-items: center; gap: 0.25rem;">
<span>📋</span>
일괄 설정
</button>
<button type="button" class="btn btn-sm btn-primary" onclick="openWorkerSelectionModal()">
<span style="font-size: 1.2rem; margin-right: 0.25rem;">+</span>
작업자 선택
</button>
</div>
</div>
<div class="form-group">
<label class="form-label">작업 장소</label>
<input type="text" id="workLocation" class="form-control" placeholder="작업 현장 위치">
<!-- 작업자 카드 리스트 -->
<div id="workerTaskList" style="display: flex; flex-direction: column; gap: 0.75rem; min-height: 100px;">
<!-- 작업자 카드들이 여기에 동적으로 추가됩니다 -->
<div class="empty-state-small" id="workerListEmpty" style="display: flex; align-items: center; justify-content: center; padding: 2rem; border: 2px dashed #d1d5db; border-radius: 0.5rem; color: #6b7280;">
<div style="text-align: center;">
<div style="font-size: 2rem; margin-bottom: 0.5rem;">👷‍♂️</div>
<p>작업자를 선택해주세요</p>
</div>
</div>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">공정 (Work Type) *</label>
<select id="workTypeId" class="form-control" onchange="loadTasksByWorkType()" required>
<option value="">공정 선택...</option>
</select>
</div>
<div class="form-group">
<label class="form-label">작업 (Task) *</label>
<select id="taskId" class="form-control" required>
<option value="">작업 선택...</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">작업 내용</label>
<textarea id="workDescription" class="form-control" rows="3" placeholder="오늘 진행할 작업 내용을 입력하세요"></textarea>
</div>
<div class="form-group">
<label class="form-label">안전 관련 특이사항</label>
<textarea id="safetyNotes" class="form-control" rows="2" placeholder="안전 주의사항이나 특이사항"></textarea>
</div>
<div class="form-group">
<label class="form-label">시작 시간</label>
<input type="time" id="startTime" class="form-control">
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeTbmModal()">취소</button>
<button type="button" class="btn btn-primary" onclick="saveTbmSession()">
💾 저장 및 팀 구성하기
💾 저장하기
</button>
</div>
</div>
</div>
<!-- 일괄 설정 모달 -->
<div id="bulkSettingModal" class="modal-overlay" style="display: none; z-index: 1001;">
<div class="modal-container" style="max-width: 700px;">
<div class="modal-header">
<h2>일괄 설정</h2>
<button class="modal-close-btn" onclick="closeBulkSettingModal()">×</button>
</div>
<div class="modal-body">
<div style="background: #dbeafe; border: 1px solid #3b82f6; padding: 1rem; border-radius: 0.5rem; margin-bottom: 1.5rem;">
<div style="font-weight: 600; color: #1e40af; margin-bottom: 0.25rem;">💡 일괄 설정</div>
<div style="color: #1e40af; font-size: 0.9rem;">
선택한 작업자들의 <strong>첫 번째 작업 라인</strong>에 동일한 정보가 적용됩니다.
</div>
</div>
<!-- 작업자 선택 -->
<div class="form-group">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem;">
<label class="form-label" style="margin-bottom: 0;">적용할 작업자 선택 *</label>
<div style="display: flex; gap: 0.25rem;">
<button type="button" class="btn btn-sm btn-secondary" onclick="selectAllForBulk()" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;">전체</button>
<button type="button" class="btn btn-sm btn-secondary" onclick="deselectAllForBulk()" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;">해제</button>
</div>
</div>
<div id="bulkWorkerSelection" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 0.5rem; padding: 0.75rem; border: 1px solid #e5e7eb; border-radius: 0.5rem; max-height: 200px; overflow-y: auto; background: #f9fafb;">
<!-- 작업자 체크박스들이 여기에 생성됩니다 -->
</div>
</div>
<div style="border-top: 1px solid #e5e7eb; margin: 1.5rem 0; padding-top: 1.5rem;">
<h4 style="font-size: 0.95rem; font-weight: 600; margin-bottom: 1rem; color: #374151;">적용할 작업 정보</h4>
<div class="form-group">
<label class="form-label">프로젝트</label>
<button type="button" id="bulkProjectBtn" onclick="openBulkItemSelect('project')" class="btn btn-secondary" style="width: 100%; text-align: left; justify-content: flex-start;">
📁 프로젝트 선택
</button>
<input type="hidden" id="bulkProjectId">
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">공정 *</label>
<button type="button" id="bulkWorkTypeBtn" onclick="openBulkItemSelect('workType')" class="btn btn-secondary" style="width: 100%; text-align: left; justify-content: flex-start;">
⚙️ 공정 선택
</button>
<input type="hidden" id="bulkWorkTypeId">
</div>
<div class="form-group">
<label class="form-label">작업 *</label>
<button type="button" id="bulkTaskBtn" onclick="openBulkItemSelect('task')" class="btn btn-secondary" style="width: 100%; text-align: left; justify-content: flex-start;" disabled>
🔧 작업 선택
</button>
<input type="hidden" id="bulkTaskId">
</div>
</div>
<div class="form-group">
<label class="form-label">작업장 *</label>
<button type="button" id="bulkWorkplaceBtn" onclick="openBulkWorkplaceSelect()" class="btn btn-secondary" style="width: 100%; text-align: left; justify-content: flex-start;">
📍 작업장 선택
</button>
<input type="hidden" id="bulkWorkplaceCategoryId">
<input type="hidden" id="bulkWorkplaceId">
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeBulkSettingModal()">취소</button>
<button type="button" class="btn btn-primary" onclick="applyBulkSettings()">
✓ 선택한 작업자에 적용
</button>
</div>
</div>
</div>
<!-- 작업자 선택 모달 -->
<div id="workerSelectionModal" class="modal-overlay" style="display: none; z-index: 1001;">
<div class="modal-container" style="max-width: 800px;">
<div class="modal-header">
<h2>작업자 선택</h2>
<button class="modal-close-btn" onclick="closeWorkerSelectionModal()">×</button>
</div>
<div class="modal-body">
<div style="margin-bottom: 1rem; display: flex; gap: 0.5rem;">
<button type="button" class="btn btn-sm btn-secondary" onclick="selectAllWorkersInModal()">전체 선택</button>
<button type="button" class="btn btn-sm btn-secondary" onclick="deselectAllWorkersInModal()">전체 해제</button>
</div>
<div id="workerCardGrid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.75rem; max-height: 500px; overflow-y: auto; padding: 0.5rem;">
<!-- 작업자 카드들이 여기에 생성됩니다 -->
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeWorkerSelectionModal()">취소</button>
<button type="button" class="btn btn-primary" onclick="confirmWorkerSelection()">
✓ 선택 완료
</button>
</div>
</div>
</div>
<!-- 항목 선택 모달 (프로젝트/공정/작업 선택용) -->
<div id="itemSelectModal" class="modal-overlay" style="display: none; z-index: 1002;">
<div class="modal-container" style="max-width: 600px;">
<div class="modal-header">
<h2 id="itemSelectModalTitle">항목 선택</h2>
<button class="modal-close-btn" onclick="closeItemSelectModal()">×</button>
</div>
<div class="modal-body">
<div id="itemSelectList" style="display: flex; flex-direction: column; gap: 0.5rem; max-height: 400px; overflow-y: auto; padding: 0.5rem;">
<!-- 선택 항목들이 여기에 생성됩니다 -->
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeItemSelectModal()">취소</button>
</div>
</div>
</div>
<!-- 작업장 선택 모달 (2단계: 공장 → 작업장) -->
<div id="workplaceSelectModal" class="modal-overlay" style="display: none; z-index: 1002;">
<div class="modal-container" style="max-width: 1000px; max-height: 90vh;">
<div class="modal-header">
<h2>작업장 선택</h2>
<button class="modal-close-btn" onclick="closeWorkplaceSelectModal()">×</button>
</div>
<div class="modal-body" style="overflow-y: auto;">
<!-- 1단계: 공장 선택 -->
<div style="margin-bottom: 1.5rem;">
<h3 style="font-size: 1rem; font-weight: 600; margin-bottom: 0.75rem; color: #374151;">
<span style="margin-right: 0.5rem;">🏭</span>
1. 공장 선택
</h3>
<div id="categoryList" style="display: flex; flex-wrap: wrap; gap: 0.5rem; padding: 0.75rem; border: 1px solid #e5e7eb; border-radius: 0.5rem; background: #f9fafb;">
<!-- 공장 카테고리 버튼들이 여기에 생성됩니다 -->
</div>
</div>
<!-- 2단계: 작업장 선택 (지도 + 리스트) -->
<div id="workplaceSelectionArea" style="display: none;">
<h3 style="font-size: 1rem; font-weight: 600; margin-bottom: 0.75rem; color: #374151;">
<span style="margin-right: 0.5rem;">📍</span>
2. 작업장 선택
</h3>
<!-- 지도 기반 선택 영역 -->
<div id="layoutMapArea" style="display: none; margin-bottom: 1.5rem; padding: 1rem; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 0.5rem;">
<div style="font-size: 0.875rem; color: #6b7280; margin-bottom: 0.75rem;">
<span style="margin-right: 0.25rem;">🗺️</span>
지도에서 작업장을 클릭하여 선택하세요
</div>
<div style="text-align: center; position: relative; display: inline-block; max-width: 100%;">
<canvas id="workplaceMapCanvas" style="max-width: 100%; border-radius: 0.5rem; cursor: pointer; box-shadow: 0 2px 4px rgba(0,0,0,0.1);"></canvas>
</div>
</div>
<!-- 리스트 기반 선택 (오류 대비용) -->
<div>
<div style="font-size: 0.875rem; color: #6b7280; margin-bottom: 0.75rem; display: flex; align-items: center; justify-content: space-between;">
<span>
<span style="margin-right: 0.25rem;">📋</span>
리스트에서 선택 (지도 오류 시)
</span>
<button type="button" class="btn btn-sm btn-secondary" onclick="toggleWorkplaceList()" id="toggleListBtn">
<span id="toggleListIcon"></span>
리스트 보기
</button>
</div>
<div id="workplaceList" style="display: none; flex-direction: column; gap: 0.5rem; max-height: 300px; overflow-y: auto; padding: 0.75rem; border: 1px solid #e5e7eb; border-radius: 0.5rem; background: white;">
<div style="color: #9ca3af; text-align: center; padding: 2rem;">
공장을 먼저 선택해주세요
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeWorkplaceSelectModal()">취소</button>
<button type="button" class="btn btn-primary" onclick="confirmWorkplaceSelection()" id="confirmWorkplaceBtn" disabled>
✓ 선택 완료
</button>
</div>
</div>
@@ -415,6 +599,6 @@
</div>
<script type="module" src="/js/load-navbar.js?v=5"></script>
<script type="module" src="/js/tbm.js?v=2"></script>
<script type="module" src="/js/tbm.js?v=3"></script>
</body>
</html>

View File

@@ -0,0 +1,371 @@
<!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/common.css?v=2">
<link rel="stylesheet" href="/css/project-management.css?v=3">
<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>
<style>
.visit-form-container {
max-width: 800px;
margin: 0 auto;
}
.form-group {
margin-bottom: 24px;
}
.form-group label {
display: block;
font-weight: 600;
margin-bottom: 8px;
color: var(--gray-700);
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 12px;
border: 1px solid var(--gray-300);
border-radius: var(--radius-md);
font-size: var(--text-base);
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--primary-500);
box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.1);
}
.form-group textarea {
min-height: 100px;
resize: vertical;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.workplace-selection {
padding: 20px;
background: var(--gray-50);
border-radius: var(--radius-md);
border: 2px dashed var(--gray-300);
min-height: 150px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all var(--transition-fast);
}
.workplace-selection:hover {
border-color: var(--primary-500);
background: var(--primary-50);
}
.workplace-selection.selected {
border-color: var(--primary-500);
border-style: solid;
background: white;
}
.workplace-selection .icon {
font-size: 48px;
margin-bottom: 12px;
}
.workplace-selection .text {
font-size: var(--text-base);
color: var(--gray-600);
text-align: center;
}
.workplace-selection.selected .text {
color: var(--primary-600);
font-weight: 600;
}
.form-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 32px;
}
/* 지도 모달 스타일 */
.map-modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
z-index: 1000;
align-items: center;
justify-content: center;
}
.map-modal-content {
background: white;
border-radius: var(--radius-lg);
max-width: 90vw;
max-height: 90vh;
overflow: auto;
padding: 32px;
box-shadow: var(--shadow-2xl);
}
.map-canvas-container {
position: relative;
margin-top: 20px;
border: 2px solid var(--gray-300);
border-radius: var(--radius-md);
overflow: hidden;
}
.map-canvas {
cursor: pointer;
display: block;
max-width: 100%;
}
.workplace-info-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: var(--primary-50);
border: 2px solid var(--primary-200);
border-radius: var(--radius-md);
margin-top: 12px;
}
.workplace-info-card .icon {
font-size: 32px;
}
.workplace-info-card .details {
flex: 1;
}
.workplace-info-card .name {
font-size: var(--text-lg);
font-weight: 700;
color: var(--primary-700);
}
.workplace-info-card .category {
font-size: var(--text-sm);
color: var(--gray-600);
margin-top: 4px;
}
.my-requests-section {
margin-top: 48px;
}
.request-card {
padding: 20px;
background: white;
border: 1px solid var(--gray-200);
border-radius: var(--radius-md);
margin-bottom: 16px;
box-shadow: var(--shadow-sm);
}
.request-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.request-status {
padding: 6px 12px;
border-radius: var(--radius-full);
font-size: var(--text-sm);
font-weight: 600;
}
.request-status.pending {
background: var(--yellow-100);
color: var(--yellow-700);
}
.request-status.approved {
background: var(--green-100);
color: var(--green-700);
}
.request-status.rejected {
background: var(--red-100);
color: var(--red-700);
}
.request-status.training_completed {
background: var(--blue-100);
color: var(--blue-700);
}
.request-info {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
color: var(--gray-700);
}
.info-item {
display: flex;
flex-direction: column;
}
.info-label {
font-size: var(--text-sm);
color: var(--gray-500);
margin-bottom: 4px;
}
.info-value {
font-weight: 600;
}
</style>
</head>
<body>
<div class="work-report-container">
<!-- 네비게이션 바 -->
<div id="navbar-container"></div>
<!-- 메인 콘텐츠 -->
<main class="work-report-main">
<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>
<!-- 출입 신청 폼 -->
<div class="visit-form-container">
<div class="code-section">
<h2 class="section-title">출입 정보 입력</h2>
<form id="visitRequestForm">
<!-- 방문자 정보 -->
<div class="form-row">
<div class="form-group">
<label for="visitorCompany">방문자 소속 *</label>
<input type="text" id="visitorCompany" placeholder="예: (주)협력업체, 일용직 등" required>
</div>
<div class="form-group">
<label for="visitorCount">방문 인원 *</label>
<input type="number" id="visitorCount" value="1" min="1" required>
</div>
</div>
<!-- 작업장 선택 -->
<div class="form-group">
<label>방문 작업장 *</label>
<div id="workplaceSelection" class="workplace-selection" onclick="openMapModal()">
<div class="icon">📍</div>
<div class="text">지도에서 작업장을 선택하세요</div>
</div>
<div id="selectedWorkplaceInfo" style="display: none;"></div>
</div>
<!-- 방문 일시 -->
<div class="form-row">
<div class="form-group">
<label for="visitDate">방문 날짜 *</label>
<input type="date" id="visitDate" required>
</div>
<div class="form-group">
<label for="visitTime">방문 시간 *</label>
<input type="time" id="visitTime" required>
</div>
</div>
<!-- 방문 목적 -->
<div class="form-group">
<label for="visitPurpose">방문 목적 *</label>
<select id="visitPurpose" required>
<option value="">선택하세요</option>
<!-- 동적으로 로드됨 -->
</select>
</div>
<!-- 비고 -->
<div class="form-group">
<label for="notes">비고 (선택)</label>
<textarea id="notes" placeholder="추가 전달 사항이 있다면 입력하세요"></textarea>
</div>
<!-- 버튼 -->
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="resetForm()">초기화</button>
<button type="submit" class="btn btn-primary">출입 신청 및 안전교육 신청</button>
</div>
</form>
</div>
<!-- 내 신청 목록 -->
<div class="my-requests-section">
<div class="code-section">
<h2 class="section-title">내 출입 신청 현황</h2>
<div id="myRequestsList">
<!-- 동적으로 로드됨 -->
</div>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- 작업장 지도 모달 -->
<div id="mapModal" class="map-modal">
<div class="map-modal-content">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px;">
<h2 style="margin: 0;">작업장 선택</h2>
<button class="btn btn-secondary" onclick="closeMapModal()">닫기</button>
</div>
<!-- 구역(공장) 선택 -->
<div class="form-group">
<label for="categorySelect">구역(공장) 선택</label>
<select id="categorySelect" onchange="loadWorkplaceMap()">
<option value="">구역을 선택하세요</option>
<!-- 동적으로 로드됨 -->
</select>
</div>
<!-- 지도 캔버스 -->
<div id="mapCanvasContainer" style="display: none;">
<div class="map-canvas-container">
<canvas id="workplaceMapCanvas" class="map-canvas"></canvas>
</div>
<p style="margin-top: 12px; color: var(--gray-600); font-size: var(--text-sm);">
지도에서 방문할 작업장을 클릭하세요
</p>
</div>
</div>
</div>
<!-- Scripts -->
<script type="module" src="/js/load-navbar.js?v=5"></script>
<script src="/js/visit-request.js"></script>
</body>
</html>