feat: 다수 기능 개선 - 순찰, 출근, 작업분석, 모바일 UI 등
- 순찰/점검 기능 개선 (zone-detail 페이지 추가) - 출근/근태 시스템 개선 (연차 조회, 근무현황) - 작업분석 대분류 그룹화 및 마이그레이션 스크립트 - 모바일 네비게이션 UI 추가 - NAS 배포 도구 및 문서 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1
deploy/tkfb-package/web-ui/pages/admin/.gitkeep
Normal file
1
deploy/tkfb-package/web-ui/pages/admin/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# Placeholder file to create admin directory
|
||||
286
deploy/tkfb-package/web-ui/pages/admin/accounts.html
Normal file
286
deploy/tkfb-package/web-ui/pages/admin/accounts.html
Normal file
@@ -0,0 +1,286 @@
|
||||
<!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=1">
|
||||
<link rel="stylesheet" href="/css/admin-settings.css?v=2">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
</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="settings-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">사용자 계정 관리</h2>
|
||||
<button class="btn btn-primary" id="addUserBtn">새 사용자 추가</button>
|
||||
</div>
|
||||
|
||||
<div class="users-container">
|
||||
<div class="users-header">
|
||||
<div class="search-box">
|
||||
<input type="text" id="userSearch" placeholder="사용자 검색..." class="search-input">
|
||||
<span class="search-icon"></span>
|
||||
</div>
|
||||
<div class="filter-buttons">
|
||||
<button class="filter-btn active" data-filter="all">전체</button>
|
||||
<button class="filter-btn" data-filter="admin">관리자</button>
|
||||
<button class="filter-btn" data-filter="leader">그룹장</button>
|
||||
<button class="filter-btn" data-filter="user">작업자</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="users-table-container">
|
||||
<table class="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>사용자명</th>
|
||||
<th>아이디</th>
|
||||
<th>역할</th>
|
||||
<th>상태</th>
|
||||
<th>최종 로그인</th>
|
||||
<th>관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="usersTableBody">
|
||||
<!-- 사용자 목록이 여기에 동적으로 생성됩니다 -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="empty-state" id="emptyState" style="display: none;">
|
||||
<h3>등록된 사용자가 없습니다</h3>
|
||||
<p>새 사용자를 추가해보세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 알림 수신자 설정 섹션 -->
|
||||
<div class="settings-section" id="notificationRecipientsSection">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">알림 수신자 설정</h2>
|
||||
<p class="section-description">알림 유형별 수신자를 지정합니다. 지정된 사용자에게만 알림이 전송됩니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="notification-recipients-container">
|
||||
<div class="notification-type-cards" id="notificationTypeCards">
|
||||
<!-- 동적으로 생성됨 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 사용자 추가/수정 모달 -->
|
||||
<div id="userModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h2 id="modalTitle">새 사용자 추가</h2>
|
||||
<button class="modal-close-btn" onclick="closeUserModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<form id="userForm">
|
||||
<div class="form-group">
|
||||
<label class="form-label">사용자명 *</label>
|
||||
<input type="text" id="userName" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">아이디 *</label>
|
||||
<input type="text" id="userId" class="form-control" required>
|
||||
<small class="form-help">영문, 숫자, 한글, 특수문자(._-) 사용 가능 (3-20자)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="passwordGroup">
|
||||
<label class="form-label">비밀번호 *</label>
|
||||
<input type="password" id="userPassword" class="form-control" required>
|
||||
<small class="form-help">최소 6자 이상</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">역할 *</label>
|
||||
<select id="userRole" class="form-control" required>
|
||||
<option value="">역할 선택</option>
|
||||
<option value="admin">관리자</option>
|
||||
<option value="user">사용자</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">이메일</label>
|
||||
<input type="email" id="userEmail" class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">전화번호</label>
|
||||
<input type="tel" id="userPhone" class="form-control">
|
||||
</div>
|
||||
|
||||
<!-- 작업자 연결 (수정 시에만 표시) -->
|
||||
<div class="form-group" id="workerLinkGroup" style="display: none;">
|
||||
<label class="form-label">작업자 연결</label>
|
||||
<div class="worker-link-container">
|
||||
<div class="linked-worker-info" id="linkedWorkerInfo">
|
||||
<span class="no-worker">연결된 작업자 없음</span>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary btn-sm" onclick="openWorkerSelectModal()">
|
||||
작업자 선택
|
||||
</button>
|
||||
</div>
|
||||
<small class="form-help">계정과 작업자를 연결하면 출퇴근, 작업보고서 등의 기록이 연동됩니다</small>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeUserModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" id="saveUserBtn">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 사용자 삭제 확인 모달 -->
|
||||
<div id="deleteModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container small">
|
||||
<div class="modal-header">
|
||||
<h2>사용자 삭제</h2>
|
||||
<button class="modal-close-btn" onclick="closeDeleteModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="delete-warning">
|
||||
<div class="warning-icon"></div>
|
||||
<p>정말로 이 사용자를 삭제하시겠습니까?</p>
|
||||
<p class="warning-text">삭제된 사용자는 복구할 수 없습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeDeleteModal()">취소</button>
|
||||
<button type="button" class="btn btn-danger" id="confirmDeleteBtn">삭제</button>
|
||||
</div>
|
||||
</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 id="workerSelectModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h2>작업자 선택</h2>
|
||||
<button class="modal-close-btn" onclick="closeWorkerSelectModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="worker-select-layout">
|
||||
<!-- 부서 목록 -->
|
||||
<div class="department-list-panel">
|
||||
<h3 class="panel-title">부서</h3>
|
||||
<div class="department-list" id="departmentList">
|
||||
<!-- 부서 목록이 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업자 목록 -->
|
||||
<div class="worker-list-panel">
|
||||
<h3 class="panel-title">작업자</h3>
|
||||
<div class="worker-list" id="workerListForSelect">
|
||||
<div class="empty-message">부서를 선택하세요</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeWorkerSelectModal()">취소</button>
|
||||
<button type="button" class="btn btn-danger" onclick="unlinkWorker()">연결 해제</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 알림 수신자 편집 모달 -->
|
||||
<div id="notificationRecipientModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h2 id="recipientModalTitle">알림 수신자 설정</h2>
|
||||
<button class="modal-close-btn" onclick="closeRecipientModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<p class="modal-description" id="recipientModalDesc">이 알림을 받을 사용자를 선택하세요.</p>
|
||||
|
||||
<div class="recipient-search-box">
|
||||
<input type="text" id="recipientSearchInput" placeholder="사용자 검색..." class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="recipient-user-list" id="recipientUserList">
|
||||
<!-- 사용자 체크박스 목록 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeRecipientModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveNotificationRecipients()">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 토스트 알림 -->
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<script src="/js/admin-settings.js?v=9"></script>
|
||||
</body>
|
||||
</html>
|
||||
486
deploy/tkfb-package/web-ui/pages/admin/attendance-report.html
Normal file
486
deploy/tkfb-package/web-ui/pages/admin/attendance-report.html
Normal file
@@ -0,0 +1,486 @@
|
||||
<!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=8">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
.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">출퇴근-작업보고서 대조</h1>
|
||||
<p class="page-description">출퇴근 기록과 작업보고서를 비교 분석합니다</p>
|
||||
</div>
|
||||
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-primary" onclick="loadComparisonData()">새로고침</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()">조회</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">
|
||||
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?limit=100');
|
||||
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>
|
||||
316
deploy/tkfb-package/web-ui/pages/admin/departments.html
Normal file
316
deploy/tkfb-package/web-ui/pages/admin/departments.html
Normal file
@@ -0,0 +1,316 @@
|
||||
<!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=8">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
.department-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 350px 1fr;
|
||||
gap: 1.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.department-list-panel {
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
.department-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
margin-bottom: 0.5rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
.department-item:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
.department-item.active {
|
||||
background: #eff6ff;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
.department-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.department-name {
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
.department-count {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
.department-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.btn-icon {
|
||||
padding: 0.375rem;
|
||||
border-radius: 0.375rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn-icon:hover {
|
||||
background: #e5e7eb;
|
||||
color: #1f2937;
|
||||
}
|
||||
.btn-icon.danger:hover {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
.worker-list-panel {
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
.worker-list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
.worker-list-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
.worker-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.worker-card:hover {
|
||||
border-color: #d1d5db;
|
||||
background: #f9fafb;
|
||||
}
|
||||
.worker-card.selected {
|
||||
background: #eff6ff;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
.worker-info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
.worker-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: #e5e7eb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
color: #4b5563;
|
||||
}
|
||||
.worker-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.worker-name {
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
.worker-job {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
.bulk-actions {
|
||||
display: none;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: #f3f4f6;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.bulk-actions.visible {
|
||||
display: flex;
|
||||
}
|
||||
.modal-backdrop {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 100;
|
||||
}
|
||||
.modal-backdrop.show {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1);
|
||||
}
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.modal-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.form-label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #374151;
|
||||
}
|
||||
.form-input, .form-select, .form-textarea {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.form-textarea {
|
||||
min-height: 80px;
|
||||
resize: vertical;
|
||||
}
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.department-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="page-container">
|
||||
<main class="main-content">
|
||||
<div class="dashboard-main">
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">부서 관리</h1>
|
||||
<p class="page-description">부서를 등록하고 작업자를 부서별로 관리합니다</p>
|
||||
</div>
|
||||
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-primary" onclick="openDepartmentModal()">새 부서 등록</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="department-grid">
|
||||
<!-- 부서 목록 패널 -->
|
||||
<div class="department-list-panel">
|
||||
<h3 style="margin-bottom: 1rem; font-size: 1rem; color: #374151;">부서 목록</h3>
|
||||
<div id="departmentList">
|
||||
<!-- 부서 목록이 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업자 목록 패널 -->
|
||||
<div class="worker-list-panel">
|
||||
<div class="worker-list-header">
|
||||
<span class="worker-list-title" id="workerListTitle">부서를 선택하세요</span>
|
||||
<button class="btn btn-secondary btn-sm" id="addWorkerBtn" style="display: none;" onclick="openAddWorkerModal()">
|
||||
작업자 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 일괄 작업 영역 -->
|
||||
<div class="bulk-actions" id="bulkActions">
|
||||
<span style="font-size: 0.875rem; color: #374151;"><strong id="selectedCount">0</strong>명 선택됨</span>
|
||||
<select class="form-select" id="moveToDepartment" style="width: 150px; margin-left: auto;">
|
||||
<option value="">부서 이동...</option>
|
||||
</select>
|
||||
<button class="btn btn-primary btn-sm" onclick="moveSelectedWorkers()">이동</button>
|
||||
</div>
|
||||
|
||||
<div id="workerList">
|
||||
<div style="text-align: center; padding: 2rem; color: #9ca3af;">
|
||||
왼쪽에서 부서를 선택하면 해당 부서의 작업자가 표시됩니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 부서 등록/수정 모달 -->
|
||||
<div class="modal-backdrop" id="departmentModal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title" id="departmentModalTitle">새 부서 등록</h2>
|
||||
<button class="btn-icon" onclick="closeDepartmentModal()">×</button>
|
||||
</div>
|
||||
<form id="departmentForm" onsubmit="saveDepartment(event)">
|
||||
<input type="hidden" id="departmentId">
|
||||
<div class="form-group">
|
||||
<label class="form-label">부서명 *</label>
|
||||
<input type="text" class="form-input" id="departmentName" required placeholder="예: 생산팀, 품질관리팀">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">상위 부서</label>
|
||||
<select class="form-select" id="parentDepartment">
|
||||
<option value="">없음 (최상위 부서)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">설명</label>
|
||||
<textarea class="form-textarea" id="departmentDescription" placeholder="부서 설명을 입력하세요"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">표시 순서</label>
|
||||
<input type="number" class="form-input" id="displayOrder" value="0" min="0">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<input type="checkbox" id="isActive" checked>
|
||||
<span>활성화</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeDepartmentModal()">취소</button>
|
||||
<button type="submit" class="btn btn-primary">저장</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/department-management.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
344
deploy/tkfb-package/web-ui/pages/admin/equipment-detail.html
Normal file
344
deploy/tkfb-package/web-ui/pages/admin/equipment-detail.html
Normal file
@@ -0,0 +1,344 @@
|
||||
<!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=8">
|
||||
<link rel="stylesheet" href="/css/equipment-detail.css?v=1">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 레이아웃 -->
|
||||
<div class="page-container">
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="main-content">
|
||||
<div class="dashboard-main">
|
||||
<!-- 뒤로가기 & 제목 -->
|
||||
<div class="page-header eq-detail-header">
|
||||
<div class="page-title-section">
|
||||
<button class="btn-back" onclick="goBack()">
|
||||
<span class="back-arrow">←</span>
|
||||
<span>뒤로</span>
|
||||
</button>
|
||||
<div class="eq-header-info">
|
||||
<h1 class="page-title" id="equipmentTitle">설비 상세</h1>
|
||||
<div class="eq-header-meta" id="equipmentMeta"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="eq-status-badge" id="equipmentStatus"></div>
|
||||
</div>
|
||||
|
||||
<!-- 설비 기본 정보 카드 -->
|
||||
<div class="eq-info-card" id="equipmentInfoCard">
|
||||
<!-- JS에서 동적으로 렌더링 -->
|
||||
</div>
|
||||
|
||||
<!-- 사진 섹션 -->
|
||||
<div class="eq-section">
|
||||
<div class="eq-section-header">
|
||||
<h2 class="eq-section-title">설비 사진</h2>
|
||||
<button class="btn btn-sm btn-outline" onclick="openPhotoModal()">+ 사진 추가</button>
|
||||
</div>
|
||||
<div class="eq-photo-grid" id="photoGrid">
|
||||
<div class="eq-photo-empty">등록된 사진이 없습니다</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 위치 정보 섹션 -->
|
||||
<div class="eq-section">
|
||||
<div class="eq-section-header">
|
||||
<h2 class="eq-section-title">위치 정보</h2>
|
||||
</div>
|
||||
<div class="eq-location-card" id="locationCard">
|
||||
<div class="eq-location-info">
|
||||
<div class="eq-location-row">
|
||||
<span class="eq-location-label">원래 위치:</span>
|
||||
<span class="eq-location-value" id="originalLocation">-</span>
|
||||
</div>
|
||||
<div class="eq-location-row" id="currentLocationRow" style="display: none;">
|
||||
<span class="eq-location-label">현재 위치:</span>
|
||||
<span class="eq-location-value eq-moved" id="currentLocation">-</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="eq-map-preview" id="mapPreview">
|
||||
<!-- 지도 미리보기 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 액션 버튼들 -->
|
||||
<div class="eq-action-buttons">
|
||||
<button class="btn btn-action btn-move" onclick="openMoveModal()">
|
||||
<span class="btn-icon">⇄</span>
|
||||
<span>임시이동</span>
|
||||
</button>
|
||||
<button class="btn btn-action btn-repair" onclick="openRepairModal()">
|
||||
<span class="btn-icon">🔧</span>
|
||||
<span>수리신청</span>
|
||||
</button>
|
||||
<button class="btn btn-action btn-export" onclick="openExportModal()">
|
||||
<span class="btn-icon">🚚</span>
|
||||
<span>외부반출</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 수리 이력 섹션 -->
|
||||
<div class="eq-section">
|
||||
<div class="eq-section-header">
|
||||
<h2 class="eq-section-title">수리 이력</h2>
|
||||
</div>
|
||||
<div class="eq-history-list" id="repairHistory">
|
||||
<div class="eq-history-empty">수리 이력이 없습니다</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 외부반출 이력 섹션 -->
|
||||
<div class="eq-section">
|
||||
<div class="eq-section-header">
|
||||
<h2 class="eq-section-title">외부반출 이력</h2>
|
||||
</div>
|
||||
<div class="eq-history-list" id="externalHistory">
|
||||
<div class="eq-history-empty">외부반출 이력이 없습니다</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 이동 이력 섹션 -->
|
||||
<div class="eq-section">
|
||||
<div class="eq-section-header">
|
||||
<h2 class="eq-section-title">이동 이력</h2>
|
||||
</div>
|
||||
<div class="eq-history-list" id="moveHistory">
|
||||
<div class="eq-history-empty">이동 이력이 없습니다</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 사진 추가 모달 -->
|
||||
<div id="photoModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 500px;">
|
||||
<div class="modal-header">
|
||||
<h2>사진 추가</h2>
|
||||
<button class="btn-close" onclick="closePhotoModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>사진 선택</label>
|
||||
<input type="file" id="photoInput" accept="image/*" onchange="previewPhoto(event)">
|
||||
</div>
|
||||
<div class="photo-preview-container" id="photoPreviewContainer" style="display: none;">
|
||||
<img id="photoPreview" class="photo-preview">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>설명 (선택)</label>
|
||||
<input type="text" id="photoDescription" class="form-control" placeholder="사진 설명을 입력하세요">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closePhotoModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" onclick="uploadPhoto()">업로드</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 임시이동 모달 -->
|
||||
<div id="moveModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 600px;">
|
||||
<div class="modal-header">
|
||||
<h2>설비 임시 이동</h2>
|
||||
<button class="btn-close" onclick="closeMoveModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="move-step" id="moveStep1">
|
||||
<div class="form-group">
|
||||
<label>이동할 공장 선택</label>
|
||||
<select id="moveFactorySelect" class="form-control" onchange="loadMoveWorkplaces()">
|
||||
<option value="">공장을 선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>이동할 작업장 선택</label>
|
||||
<select id="moveWorkplaceSelect" class="form-control" onchange="loadMoveMap()">
|
||||
<option value="">작업장을 선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="move-step" id="moveStep2" style="display: none;">
|
||||
<p class="move-instruction">지도에서 이동할 위치를 클릭하세요</p>
|
||||
<div class="move-map-container" id="moveMapContainer">
|
||||
<!-- 지도가 여기에 표시됨 -->
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>이동 사유 (선택)</label>
|
||||
<input type="text" id="moveReason" class="form-control" placeholder="이동 사유를 입력하세요">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeMoveModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" id="moveConfirmBtn" onclick="confirmMove()" disabled>이동 확인</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 수리신청 모달 -->
|
||||
<div id="repairModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 500px;">
|
||||
<div class="modal-header">
|
||||
<h2>수리 신청</h2>
|
||||
<button class="btn-close" onclick="closeRepairModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>수리 유형</label>
|
||||
<select id="repairItemSelect" class="form-control">
|
||||
<option value="">선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>상세 내용</label>
|
||||
<textarea id="repairDescription" class="form-control" rows="3" placeholder="수리가 필요한 내용을 상세히 적어주세요"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>사진 첨부 (선택)</label>
|
||||
<input type="file" id="repairPhotoInput" accept="image/*" multiple onchange="previewRepairPhotos(event)">
|
||||
<div class="repair-photo-previews" id="repairPhotoPreviews"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeRepairModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" onclick="submitRepairRequest()">신청</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 외부반출 모달 -->
|
||||
<div id="exportModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 500px;">
|
||||
<div class="modal-header">
|
||||
<h2>외부 반출</h2>
|
||||
<button class="btn-close" onclick="closeExportModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="isRepairExport" onchange="toggleRepairFields()">
|
||||
<span>수리 외주 (외부 수리)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>반출일</label>
|
||||
<input type="date" id="exportDate" class="form-control">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>반입 예정일</label>
|
||||
<input type="date" id="expectedReturnDate" class="form-control">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>반출처 (업체명)</label>
|
||||
<input type="text" id="exportDestination" class="form-control" placeholder="예: 삼성정비">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>반출 사유</label>
|
||||
<textarea id="exportReason" class="form-control" rows="2" placeholder="반출 사유를 입력하세요"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>비고</label>
|
||||
<textarea id="exportNotes" class="form-control" rows="2" placeholder="기타 메모사항"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeExportModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" onclick="submitExport()">반출 등록</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 반입 모달 -->
|
||||
<div id="returnModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 400px;">
|
||||
<div class="modal-header">
|
||||
<h2>설비 반입</h2>
|
||||
<button class="btn-close" onclick="closeReturnModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="returnLogId">
|
||||
<div class="form-group">
|
||||
<label>반입일</label>
|
||||
<input type="date" id="returnDate" class="form-control">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>반입 후 상태</label>
|
||||
<select id="returnStatus" class="form-control">
|
||||
<option value="active">정상 가동</option>
|
||||
<option value="maintenance">점검 필요</option>
|
||||
<option value="repair_needed">추가 수리 필요</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>비고</label>
|
||||
<textarea id="returnNotes" class="form-control" rows="2" placeholder="반입 관련 메모"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeReturnModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" onclick="submitReturn()">반입 처리</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 사진 확대 모달 -->
|
||||
<div id="photoViewModal" class="modal-overlay" style="display: none;">
|
||||
<div class="photo-view-container" onclick="closePhotoView()">
|
||||
<button class="photo-view-close">×</button>
|
||||
<img id="photoViewImage" class="photo-view-image">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script type="module">
|
||||
import '/js/api-config.js?v=3';
|
||||
</script>
|
||||
<script>
|
||||
(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 src="/js/equipment-detail.js?v=1"></script>
|
||||
</body>
|
||||
</html>
|
||||
220
deploy/tkfb-package/web-ui/pages/admin/equipments.html
Normal file
220
deploy/tkfb-package/web-ui/pages/admin/equipments.html
Normal file
@@ -0,0 +1,220 @@
|
||||
<!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=8">
|
||||
<link rel="stylesheet" href="/css/equipment-management.css?v=1">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 레이아웃 -->
|
||||
<div class="page-container">
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="main-content">
|
||||
<div class="dashboard-main">
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">설비 관리</h1>
|
||||
<p class="page-description">작업장별 설비 정보를 등록하고 관리합니다</p>
|
||||
</div>
|
||||
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-primary" onclick="openEquipmentModal()">
|
||||
<span>+ 설비 추가</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 통계 요약 -->
|
||||
<div id="statsSection" class="eq-stats-section">
|
||||
<!-- JS에서 동적으로 렌더링 -->
|
||||
</div>
|
||||
|
||||
<!-- 필터 영역 -->
|
||||
<div class="eq-filter-section">
|
||||
<div class="eq-filter-group">
|
||||
<label for="filterWorkplace">작업장</label>
|
||||
<select id="filterWorkplace" class="form-control" onchange="filterEquipments()">
|
||||
<option value="">전체</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="eq-filter-group">
|
||||
<label for="filterType">설비 유형</label>
|
||||
<select id="filterType" class="form-control" onchange="filterEquipments()">
|
||||
<option value="">전체</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="eq-filter-group">
|
||||
<label for="filterStatus">상태</label>
|
||||
<select id="filterStatus" class="form-control" onchange="filterEquipments()">
|
||||
<option value="">전체</option>
|
||||
<option value="active">활성</option>
|
||||
<option value="maintenance">정비중</option>
|
||||
<option value="inactive">비활성</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="eq-filter-group eq-search-group">
|
||||
<label for="searchInput">검색</label>
|
||||
<input type="text" id="searchInput" class="form-control" placeholder="설비명, 코드, 제조사 검색..." oninput="filterEquipments()">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 설비 목록 -->
|
||||
<div id="equipmentList" class="eq-table-container">
|
||||
<!-- 설비 목록이 여기에 동적으로 렌더링됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 설비 추가/수정 모달 -->
|
||||
<div id="equipmentModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 720px;">
|
||||
<div class="modal-header">
|
||||
<h2 id="modalTitle">설비 추가</h2>
|
||||
<button class="btn-close" onclick="closeEquipmentModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body eq-modal-body">
|
||||
<form id="equipmentForm">
|
||||
<input type="hidden" id="equipmentId">
|
||||
|
||||
<!-- 기본 정보 -->
|
||||
<div class="eq-form-section">
|
||||
<div class="eq-form-section-title">기본 정보</div>
|
||||
<div class="eq-form-row">
|
||||
<div class="form-group">
|
||||
<label for="equipmentCode">관리번호 *</label>
|
||||
<input type="text" id="equipmentCode" class="form-control" placeholder="예: TKP-001" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="equipmentName">설비명 *</label>
|
||||
<input type="text" id="equipmentName" class="form-control" placeholder="예: TIG용접기" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="eq-form-row">
|
||||
<div class="form-group">
|
||||
<label for="modelName">모델명</label>
|
||||
<input type="text" id="modelName" class="form-control" placeholder="예: Perfect-500PT">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="specifications">규격</label>
|
||||
<input type="text" id="specifications" class="form-control" placeholder="예: 500A/DC">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 제조사 및 구입 정보 -->
|
||||
<div class="eq-form-section">
|
||||
<div class="eq-form-section-title">제조사 및 구입 정보</div>
|
||||
<div class="eq-form-row">
|
||||
<div class="form-group">
|
||||
<label for="manufacturer">제조사 (메이커)</label>
|
||||
<input type="text" id="manufacturer" class="form-control" placeholder="예: 퍼펙트대대">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="supplier">구입처</label>
|
||||
<input type="text" id="supplier" class="form-control" placeholder="예: 현대용접기">
|
||||
</div>
|
||||
</div>
|
||||
<div class="eq-form-row">
|
||||
<div class="form-group">
|
||||
<label for="purchasePrice">구입가격 (원)</label>
|
||||
<input type="number" id="purchasePrice" class="form-control" placeholder="예: 1600000">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="installationDate">구입일자</label>
|
||||
<input type="date" id="installationDate" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 상세 정보 -->
|
||||
<div class="eq-form-section">
|
||||
<div class="eq-form-section-title">상세 정보</div>
|
||||
<div class="eq-form-row">
|
||||
<div class="form-group">
|
||||
<label for="serialNumber">시리얼 번호 (S/N)</label>
|
||||
<input type="text" id="serialNumber" class="form-control">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="equipmentStatus">상태</label>
|
||||
<select id="equipmentStatus" class="form-control">
|
||||
<option value="active">활성</option>
|
||||
<option value="maintenance">정비중</option>
|
||||
<option value="inactive">비활성</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="eq-form-row">
|
||||
<div class="form-group">
|
||||
<label for="equipmentType">설비 유형</label>
|
||||
<input type="text" id="equipmentType" class="form-control" placeholder="예: 용접기, 크레인 등">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="workplaceId">작업장</label>
|
||||
<select id="workplaceId" class="form-control">
|
||||
<option value="">선택 안함</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="notes">비고</label>
|
||||
<textarea id="notes" class="form-control" rows="2" placeholder="기타 메모사항"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeEquipmentModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveEquipment()">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script type="module">
|
||||
import '/js/api-config.js?v=3';
|
||||
</script>
|
||||
<script>
|
||||
(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 src="/js/equipment-management.js?v=8"></script>
|
||||
</body>
|
||||
</html>
|
||||
321
deploy/tkfb-package/web-ui/pages/admin/issue-categories.html
Normal file
321
deploy/tkfb-package/web-ui/pages/admin/issue-categories.html
Normal file
@@ -0,0 +1,321 @@
|
||||
<!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=8">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
.type-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.type-tab {
|
||||
padding: 12px 24px;
|
||||
background: var(--gray-100);
|
||||
border: 2px solid var(--gray-200);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: var(--gray-600);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.type-tab:hover {
|
||||
background: var(--gray-200);
|
||||
}
|
||||
.type-tab.active {
|
||||
background: var(--primary-600);
|
||||
border-color: var(--primary-600);
|
||||
color: white;
|
||||
}
|
||||
.type-tab.safety.active {
|
||||
background: var(--yellow-500);
|
||||
border-color: var(--yellow-500);
|
||||
}
|
||||
|
||||
.category-section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.category-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
background: var(--gray-100);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
}
|
||||
.category-header:hover {
|
||||
background: var(--gray-200);
|
||||
}
|
||||
.category-name {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--gray-800);
|
||||
}
|
||||
.category-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.severity-badge {
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.severity-badge.low { background: var(--gray-200); color: var(--gray-700); }
|
||||
.severity-badge.medium { background: var(--yellow-100); color: var(--yellow-700); }
|
||||
.severity-badge.high { background: var(--orange-100); color: var(--orange-700); }
|
||||
.severity-badge.critical { background: var(--red-100); color: var(--red-700); }
|
||||
|
||||
.item-count {
|
||||
font-size: 0.875rem;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
.category-items {
|
||||
display: none;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--gray-200);
|
||||
border-top: none;
|
||||
border-radius: 0 0 var(--radius-md) var(--radius-md);
|
||||
}
|
||||
.category-section.expanded .category-items {
|
||||
display: block;
|
||||
}
|
||||
.category-section.expanded .category-header {
|
||||
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||
}
|
||||
|
||||
.item-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.item-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: white;
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.item-info {
|
||||
flex: 1;
|
||||
}
|
||||
.item-name {
|
||||
font-weight: 500;
|
||||
color: var(--gray-800);
|
||||
}
|
||||
.item-desc {
|
||||
font-size: 0.85rem;
|
||||
color: var(--gray-500);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.item-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.add-item-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed var(--gray-300);
|
||||
}
|
||||
.add-item-form input {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--gray-300);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
/* 모달 */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.modal-container {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--gray-200);
|
||||
}
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
.modal-body {
|
||||
padding: 24px;
|
||||
}
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
color: var(--gray-700);
|
||||
}
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--gray-300);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-500);
|
||||
box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.1);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="page-container">
|
||||
<main class="main-content">
|
||||
<div class="dashboard-main">
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">신고 카테고리 관리</h1>
|
||||
<p class="page-description">부적합 및 안전 신고에 사용되는 카테고리와 항목을 관리합니다</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-primary" onclick="openCategoryModal()">새 카테고리 추가</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 유형 탭 -->
|
||||
<div class="type-tabs">
|
||||
<button class="type-tab active" data-type="nonconformity" onclick="switchType('nonconformity')">부적합</button>
|
||||
<button class="type-tab safety" data-type="safety" onclick="switchType('safety')">안전</button>
|
||||
</div>
|
||||
|
||||
<!-- 카테고리 목록 -->
|
||||
<div id="categoryList">
|
||||
<div class="empty-state">카테고리를 불러오는 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 카테고리 추가/수정 모달 -->
|
||||
<div id="categoryModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h2 id="categoryModalTitle">새 카테고리</h2>
|
||||
<button class="btn btn-secondary btn-sm" onclick="closeCategoryModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="categoryForm" onsubmit="event.preventDefault(); saveCategory();">
|
||||
<input type="hidden" id="categoryId">
|
||||
<div class="form-group">
|
||||
<label class="form-label">카테고리 이름 *</label>
|
||||
<input type="text" id="categoryName" class="form-control" placeholder="예: 자재 불량" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">설명</label>
|
||||
<textarea id="categoryDescription" class="form-control" rows="3" placeholder="카테고리 설명"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">심각도</label>
|
||||
<select id="categorySeverity" class="form-control">
|
||||
<option value="low">낮음</option>
|
||||
<option value="medium">보통</option>
|
||||
<option value="high">높음</option>
|
||||
<option value="critical">심각</option>
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeCategoryModal()">취소</button>
|
||||
<button type="button" class="btn btn-danger" id="deleteCategoryBtn" onclick="deleteCategory()" style="display: none;">삭제</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveCategory()">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 항목 추가/수정 모달 -->
|
||||
<div id="itemModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h2 id="itemModalTitle">새 항목</h2>
|
||||
<button class="btn btn-secondary btn-sm" onclick="closeItemModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="itemForm" onsubmit="event.preventDefault(); saveItem();">
|
||||
<input type="hidden" id="itemId">
|
||||
<input type="hidden" id="itemCategoryId">
|
||||
<div class="form-group">
|
||||
<label class="form-label">항목 이름 *</label>
|
||||
<input type="text" id="itemName" class="form-control" placeholder="예: 용접봉 품질 미달" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">설명</label>
|
||||
<textarea id="itemDescription" class="form-control" rows="3" placeholder="항목 설명"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">심각도</label>
|
||||
<select id="itemSeverity" class="form-control">
|
||||
<option value="low">낮음</option>
|
||||
<option value="medium">보통</option>
|
||||
<option value="high">높음</option>
|
||||
<option value="critical">심각</option>
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeItemModal()">취소</button>
|
||||
<button type="button" class="btn btn-danger" id="deleteItemBtn" onclick="deleteItem()" style="display: none;">삭제</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveItem()">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/issue-category-manage.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
554
deploy/tkfb-package/web-ui/pages/admin/notifications.html
Normal file
554
deploy/tkfb-package/web-ui/pages/admin/notifications.html
Normal file
@@ -0,0 +1,554 @@
|
||||
<!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=8">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
.notification-page-container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.notification-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.notification-header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.btn-mark-all {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--primary-500);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-mark-all:hover {
|
||||
background: var(--primary-600);
|
||||
}
|
||||
|
||||
.notification-stats {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
flex: 1;
|
||||
background: white;
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-card.unread .stat-value {
|
||||
color: var(--primary-500);
|
||||
}
|
||||
|
||||
.notification-list-container {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--border-light);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.notification-list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.notification-list-header h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.filter-tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-tab {
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.filter-tab:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.filter-tab.active {
|
||||
background: var(--primary-500);
|
||||
color: white;
|
||||
border-color: var(--primary-500);
|
||||
}
|
||||
|
||||
.notification-list {
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.notification-item:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.notification-item.unread {
|
||||
background: rgba(14, 165, 233, 0.05);
|
||||
}
|
||||
|
||||
.notification-item.unread::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background: var(--primary-500);
|
||||
}
|
||||
|
||||
.notification-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: var(--radius-full);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notification-icon.repair {
|
||||
background: var(--warning-100);
|
||||
}
|
||||
|
||||
.notification-icon.safety {
|
||||
background: var(--error-100);
|
||||
}
|
||||
|
||||
.notification-icon.system {
|
||||
background: var(--primary-100);
|
||||
}
|
||||
|
||||
.notification-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notification-title {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.notification-message {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.notification-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.notification-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-action:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.btn-action.danger:hover {
|
||||
background: var(--error-50);
|
||||
color: var(--error-600);
|
||||
border-color: var(--error-200);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 3rem;
|
||||
text-align: center;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.pagination-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: white;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.pagination-btn:hover:not(:disabled) {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.pagination-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.notification-stats {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.notification-header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.notification-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="navbar-placeholder"></div>
|
||||
<div id="sidebar-placeholder"></div>
|
||||
|
||||
<main class="main-content">
|
||||
<div class="notification-page-container">
|
||||
<div class="notification-header">
|
||||
<h1>알림 관리</h1>
|
||||
<div class="header-actions">
|
||||
<button class="btn-mark-all" onclick="markAllAsRead()">
|
||||
<span>모두 읽음 처리</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="notification-stats">
|
||||
<div class="stat-card unread">
|
||||
<div class="stat-value" id="unreadCount">0</div>
|
||||
<div class="stat-label">읽지 않은 알림</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="totalCount">0</div>
|
||||
<div class="stat-label">전체 알림</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="notification-list-container">
|
||||
<div class="notification-list-header">
|
||||
<h2>알림 목록</h2>
|
||||
<div class="filter-tabs">
|
||||
<button class="filter-tab active" data-filter="all" onclick="setFilter('all')">전체</button>
|
||||
<button class="filter-tab" data-filter="unread" onclick="setFilter('unread')">읽지 않음</button>
|
||||
<button class="filter-tab" data-filter="repair" onclick="setFilter('repair')">수리</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="notification-list" id="notificationList">
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">🔔</div>
|
||||
<p>알림이 없습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pagination" id="pagination" style="display: none;">
|
||||
<button class="pagination-btn" id="prevBtn" onclick="changePage(-1)">이전</button>
|
||||
<span class="pagination-info" id="pageInfo">1 / 1</span>
|
||||
<button class="pagination-btn" id="nextBtn" onclick="changePage(1)">다음</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="/js/load-sidebar.js"></script>
|
||||
<script>
|
||||
let currentPage = 1;
|
||||
let totalPages = 1;
|
||||
let currentFilter = 'all';
|
||||
let allNotifications = [];
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadNotifications();
|
||||
});
|
||||
|
||||
async function loadNotifications() {
|
||||
try {
|
||||
const response = await window.apiCall(`/notifications?page=${currentPage}&limit=20`);
|
||||
if (response.success) {
|
||||
allNotifications = response.data || [];
|
||||
totalPages = response.pagination?.totalPages || 1;
|
||||
|
||||
updateStats();
|
||||
renderNotifications();
|
||||
updatePagination();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('알림 로드 오류:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
const unreadCount = allNotifications.filter(n => !n.is_read).length;
|
||||
document.getElementById('unreadCount').textContent = unreadCount;
|
||||
document.getElementById('totalCount').textContent = allNotifications.length;
|
||||
}
|
||||
|
||||
function renderNotifications() {
|
||||
const list = document.getElementById('notificationList');
|
||||
|
||||
// 필터 적용
|
||||
let filtered = allNotifications;
|
||||
if (currentFilter === 'unread') {
|
||||
filtered = allNotifications.filter(n => !n.is_read);
|
||||
} else if (currentFilter === 'repair') {
|
||||
filtered = allNotifications.filter(n => n.type === 'repair');
|
||||
}
|
||||
|
||||
if (filtered.length === 0) {
|
||||
list.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">🔔</div>
|
||||
<p>알림이 없습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = filtered.map(n => `
|
||||
<div class="notification-item ${n.is_read ? '' : 'unread'}" data-id="${n.notification_id}">
|
||||
<div class="notification-icon ${n.type || 'system'}">
|
||||
${getNotificationIcon(n.type)}
|
||||
</div>
|
||||
<div class="notification-content" onclick="handleNotificationClick(${n.notification_id}, '${n.link_url || ''}')">
|
||||
<div class="notification-title">${escapeHtml(n.title)}</div>
|
||||
<div class="notification-message">${escapeHtml(n.message || '')}</div>
|
||||
<div class="notification-meta">
|
||||
<span>${formatDateTime(n.created_at)}</span>
|
||||
<span>${n.is_read ? '읽음' : '읽지 않음'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="notification-actions">
|
||||
${!n.is_read ? `<button class="btn-action" onclick="markAsRead(${n.notification_id})">읽음</button>` : ''}
|
||||
<button class="btn-action danger" onclick="deleteNotification(${n.notification_id})">삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function getNotificationIcon(type) {
|
||||
const icons = {
|
||||
repair: '🔧',
|
||||
safety: '⚠️',
|
||||
system: '📢',
|
||||
equipment: '🔩',
|
||||
maintenance: '🛠️'
|
||||
};
|
||||
return icons[type] || '🔔';
|
||||
}
|
||||
|
||||
function formatDateTime(dateString) {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function setFilter(filter) {
|
||||
currentFilter = filter;
|
||||
document.querySelectorAll('.filter-tab').forEach(tab => {
|
||||
tab.classList.toggle('active', tab.dataset.filter === filter);
|
||||
});
|
||||
renderNotifications();
|
||||
}
|
||||
|
||||
async function handleNotificationClick(notificationId, linkUrl) {
|
||||
await markAsRead(notificationId);
|
||||
if (linkUrl) {
|
||||
window.location.href = linkUrl;
|
||||
}
|
||||
}
|
||||
|
||||
async function markAsRead(notificationId) {
|
||||
try {
|
||||
const response = await window.apiCall(`/notifications/${notificationId}/read`, { method: 'POST' });
|
||||
if (response.success) {
|
||||
const notification = allNotifications.find(n => n.notification_id === notificationId);
|
||||
if (notification) {
|
||||
notification.is_read = true;
|
||||
}
|
||||
updateStats();
|
||||
renderNotifications();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('알림 읽음 처리 오류:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function markAllAsRead() {
|
||||
try {
|
||||
const response = await window.apiCall('/notifications/read-all', { method: 'POST' });
|
||||
if (response.success) {
|
||||
allNotifications.forEach(n => n.is_read = true);
|
||||
updateStats();
|
||||
renderNotifications();
|
||||
alert('모든 알림을 읽음 처리했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('전체 읽음 처리 오류:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteNotification(notificationId) {
|
||||
if (!confirm('이 알림을 삭제하시겠습니까?')) return;
|
||||
|
||||
try {
|
||||
const response = await window.apiCall(`/notifications/${notificationId}`, { method: 'DELETE' });
|
||||
if (response.success) {
|
||||
allNotifications = allNotifications.filter(n => n.notification_id !== notificationId);
|
||||
updateStats();
|
||||
renderNotifications();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('알림 삭제 오류:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function updatePagination() {
|
||||
const pagination = document.getElementById('pagination');
|
||||
const pageInfo = document.getElementById('pageInfo');
|
||||
const prevBtn = document.getElementById('prevBtn');
|
||||
const nextBtn = document.getElementById('nextBtn');
|
||||
|
||||
if (totalPages <= 1) {
|
||||
pagination.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
pagination.style.display = 'flex';
|
||||
pageInfo.textContent = `${currentPage} / ${totalPages}`;
|
||||
prevBtn.disabled = currentPage <= 1;
|
||||
nextBtn.disabled = currentPage >= totalPages;
|
||||
}
|
||||
|
||||
function changePage(delta) {
|
||||
const newPage = currentPage + delta;
|
||||
if (newPage >= 1 && newPage <= totalPages) {
|
||||
currentPage = newPage;
|
||||
loadNotifications();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
687
deploy/tkfb-package/web-ui/pages/admin/projects.html
Normal file
687
deploy/tkfb-package/web-ui/pages/admin/projects.html
Normal file
@@ -0,0 +1,687 @@
|
||||
<!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=8">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<style>
|
||||
.page-wrapper {
|
||||
padding: 1rem 1.5rem;
|
||||
max-width: 1600px;
|
||||
}
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.page-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
.header-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.search-input {
|
||||
padding: 0.4rem 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
width: 200px;
|
||||
}
|
||||
.filter-select {
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
min-width: 100px;
|
||||
}
|
||||
.btn {
|
||||
padding: 0.4rem 0.75rem;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.btn-primary { background: #3b82f6; color: white; }
|
||||
.btn-outline { background: white; border: 1px solid #d1d5db; }
|
||||
.btn-danger { background: #ef4444; color: white; }
|
||||
.btn-sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; }
|
||||
|
||||
/* 통계 바 */
|
||||
.stats-bar {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
padding: 0.5rem 0;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: #6b7280;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
.stats-bar .stat {
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.stats-bar .stat:hover { background: #f3f4f6; }
|
||||
.stats-bar .stat.active { background: #dbeafe; color: #1d4ed8; }
|
||||
.stats-bar .stat strong { font-weight: 600; }
|
||||
|
||||
/* 테이블 */
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8rem;
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
.data-table th, .data-table td {
|
||||
padding: 0.5rem 0.6rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.data-table th {
|
||||
background: #f9fafb;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
font-size: 0.75rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
.data-table tr:hover { background: #f9fafb; }
|
||||
.data-table tr.inactive { background: #fef2f2; opacity: 0.7; }
|
||||
.data-table .job-no {
|
||||
font-family: monospace;
|
||||
color: #6b7280;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.data-table .project-name {
|
||||
font-weight: 500;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.status-planning { background: #f3f4f6; color: #6b7280; }
|
||||
.status-active { background: #dcfce7; color: #166534; }
|
||||
.status-completed { background: #dbeafe; color: #1e40af; }
|
||||
.status-cancelled { background: #fee2e2; color: #dc2626; }
|
||||
.inactive-badge {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.3rem;
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
border-radius: 0.2rem;
|
||||
font-size: 0.65rem;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
.action-btns {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.action-btns button {
|
||||
padding: 0.2rem 0.4rem;
|
||||
font-size: 0.7rem;
|
||||
border: 1px solid #d1d5db;
|
||||
background: white;
|
||||
border-radius: 0.2rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.action-btns button:hover { background: #f3f4f6; }
|
||||
.action-btns .btn-edit { color: #3b82f6; }
|
||||
.action-btns .btn-del { color: #ef4444; }
|
||||
|
||||
.empty-row td {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
.table-wrapper {
|
||||
max-height: calc(100vh - 280px);
|
||||
overflow-y: auto;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
/* 모달 */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.modal-container {
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
width: 600px;
|
||||
max-width: 95vw;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
.modal-header h2 { font-size: 1rem; font-weight: 600; margin: 0; }
|
||||
.modal-close { background: none; border: none; font-size: 1.25rem; cursor: pointer; color: #6b7280; }
|
||||
.modal-body { padding: 1rem; }
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.form-group { margin-bottom: 0.75rem; }
|
||||
.form-group:last-child { margin-bottom: 0; }
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.form-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.form-hint {
|
||||
font-size: 0.7rem;
|
||||
color: #6b7280;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="has-sidebar">
|
||||
<div id="navbar-container"></div>
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<main class="main-content">
|
||||
<div class="page-wrapper">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">프로젝트 관리</h1>
|
||||
<div class="header-controls">
|
||||
<input type="text" id="searchInput" class="search-input" placeholder="검색 (Job No., 프로젝트명, PM)">
|
||||
<select id="statusFilter" class="filter-select" onchange="filterProjects()">
|
||||
<option value="">전체 상태</option>
|
||||
<option value="planning">계획</option>
|
||||
<option value="active">진행중</option>
|
||||
<option value="completed">완료</option>
|
||||
<option value="cancelled">취소</option>
|
||||
</select>
|
||||
<select id="sortBy" class="filter-select" onchange="sortProjects()">
|
||||
<option value="created_at">등록일순</option>
|
||||
<option value="project_name">이름순</option>
|
||||
<option value="due_date">납기일순</option>
|
||||
</select>
|
||||
<button class="btn btn-outline" onclick="refreshProjectList()">새로고침</button>
|
||||
<button class="btn btn-primary" onclick="openProjectModal()">+ 새 프로젝트</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-bar">
|
||||
<span class="stat active" data-filter="all" onclick="filterByStatus('all')">전체 <strong id="totalProjects">0</strong></span>
|
||||
<span class="stat" data-filter="active" onclick="filterByStatus('active')">활성 <strong id="activeProjects">0</strong></span>
|
||||
<span class="stat" data-filter="inactive" onclick="filterByStatus('inactive')">비활성 <strong id="inactiveProjects">0</strong></span>
|
||||
</div>
|
||||
|
||||
<div class="table-wrapper">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:30px">#</th>
|
||||
<th style="width:120px">Job No.</th>
|
||||
<th>프로젝트명</th>
|
||||
<th style="width:70px">상태</th>
|
||||
<th style="width:90px">계약일</th>
|
||||
<th style="width:90px">납기일</th>
|
||||
<th style="width:80px">PM</th>
|
||||
<th>현장</th>
|
||||
<th style="width:70px">활성</th>
|
||||
<th style="width:80px">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="projectsTableBody">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 프로젝트 모달 -->
|
||||
<div id="projectModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h2 id="modalTitle">새 프로젝트 등록</h2>
|
||||
<button class="modal-close" onclick="closeProjectModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="projectForm">
|
||||
<input type="hidden" id="projectId">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Job No. *</label>
|
||||
<input type="text" id="jobNo" class="form-control" required placeholder="TK-2024-001">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">프로젝트명 *</label>
|
||||
<input type="text" id="projectName" class="form-control" required placeholder="프로젝트명">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">계약일</label>
|
||||
<input type="date" id="contractDate" class="form-control">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">납기일</label>
|
||||
<input type="date" id="dueDate" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">PM</label>
|
||||
<input type="text" id="pm" class="form-control" placeholder="담당 PM">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">현장</label>
|
||||
<input type="text" id="site" class="form-control" placeholder="현장 위치">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">납품방법</label>
|
||||
<select id="deliveryMethod" class="form-control">
|
||||
<option value="">선택</option>
|
||||
<option value="직접납품">직접납품</option>
|
||||
<option value="택배">택배</option>
|
||||
<option value="화물">화물</option>
|
||||
<option value="현장설치">현장설치</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">프로젝트 상태</label>
|
||||
<select id="projectStatus" class="form-control">
|
||||
<option value="planning">계획</option>
|
||||
<option value="active" selected>진행중</option>
|
||||
<option value="completed">완료</option>
|
||||
<option value="cancelled">취소</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">완료일</label>
|
||||
<input type="date" id="completedDate" class="form-control">
|
||||
</div>
|
||||
<div class="form-group" style="display:flex;align-items:flex-end;">
|
||||
<label class="form-check">
|
||||
<input type="checkbox" id="isActive" checked>
|
||||
<span>프로젝트 활성화</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p class="form-hint">* 비활성화 시 작업보고서 입력에서 숨겨집니다</p>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline" onclick="closeProjectModal()">취소</button>
|
||||
<button type="button" class="btn btn-danger" id="deleteProjectBtn" onclick="deleteProject()" style="display:none;">삭제</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveProject()">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script>
|
||||
let allProjects = [];
|
||||
let filteredProjects = [];
|
||||
let currentEditingProject = null;
|
||||
let currentStatusFilter = 'all';
|
||||
let currentProjectStatusFilter = '';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadProjects();
|
||||
setupSearchInput();
|
||||
});
|
||||
|
||||
function setupSearchInput() {
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', () => applyAllFilters());
|
||||
searchInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') applyAllFilters();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const response = await apiCall('/projects', 'GET');
|
||||
let projectData = [];
|
||||
if (response && response.success && Array.isArray(response.data)) {
|
||||
projectData = response.data;
|
||||
} else if (Array.isArray(response)) {
|
||||
projectData = response;
|
||||
}
|
||||
allProjects = projectData;
|
||||
applyAllFilters();
|
||||
updateStatCardActiveState();
|
||||
} catch (error) {
|
||||
console.error('프로젝트 로딩 오류:', error);
|
||||
allProjects = [];
|
||||
filteredProjects = [];
|
||||
renderProjects();
|
||||
}
|
||||
}
|
||||
|
||||
function renderProjects() {
|
||||
const tbody = document.getElementById('projectsTableBody');
|
||||
if (!tbody) return;
|
||||
|
||||
if (filteredProjects.length === 0) {
|
||||
tbody.innerHTML = '<tr class="empty-row"><td colspan="10">등록된 프로젝트가 없습니다</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
const statusMap = {
|
||||
'planning': { text: '계획', class: 'status-planning' },
|
||||
'active': { text: '진행중', class: 'status-active' },
|
||||
'completed': { text: '완료', class: 'status-completed' },
|
||||
'cancelled': { text: '취소', class: 'status-cancelled' }
|
||||
};
|
||||
|
||||
tbody.innerHTML = filteredProjects.map((p, idx) => {
|
||||
const status = statusMap[p.project_status] || statusMap['active'];
|
||||
const isInactive = p.is_active === 0 || p.is_active === false;
|
||||
const rowClass = isInactive ? 'inactive' : '';
|
||||
|
||||
return `
|
||||
<tr class="${rowClass}">
|
||||
<td>${idx + 1}</td>
|
||||
<td class="job-no">${p.job_no || '-'}</td>
|
||||
<td class="project-name" title="${p.project_name}">
|
||||
${p.project_name}
|
||||
${isInactive ? '<span class="inactive-badge">비활성</span>' : ''}
|
||||
</td>
|
||||
<td><span class="status-badge ${status.class}">${status.text}</span></td>
|
||||
<td>${formatDate(p.contract_date)}</td>
|
||||
<td>${formatDate(p.due_date)}</td>
|
||||
<td>${p.pm || '-'}</td>
|
||||
<td>${p.site || '-'}</td>
|
||||
<td>${isInactive ? '비활성' : '활성'}</td>
|
||||
<td>
|
||||
<div class="action-btns">
|
||||
<button class="btn-edit" onclick="editProject(${p.project_id})">수정</button>
|
||||
<button class="btn-del" onclick="confirmDeleteProject(${p.project_id})">삭제</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return '-';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('ko-KR', { year: '2-digit', month: '2-digit', day: '2-digit' });
|
||||
}
|
||||
|
||||
function filterByStatus(status) {
|
||||
currentStatusFilter = status;
|
||||
updateStatCardActiveState();
|
||||
applyAllFilters();
|
||||
}
|
||||
|
||||
function updateStatCardActiveState() {
|
||||
document.querySelectorAll('.stats-bar .stat').forEach(item => {
|
||||
item.classList.remove('active');
|
||||
if (item.dataset.filter === currentStatusFilter) {
|
||||
item.classList.add('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function filterProjects() {
|
||||
currentProjectStatusFilter = document.getElementById('statusFilter').value;
|
||||
applyAllFilters();
|
||||
}
|
||||
|
||||
function applyAllFilters() {
|
||||
const searchTerm = (document.getElementById('searchInput')?.value || '').toLowerCase().trim();
|
||||
|
||||
// 1. is_active 필터
|
||||
let result = [...allProjects];
|
||||
if (currentStatusFilter === 'active') {
|
||||
result = result.filter(p => p.is_active === 1 || p.is_active === true);
|
||||
} else if (currentStatusFilter === 'inactive') {
|
||||
result = result.filter(p => p.is_active === 0 || p.is_active === false);
|
||||
}
|
||||
|
||||
// 2. project_status 필터
|
||||
if (currentProjectStatusFilter) {
|
||||
result = result.filter(p => p.project_status === currentProjectStatusFilter);
|
||||
}
|
||||
|
||||
// 3. 검색
|
||||
if (searchTerm) {
|
||||
result = result.filter(p =>
|
||||
(p.project_name && p.project_name.toLowerCase().includes(searchTerm)) ||
|
||||
(p.job_no && p.job_no.toLowerCase().includes(searchTerm)) ||
|
||||
(p.pm && p.pm.toLowerCase().includes(searchTerm)) ||
|
||||
(p.site && p.site.toLowerCase().includes(searchTerm))
|
||||
);
|
||||
}
|
||||
|
||||
filteredProjects = result;
|
||||
renderProjects();
|
||||
updateProjectStats();
|
||||
}
|
||||
|
||||
function sortProjects() {
|
||||
const sortField = document.getElementById('sortBy')?.value || 'created_at';
|
||||
filteredProjects.sort((a, b) => {
|
||||
switch (sortField) {
|
||||
case 'project_name':
|
||||
return (a.project_name || '').localeCompare(b.project_name || '');
|
||||
case 'due_date':
|
||||
if (!a.due_date && !b.due_date) return 0;
|
||||
if (!a.due_date) return 1;
|
||||
if (!b.due_date) return -1;
|
||||
return new Date(a.due_date) - new Date(b.due_date);
|
||||
default:
|
||||
return new Date(b.created_at || 0) - new Date(a.created_at || 0);
|
||||
}
|
||||
});
|
||||
renderProjects();
|
||||
}
|
||||
|
||||
function updateProjectStats() {
|
||||
const active = allProjects.filter(p => p.is_active === 1 || p.is_active === true).length;
|
||||
const inactive = allProjects.filter(p => p.is_active === 0 || p.is_active === false).length;
|
||||
document.getElementById('totalProjects').textContent = allProjects.length;
|
||||
document.getElementById('activeProjects').textContent = active;
|
||||
document.getElementById('inactiveProjects').textContent = inactive;
|
||||
}
|
||||
|
||||
async function refreshProjectList() {
|
||||
await loadProjects();
|
||||
showToast('새로고침 완료');
|
||||
}
|
||||
|
||||
function openProjectModal(project = null) {
|
||||
const modal = document.getElementById('projectModal');
|
||||
const modalTitle = document.getElementById('modalTitle');
|
||||
const deleteBtn = document.getElementById('deleteProjectBtn');
|
||||
currentEditingProject = project;
|
||||
|
||||
if (project) {
|
||||
modalTitle.textContent = '프로젝트 수정';
|
||||
deleteBtn.style.display = 'inline-block';
|
||||
document.getElementById('projectId').value = project.project_id;
|
||||
document.getElementById('jobNo').value = project.job_no || '';
|
||||
document.getElementById('projectName').value = project.project_name || '';
|
||||
document.getElementById('contractDate').value = project.contract_date || '';
|
||||
document.getElementById('dueDate').value = project.due_date || '';
|
||||
document.getElementById('deliveryMethod').value = project.delivery_method || '';
|
||||
document.getElementById('site').value = project.site || '';
|
||||
document.getElementById('pm').value = project.pm || '';
|
||||
document.getElementById('projectStatus').value = project.project_status || 'active';
|
||||
document.getElementById('completedDate').value = project.completed_date || '';
|
||||
document.getElementById('isActive').checked = project.is_active === 1 || project.is_active === true;
|
||||
} else {
|
||||
modalTitle.textContent = '새 프로젝트 등록';
|
||||
deleteBtn.style.display = 'none';
|
||||
document.getElementById('projectForm').reset();
|
||||
document.getElementById('projectId').value = '';
|
||||
document.getElementById('isActive').checked = true;
|
||||
}
|
||||
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeProjectModal() {
|
||||
document.getElementById('projectModal').style.display = 'none';
|
||||
currentEditingProject = null;
|
||||
}
|
||||
|
||||
function editProject(projectId) {
|
||||
const project = allProjects.find(p => p.project_id === projectId);
|
||||
if (project) openProjectModal(project);
|
||||
}
|
||||
|
||||
async function saveProject() {
|
||||
const projectData = {
|
||||
job_no: document.getElementById('jobNo').value.trim(),
|
||||
project_name: document.getElementById('projectName').value.trim(),
|
||||
contract_date: document.getElementById('contractDate').value || null,
|
||||
due_date: document.getElementById('dueDate').value || null,
|
||||
delivery_method: document.getElementById('deliveryMethod').value || null,
|
||||
site: document.getElementById('site').value.trim() || null,
|
||||
pm: document.getElementById('pm').value.trim() || null,
|
||||
project_status: document.getElementById('projectStatus').value || 'active',
|
||||
completed_date: document.getElementById('completedDate').value || null,
|
||||
is_active: document.getElementById('isActive').checked ? 1 : 0
|
||||
};
|
||||
|
||||
if (!projectData.job_no || !projectData.project_name) {
|
||||
showToast('Job No.와 프로젝트명은 필수입니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const projectId = document.getElementById('projectId').value;
|
||||
let response;
|
||||
if (projectId) {
|
||||
response = await apiCall(`/projects/${projectId}`, 'PUT', projectData);
|
||||
} else {
|
||||
response = await apiCall('/projects', 'POST', projectData);
|
||||
}
|
||||
|
||||
if (response && (response.success || response.project_id)) {
|
||||
showToast(projectId ? '수정 완료' : '등록 완료');
|
||||
closeProjectModal();
|
||||
await loadProjects();
|
||||
} else {
|
||||
throw new Error(response?.message || '저장 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(error.message || '저장 중 오류', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteProject(projectId) {
|
||||
const project = allProjects.find(p => p.project_id === projectId);
|
||||
if (!project) return;
|
||||
if (confirm(`"${project.project_name}" 프로젝트를 삭제하시겠습니까?`)) {
|
||||
deleteProjectById(projectId);
|
||||
}
|
||||
}
|
||||
|
||||
function deleteProject() {
|
||||
if (currentEditingProject) {
|
||||
confirmDeleteProject(currentEditingProject.project_id);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProjectById(projectId) {
|
||||
try {
|
||||
const response = await apiCall(`/projects/${projectId}`, 'DELETE');
|
||||
if (response && response.success) {
|
||||
showToast('삭제 완료');
|
||||
closeProjectModal();
|
||||
await loadProjects();
|
||||
} else {
|
||||
throw new Error(response?.message || '삭제 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(error.message || '삭제 중 오류', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(message, type = 'success') {
|
||||
const existing = document.querySelector('.toast-msg');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast-msg';
|
||||
toast.textContent = message;
|
||||
toast.style.cssText = `
|
||||
position: fixed; top: 20px; right: 20px;
|
||||
padding: 0.75rem 1.25rem; border-radius: 0.25rem;
|
||||
color: white; font-size: 0.85rem; z-index: 2000;
|
||||
background: ${type === 'error' ? '#ef4444' : '#10b981'};
|
||||
`;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 2500);
|
||||
}
|
||||
|
||||
// 전역 함수 노출
|
||||
window.openProjectModal = openProjectModal;
|
||||
window.closeProjectModal = closeProjectModal;
|
||||
window.editProject = editProject;
|
||||
window.saveProject = saveProject;
|
||||
window.deleteProject = deleteProject;
|
||||
window.confirmDeleteProject = confirmDeleteProject;
|
||||
window.filterProjects = filterProjects;
|
||||
window.sortProjects = sortProjects;
|
||||
window.refreshProjectList = refreshProjectList;
|
||||
window.filterByStatus = filterByStatus;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
852
deploy/tkfb-package/web-ui/pages/admin/repair-management.html
Normal file
852
deploy/tkfb-package/web-ui/pages/admin/repair-management.html
Normal file
@@ -0,0 +1,852 @@
|
||||
<!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=8">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=3" defer></script>
|
||||
<style>
|
||||
.repair-page {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
flex: 1;
|
||||
background: white;
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
border-left: 4px solid var(--gray-300);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.stat-card.active {
|
||||
background: var(--primary-50);
|
||||
}
|
||||
|
||||
.stat-card.reported { border-left-color: var(--error-500); }
|
||||
.stat-card.received { border-left-color: var(--warning-500); }
|
||||
.stat-card.in_progress { border-left-color: var(--primary-500); }
|
||||
.stat-card.completed { border-left-color: var(--success-500); }
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.repair-table-container {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.repair-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.repair-table th,
|
||||
.repair-table td {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.repair-table th {
|
||||
background: var(--bg-secondary);
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.repair-table tr:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-badge.reported {
|
||||
background: var(--error-100);
|
||||
color: var(--error-700);
|
||||
}
|
||||
|
||||
.status-badge.received {
|
||||
background: var(--warning-100);
|
||||
color: var(--warning-700);
|
||||
}
|
||||
|
||||
.status-badge.in_progress {
|
||||
background: var(--primary-100);
|
||||
color: var(--primary-700);
|
||||
}
|
||||
|
||||
.status-badge.completed, .status-badge.resolved {
|
||||
background: var(--success-100);
|
||||
color: var(--success-700);
|
||||
}
|
||||
|
||||
.action-btns {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-receive {
|
||||
background: var(--warning-500);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-receive:hover {
|
||||
background: var(--warning-600);
|
||||
}
|
||||
|
||||
.btn-start {
|
||||
background: var(--primary-500);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-start:hover {
|
||||
background: var(--primary-600);
|
||||
}
|
||||
|
||||
.btn-complete {
|
||||
background: var(--success-500);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-complete:hover {
|
||||
background: var(--success-600);
|
||||
}
|
||||
|
||||
.btn-view {
|
||||
background: var(--gray-100);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-view:hover {
|
||||
background: var(--gray-200);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 3rem;
|
||||
text-align: center;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.repair-desc {
|
||||
max-width: 200px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.assignee-info {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* 모달 */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-overlay.show {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
min-height: 100px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid var(--border-light);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary-500);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--gray-100);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
width: 80px;
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
flex: 1;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.photo-thumbnails {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.photo-thumb {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
flex: 1 1 45%;
|
||||
}
|
||||
|
||||
.repair-table {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.repair-table th:nth-child(3),
|
||||
.repair-table td:nth-child(3),
|
||||
.repair-table th:nth-child(5),
|
||||
.repair-table td:nth-child(5) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="navbar-container"></div>
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<main class="main-content">
|
||||
<div class="repair-page">
|
||||
<div class="page-header">
|
||||
<h1>시설설비 관리</h1>
|
||||
</div>
|
||||
|
||||
<div class="stats-row">
|
||||
<div class="stat-card reported" onclick="filterByStatus('reported')">
|
||||
<div class="stat-value" id="reportedCount">0</div>
|
||||
<div class="stat-label">신청</div>
|
||||
</div>
|
||||
<div class="stat-card received" onclick="filterByStatus('received')">
|
||||
<div class="stat-value" id="receivedCount">0</div>
|
||||
<div class="stat-label">접수</div>
|
||||
</div>
|
||||
<div class="stat-card in_progress" onclick="filterByStatus('in_progress')">
|
||||
<div class="stat-value" id="inProgressCount">0</div>
|
||||
<div class="stat-label">처리중</div>
|
||||
</div>
|
||||
<div class="stat-card completed" onclick="filterByStatus('completed')">
|
||||
<div class="stat-value" id="completedCount">0</div>
|
||||
<div class="stat-label">완료</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="repair-table-container">
|
||||
<table class="repair-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>신청일</th>
|
||||
<th>작업장</th>
|
||||
<th>유형</th>
|
||||
<th>설명</th>
|
||||
<th>담당자</th>
|
||||
<th>상태</th>
|
||||
<th>처리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="repairTableBody">
|
||||
<tr>
|
||||
<td colspan="7" class="empty-state">로딩중...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 접수 모달 -->
|
||||
<div class="modal-overlay" id="receiveModal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>접수 확인</h3>
|
||||
<button class="modal-close" onclick="closeModal('receiveModal')">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>담당자 배정 *</label>
|
||||
<select id="receiveAssignee" required>
|
||||
<option value="">담당자 선택</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>메모</label>
|
||||
<textarea id="receiveNotes" placeholder="접수 시 메모 (선택사항)"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeModal('receiveModal')">취소</button>
|
||||
<button class="btn btn-primary" onclick="submitReceive()">접수 확인</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 완료 모달 -->
|
||||
<div class="modal-overlay" id="completeModal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>완료 처리</h3>
|
||||
<button class="modal-close" onclick="closeModal('completeModal')">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>처리 내용 *</label>
|
||||
<textarea id="completeNotes" placeholder="처리 내용을 입력하세요..." required></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeModal('completeModal')">취소</button>
|
||||
<button class="btn btn-primary" onclick="submitComplete()">완료 처리</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 상세 모달 -->
|
||||
<div class="modal-overlay" id="detailModal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>상세 정보</h3>
|
||||
<button class="modal-close" onclick="closeModal('detailModal')">×</button>
|
||||
</div>
|
||||
<div class="modal-body" id="detailContent">
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeModal('detailModal')">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentReportId = null;
|
||||
let allRepairs = [];
|
||||
let workers = [];
|
||||
let currentFilter = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
setTimeout(() => {
|
||||
loadWorkers();
|
||||
loadRepairRequests();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
async function loadWorkers() {
|
||||
try {
|
||||
const response = await window.apiCall('/workers?status=active');
|
||||
if (response.success) {
|
||||
workers = response.data || [];
|
||||
populateAssigneeDropdown();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('작업자 목록 로드 오류:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function populateAssigneeDropdown() {
|
||||
const select = document.getElementById('receiveAssignee');
|
||||
select.innerHTML = '<option value="">담당자 선택</option>' +
|
||||
workers.map(w => `<option value="${w.worker_id}">${w.worker_name} (${w.job_type || ''})</option>`).join('');
|
||||
}
|
||||
|
||||
async function loadRepairRequests() {
|
||||
try {
|
||||
const response = await window.apiCall('/work-issues?category_type=nonconformity');
|
||||
if (response.success) {
|
||||
allRepairs = response.data || [];
|
||||
updateStats();
|
||||
renderTable();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('수리 목록 로드 오류:', error);
|
||||
document.getElementById('repairTableBody').innerHTML =
|
||||
'<tr><td colspan="7" class="empty-state">데이터를 불러올 수 없습니다.</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
const counts = {
|
||||
reported: 0,
|
||||
received: 0,
|
||||
in_progress: 0,
|
||||
completed: 0
|
||||
};
|
||||
|
||||
allRepairs.forEach(r => {
|
||||
if (counts.hasOwnProperty(r.status)) {
|
||||
counts[r.status]++;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('reportedCount').textContent = counts.reported;
|
||||
document.getElementById('receivedCount').textContent = counts.received;
|
||||
document.getElementById('inProgressCount').textContent = counts.in_progress;
|
||||
document.getElementById('completedCount').textContent = counts.completed;
|
||||
|
||||
// 활성 필터 표시
|
||||
document.querySelectorAll('.stat-card').forEach(card => {
|
||||
card.classList.remove('active');
|
||||
});
|
||||
if (currentFilter) {
|
||||
document.querySelector(`.stat-card.${currentFilter}`)?.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
function filterByStatus(status) {
|
||||
if (currentFilter === status) {
|
||||
currentFilter = null; // 토글 off
|
||||
} else {
|
||||
currentFilter = status;
|
||||
}
|
||||
updateStats();
|
||||
renderTable();
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const tbody = document.getElementById('repairTableBody');
|
||||
|
||||
let filtered = allRepairs;
|
||||
if (currentFilter) {
|
||||
filtered = allRepairs.filter(r => r.status === currentFilter);
|
||||
}
|
||||
|
||||
if (filtered.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="empty-state">수리 신청 내역이 없습니다.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = filtered.map(r => `
|
||||
<tr>
|
||||
<td>${formatDate(r.report_date)}</td>
|
||||
<td>${r.workplace_name || '-'}</td>
|
||||
<td>${r.issue_item_name || '-'}</td>
|
||||
<td class="repair-desc" title="${escapeHtml(r.additional_description || '')}">${escapeHtml(r.additional_description || '-')}</td>
|
||||
<td>
|
||||
${r.assigned_full_name || r.assigned_user_name || '-'}
|
||||
${r.assigned_at ? `<div class="assignee-info">${formatDate(r.assigned_at)}</div>` : ''}
|
||||
</td>
|
||||
<td><span class="status-badge ${r.status}">${getStatusText(r.status)}</span></td>
|
||||
<td class="action-btns">
|
||||
${getActionButtons(r)}
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function getActionButtons(r) {
|
||||
let buttons = '';
|
||||
|
||||
switch (r.status) {
|
||||
case 'reported':
|
||||
buttons = `<button class="btn-sm btn-receive" onclick="openReceiveModal(${r.report_id})">접수</button>`;
|
||||
break;
|
||||
case 'received':
|
||||
buttons = `<button class="btn-sm btn-start" onclick="startProcessing(${r.report_id})">처리시작</button>`;
|
||||
break;
|
||||
case 'in_progress':
|
||||
buttons = `<button class="btn-sm btn-complete" onclick="openCompleteModal(${r.report_id})">완료</button>`;
|
||||
break;
|
||||
}
|
||||
|
||||
buttons += `<button class="btn-sm btn-view" onclick="viewDetail(${r.report_id})">상세</button>`;
|
||||
return buttons;
|
||||
}
|
||||
|
||||
function getStatusText(status) {
|
||||
const texts = {
|
||||
'reported': '신청',
|
||||
'received': '접수',
|
||||
'in_progress': '처리중',
|
||||
'completed': '완료',
|
||||
'closed': '종료',
|
||||
'resolved': '완료'
|
||||
};
|
||||
return texts[status] || status;
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('ko-KR', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// 접수 모달
|
||||
function openReceiveModal(reportId) {
|
||||
currentReportId = reportId;
|
||||
document.getElementById('receiveNotes').value = '';
|
||||
document.getElementById('receiveAssignee').value = '';
|
||||
document.getElementById('receiveModal').classList.add('show');
|
||||
}
|
||||
|
||||
async function submitReceive() {
|
||||
const assigneeId = document.getElementById('receiveAssignee').value;
|
||||
|
||||
if (!assigneeId) {
|
||||
alert('담당자를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 접수 처리
|
||||
const receiveRes = await window.apiCall(`/work-issues/${currentReportId}/receive`, {
|
||||
method: 'PUT'
|
||||
});
|
||||
|
||||
if (!receiveRes.success) {
|
||||
throw new Error(receiveRes.message || '접수 처리 실패');
|
||||
}
|
||||
|
||||
// 2. 담당자 배정
|
||||
const assignRes = await window.apiCall(`/work-issues/${currentReportId}/assign`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
assigned_user_id: parseInt(assigneeId)
|
||||
})
|
||||
});
|
||||
|
||||
// 3. 관련 알림 읽음 처리
|
||||
await markRelatedNotificationAsRead(currentReportId);
|
||||
|
||||
alert('접수 완료되었습니다.');
|
||||
closeModal('receiveModal');
|
||||
loadRepairRequests();
|
||||
|
||||
} catch (error) {
|
||||
console.error('접수 오류:', error);
|
||||
alert('접수 처리 중 오류가 발생했습니다: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 처리 시작
|
||||
async function startProcessing(reportId) {
|
||||
if (!confirm('처리를 시작하시겠습니까?')) return;
|
||||
|
||||
try {
|
||||
const response = await window.apiCall(`/work-issues/${reportId}/start`, {
|
||||
method: 'PUT'
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
alert('처리가 시작되었습니다.');
|
||||
loadRepairRequests();
|
||||
} else {
|
||||
throw new Error(response.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('처리 시작 오류:', error);
|
||||
alert('처리 시작 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
// 완료 모달
|
||||
function openCompleteModal(reportId) {
|
||||
currentReportId = reportId;
|
||||
document.getElementById('completeNotes').value = '';
|
||||
document.getElementById('completeModal').classList.add('show');
|
||||
}
|
||||
|
||||
async function submitComplete() {
|
||||
const notes = document.getElementById('completeNotes').value.trim();
|
||||
|
||||
if (!notes) {
|
||||
alert('처리 내용을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await window.apiCall(`/work-issues/${currentReportId}/complete`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
resolution_notes: notes
|
||||
})
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
alert('완료 처리되었습니다.');
|
||||
closeModal('completeModal');
|
||||
loadRepairRequests();
|
||||
} else {
|
||||
throw new Error(response.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('완료 처리 오류:', error);
|
||||
alert('완료 처리 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
// 상세 보기
|
||||
async function viewDetail(reportId) {
|
||||
try {
|
||||
const response = await window.apiCall(`/work-issues/${reportId}`);
|
||||
if (response.success && response.data) {
|
||||
const r = response.data;
|
||||
let html = `
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">신청일</span>
|
||||
<span class="detail-value">${formatDate(r.report_date)}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">신청자</span>
|
||||
<span class="detail-value">${r.reporter_full_name || r.reporter_name || '-'}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">작업장</span>
|
||||
<span class="detail-value">${r.workplace_name || '-'}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">유형</span>
|
||||
<span class="detail-value">${r.issue_item_name || '-'}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">상태</span>
|
||||
<span class="detail-value"><span class="status-badge ${r.status}">${getStatusText(r.status)}</span></span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">설명</span>
|
||||
<span class="detail-value">${escapeHtml(r.additional_description) || '-'}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (r.assigned_full_name || r.assigned_user_name) {
|
||||
html += `
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">담당자</span>
|
||||
<span class="detail-value">${r.assigned_full_name || r.assigned_user_name}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (r.resolution_notes) {
|
||||
html += `
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">처리내용</span>
|
||||
<span class="detail-value">${escapeHtml(r.resolution_notes)}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 사진
|
||||
const photos = [r.photo_path1, r.photo_path2, r.photo_path3, r.photo_path4, r.photo_path5].filter(p => p);
|
||||
if (photos.length > 0) {
|
||||
html += `
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">사진</span>
|
||||
<span class="detail-value">
|
||||
<div class="photo-thumbnails">
|
||||
${photos.map(p => `<img src="${p}" class="photo-thumb" onclick="window.open('${p}', '_blank')">`).join('')}
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
document.getElementById('detailContent').innerHTML = html;
|
||||
document.getElementById('detailModal').classList.add('show');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('상세 조회 오류:', error);
|
||||
alert('상세 정보를 불러올 수 없습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal(modalId) {
|
||||
document.getElementById(modalId).classList.remove('show');
|
||||
currentReportId = null;
|
||||
}
|
||||
|
||||
async function markRelatedNotificationAsRead(reportId) {
|
||||
try {
|
||||
const response = await window.apiCall('/notifications?limit=100');
|
||||
if (response.success) {
|
||||
const notifications = response.data || [];
|
||||
const related = notifications.find(n =>
|
||||
n.reference_type === 'work_issue_reports' &&
|
||||
n.reference_id == reportId
|
||||
);
|
||||
if (related) {
|
||||
await window.apiCall(`/notifications/${related.notification_id}/read`, {
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('알림 읽음 처리 실패:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 모달 외부 클릭시 닫기
|
||||
document.querySelectorAll('.modal-overlay').forEach(modal => {
|
||||
modal.addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
this.classList.remove('show');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
596
deploy/tkfb-package/web-ui/pages/admin/tasks.html
Normal file
596
deploy/tkfb-package/web-ui/pages/admin/tasks.html
Normal file
@@ -0,0 +1,596 @@
|
||||
<!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=8">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<style>
|
||||
.page-wrapper { padding: 1rem 1.5rem; max-width: 1400px; }
|
||||
.page-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.page-title { font-size: 1.25rem; font-weight: 600; margin: 0; }
|
||||
.header-controls { display: flex; gap: 0.5rem; align-items: center; }
|
||||
.filter-select {
|
||||
padding: 0.4rem 0.5rem; border: 1px solid #d1d5db;
|
||||
border-radius: 0.25rem; font-size: 0.8rem; min-width: 120px;
|
||||
}
|
||||
.btn {
|
||||
padding: 0.4rem 0.75rem; border: none; border-radius: 0.25rem;
|
||||
cursor: pointer; font-size: 0.8rem;
|
||||
}
|
||||
.btn-primary { background: #3b82f6; color: white; }
|
||||
.btn-outline { background: white; border: 1px solid #d1d5db; }
|
||||
.btn-danger { background: #ef4444; color: white; }
|
||||
|
||||
/* 2열 레이아웃 */
|
||||
.two-col-layout { display: grid; grid-template-columns: 280px 1fr; gap: 1rem; }
|
||||
|
||||
/* 공정 패널 */
|
||||
.work-type-panel {
|
||||
background: white; border: 1px solid #e5e7eb; border-radius: 0.25rem;
|
||||
max-height: calc(100vh - 200px); overflow-y: auto;
|
||||
}
|
||||
.panel-header {
|
||||
padding: 0.75rem; border-bottom: 1px solid #e5e7eb;
|
||||
font-weight: 600; font-size: 0.85rem; background: #f9fafb;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
}
|
||||
.panel-header .btn { padding: 0.25rem 0.5rem; font-size: 0.7rem; }
|
||||
.panel-header .count {
|
||||
background: #e5e7eb; padding: 0.1rem 0.5rem; border-radius: 0.25rem;
|
||||
font-size: 0.75rem; font-weight: 500;
|
||||
}
|
||||
.work-type-list { padding: 0; margin: 0; list-style: none; }
|
||||
.work-type-item {
|
||||
padding: 0.6rem 0.75rem; border-bottom: 1px solid #f3f4f6;
|
||||
cursor: pointer; font-size: 0.8rem;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
}
|
||||
.work-type-item:hover { background: #f9fafb; }
|
||||
.work-type-item.active { background: #dbeafe; color: #1d4ed8; }
|
||||
.work-type-item .count {
|
||||
background: #f3f4f6; padding: 0.1rem 0.4rem; border-radius: 0.25rem;
|
||||
font-size: 0.7rem; color: #6b7280;
|
||||
}
|
||||
.work-type-item.active .count { background: #bfdbfe; color: #1d4ed8; }
|
||||
.work-type-item .edit-btn {
|
||||
opacity: 0; font-size: 0.7rem; padding: 0.2rem 0.4rem;
|
||||
background: white; border: 1px solid #d1d5db; border-radius: 0.2rem;
|
||||
cursor: pointer; margin-left: 0.5rem;
|
||||
}
|
||||
.work-type-item:hover .edit-btn { opacity: 1; }
|
||||
|
||||
/* 작업 테이블 */
|
||||
.task-panel { background: white; border: 1px solid #e5e7eb; border-radius: 0.25rem; }
|
||||
.task-header {
|
||||
padding: 0.75rem; border-bottom: 1px solid #e5e7eb;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
background: #f9fafb;
|
||||
}
|
||||
.task-header-title { font-weight: 600; font-size: 0.85rem; }
|
||||
.task-stats { font-size: 0.75rem; color: #6b7280; }
|
||||
.task-stats span { margin-left: 1rem; }
|
||||
.table-wrapper { max-height: calc(100vh - 280px); overflow-y: auto; }
|
||||
.data-table {
|
||||
width: 100%; border-collapse: collapse; font-size: 0.8rem;
|
||||
}
|
||||
.data-table th, .data-table td {
|
||||
padding: 0.5rem 0.6rem; text-align: left; border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
.data-table th {
|
||||
background: #f9fafb; font-weight: 500; color: #374151;
|
||||
font-size: 0.75rem; position: sticky; top: 0;
|
||||
}
|
||||
.data-table tr:hover { background: #f9fafb; }
|
||||
.data-table tr.inactive { opacity: 0.6; }
|
||||
.task-name { font-weight: 500; }
|
||||
.status-badge {
|
||||
display: inline-block; padding: 0.1rem 0.4rem;
|
||||
border-radius: 0.2rem; font-size: 0.7rem; font-weight: 500;
|
||||
}
|
||||
.status-active { background: #dcfce7; color: #166534; }
|
||||
.status-inactive { background: #f3f4f6; color: #6b7280; }
|
||||
.action-btns { display: flex; gap: 0.25rem; }
|
||||
.action-btns button {
|
||||
padding: 0.2rem 0.4rem; font-size: 0.7rem;
|
||||
border: 1px solid #d1d5db; background: white;
|
||||
border-radius: 0.2rem; cursor: pointer;
|
||||
}
|
||||
.action-btns button:hover { background: #f3f4f6; }
|
||||
.action-btns .btn-edit { color: #3b82f6; }
|
||||
.action-btns .btn-del { color: #ef4444; }
|
||||
.empty-row td { text-align: center; padding: 2rem; color: #6b7280; }
|
||||
.desc-cell { max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #6b7280; }
|
||||
|
||||
/* 모달 */
|
||||
.modal-overlay {
|
||||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.5); display: flex;
|
||||
align-items: center; justify-content: center; z-index: 1000;
|
||||
}
|
||||
.modal-container {
|
||||
background: white; border-radius: 0.5rem; width: 500px;
|
||||
max-width: 95vw; max-height: 90vh; overflow-y: auto;
|
||||
}
|
||||
.modal-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 1rem; border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
.modal-header h2 { font-size: 1rem; font-weight: 600; margin: 0; }
|
||||
.modal-close { background: none; border: none; font-size: 1.25rem; cursor: pointer; color: #6b7280; }
|
||||
.modal-body { padding: 1rem; }
|
||||
.modal-footer {
|
||||
display: flex; justify-content: flex-end; gap: 0.5rem;
|
||||
padding: 1rem; border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
.form-group { margin-bottom: 0.75rem; }
|
||||
.form-label { display: block; font-size: 0.8rem; font-weight: 500; color: #374151; margin-bottom: 0.25rem; }
|
||||
.form-control {
|
||||
width: 100%; padding: 0.4rem 0.5rem; border: 1px solid #d1d5db;
|
||||
border-radius: 0.25rem; font-size: 0.85rem;
|
||||
}
|
||||
.form-check { display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; }
|
||||
.form-hint { font-size: 0.7rem; color: #6b7280; margin-top: 0.25rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="has-sidebar">
|
||||
<div id="navbar-container"></div>
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<main class="main-content">
|
||||
<div class="page-wrapper">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">작업 관리</h1>
|
||||
<div class="header-controls">
|
||||
<button class="btn btn-outline" onclick="refreshData()">새로고침</button>
|
||||
<button class="btn btn-primary" onclick="openWorkTypeModal()">+ 공정</button>
|
||||
<button class="btn btn-primary" onclick="openTaskModal()">+ 작업</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="two-col-layout">
|
||||
<!-- 공정 목록 -->
|
||||
<div class="work-type-panel">
|
||||
<div class="panel-header">
|
||||
<span>공정 목록</span>
|
||||
<span class="count" id="totalTaskCount">0</span>
|
||||
</div>
|
||||
<ul class="work-type-list" id="workTypeList">
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 작업 테이블 -->
|
||||
<div class="task-panel">
|
||||
<div class="task-header">
|
||||
<span class="task-header-title" id="currentWorkTypeName">전체 작업</span>
|
||||
<div class="task-stats">
|
||||
<span>활성 <strong id="activeCount">0</strong></span>
|
||||
<span>비활성 <strong id="inactiveCount">0</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrapper">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:30px">#</th>
|
||||
<th>작업명</th>
|
||||
<th>소속 공정</th>
|
||||
<th>설명</th>
|
||||
<th style="width:60px">상태</th>
|
||||
<th style="width:80px">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="taskTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 작업 모달 -->
|
||||
<div id="taskModal" class="modal-overlay" style="display:none;">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h2 id="taskModalTitle">작업 추가</h2>
|
||||
<button class="modal-close" onclick="closeTaskModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="taskForm">
|
||||
<input type="hidden" id="taskId">
|
||||
<div class="form-group">
|
||||
<label class="form-label">소속 공정 *</label>
|
||||
<select id="taskWorkTypeId" class="form-control" required>
|
||||
<option value="">선택...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">작업명 *</label>
|
||||
<input type="text" id="taskName" class="form-control" required placeholder="예: 서스 용접">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">설명</label>
|
||||
<textarea id="taskDescription" class="form-control" rows="3" placeholder="작업 설명"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-check">
|
||||
<input type="checkbox" id="taskIsActive" checked>
|
||||
<span>활성화</span>
|
||||
</label>
|
||||
<p class="form-hint">비활성화 시 TBM 입력에서 숨김</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline" onclick="closeTaskModal()">취소</button>
|
||||
<button class="btn btn-danger" id="deleteTaskBtn" onclick="deleteTask()" style="display:none;">삭제</button>
|
||||
<button class="btn btn-primary" onclick="saveTask()">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 공정 모달 -->
|
||||
<div id="workTypeModal" class="modal-overlay" style="display:none;">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h2 id="workTypeModalTitle">공정 추가</h2>
|
||||
<button class="modal-close" onclick="closeWorkTypeModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="workTypeForm">
|
||||
<input type="hidden" id="workTypeId">
|
||||
<div class="form-group">
|
||||
<label class="form-label">공정명 *</label>
|
||||
<input type="text" id="workTypeName" class="form-control" required placeholder="예: Base(구조물)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">카테고리</label>
|
||||
<input type="text" id="workTypeCategory" class="form-control" placeholder="예: 제작">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">설명</label>
|
||||
<textarea id="workTypeDescription" class="form-control" rows="2" placeholder="공정 설명"></textarea>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline" onclick="closeWorkTypeModal()">취소</button>
|
||||
<button class="btn btn-danger" id="deleteWorkTypeBtn" onclick="deleteWorkType()" style="display:none;">삭제</button>
|
||||
<button class="btn btn-primary" onclick="saveWorkType()">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let workTypes = [];
|
||||
let tasks = [];
|
||||
let currentWorkTypeId = '';
|
||||
let currentEditingTask = null;
|
||||
let currentEditingWorkType = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
let retryCount = 0;
|
||||
while (!window.apiCall && retryCount < 50) {
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
retryCount++;
|
||||
}
|
||||
if (!window.apiCall) {
|
||||
alert('시스템 초기화 실패. 페이지를 새로고침하세요.');
|
||||
return;
|
||||
}
|
||||
await loadAllData();
|
||||
});
|
||||
|
||||
async function loadAllData() {
|
||||
try {
|
||||
const [wtRes, taskRes] = await Promise.all([
|
||||
window.apiCall('/daily-work-reports/work-types'),
|
||||
window.apiCall('/tasks')
|
||||
]);
|
||||
workTypes = (wtRes && wtRes.success) ? (wtRes.data || []) : [];
|
||||
tasks = (taskRes && taskRes.success) ? (taskRes.data || []) : [];
|
||||
renderWorkTypeList();
|
||||
renderTasks();
|
||||
} catch (e) {
|
||||
console.error('데이터 로드 오류:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function renderWorkTypeList() {
|
||||
const list = document.getElementById('workTypeList');
|
||||
let html = `
|
||||
<li class="work-type-item ${currentWorkTypeId === '' ? 'active' : ''}" data-id="" onclick="filterByWorkType('')">
|
||||
<span>전체</span>
|
||||
<span class="count">${tasks.length}</span>
|
||||
</li>
|
||||
`;
|
||||
workTypes.forEach(wt => {
|
||||
const count = tasks.filter(t => t.work_type_id === wt.id).length;
|
||||
const isActive = currentWorkTypeId === wt.id;
|
||||
html += `
|
||||
<li class="work-type-item ${isActive ? 'active' : ''}" data-id="${wt.id}" onclick="filterByWorkType(${wt.id})">
|
||||
<span>${escapeHtml(wt.name)}</span>
|
||||
<span class="count">${count}</span>
|
||||
<button class="edit-btn" onclick="event.stopPropagation(); editWorkType(${wt.id})">수정</button>
|
||||
</li>
|
||||
`;
|
||||
});
|
||||
list.innerHTML = html;
|
||||
const totalEl = document.getElementById('totalTaskCount');
|
||||
if (totalEl) totalEl.textContent = tasks.length;
|
||||
}
|
||||
|
||||
function filterByWorkType(id) {
|
||||
currentWorkTypeId = id === '' ? '' : parseInt(id);
|
||||
renderWorkTypeList();
|
||||
renderTasks();
|
||||
|
||||
const wt = workTypes.find(w => w.id === currentWorkTypeId);
|
||||
document.getElementById('currentWorkTypeName').textContent = wt ? wt.name + ' 작업' : '전체 작업';
|
||||
}
|
||||
|
||||
function renderTasks() {
|
||||
const tbody = document.getElementById('taskTableBody');
|
||||
let filtered = tasks;
|
||||
if (currentWorkTypeId !== '') {
|
||||
filtered = tasks.filter(t => t.work_type_id === currentWorkTypeId);
|
||||
}
|
||||
|
||||
const active = filtered.filter(t => t.is_active).length;
|
||||
const inactive = filtered.length - active;
|
||||
document.getElementById('activeCount').textContent = active;
|
||||
document.getElementById('inactiveCount').textContent = inactive;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
tbody.innerHTML = '<tr class="empty-row"><td colspan="6">등록된 작업이 없습니다</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = filtered.map((t, idx) => {
|
||||
const rowClass = t.is_active ? '' : 'inactive';
|
||||
return `
|
||||
<tr class="${rowClass}">
|
||||
<td>${idx + 1}</td>
|
||||
<td class="task-name">${escapeHtml(t.task_name)}</td>
|
||||
<td>${escapeHtml(t.work_type_name || '-')}</td>
|
||||
<td class="desc-cell" title="${escapeHtml(t.description || '')}">${escapeHtml(t.description || '-')}</td>
|
||||
<td><span class="status-badge ${t.is_active ? 'status-active' : 'status-inactive'}">${t.is_active ? '활성' : '비활성'}</span></td>
|
||||
<td>
|
||||
<div class="action-btns">
|
||||
<button class="btn-edit" onclick="editTask(${t.task_id})">수정</button>
|
||||
<button class="btn-del" onclick="confirmDeleteTask(${t.task_id})">삭제</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
async function refreshData() {
|
||||
await loadAllData();
|
||||
showToast('새로고침 완료');
|
||||
}
|
||||
|
||||
// ========== 작업 모달 ==========
|
||||
function openTaskModal() {
|
||||
currentEditingTask = null;
|
||||
document.getElementById('taskModalTitle').textContent = '작업 추가';
|
||||
document.getElementById('taskForm').reset();
|
||||
document.getElementById('taskId').value = '';
|
||||
document.getElementById('taskIsActive').checked = true;
|
||||
populateWorkTypeSelect();
|
||||
if (currentWorkTypeId !== '') {
|
||||
document.getElementById('taskWorkTypeId').value = currentWorkTypeId;
|
||||
}
|
||||
document.getElementById('deleteTaskBtn').style.display = 'none';
|
||||
document.getElementById('taskModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function populateWorkTypeSelect() {
|
||||
const select = document.getElementById('taskWorkTypeId');
|
||||
select.innerHTML = '<option value="">선택...</option>' +
|
||||
workTypes.map(wt => `<option value="${wt.id}">${escapeHtml(wt.name)}</option>`).join('');
|
||||
}
|
||||
|
||||
async function editTask(taskId) {
|
||||
try {
|
||||
const res = await window.apiCall(`/tasks/${taskId}`);
|
||||
if (res && res.success) {
|
||||
currentEditingTask = res.data;
|
||||
document.getElementById('taskModalTitle').textContent = '작업 수정';
|
||||
document.getElementById('taskId').value = currentEditingTask.task_id;
|
||||
populateWorkTypeSelect();
|
||||
document.getElementById('taskWorkTypeId').value = currentEditingTask.work_type_id || '';
|
||||
document.getElementById('taskName').value = currentEditingTask.task_name;
|
||||
document.getElementById('taskDescription').value = currentEditingTask.description || '';
|
||||
document.getElementById('taskIsActive').checked = currentEditingTask.is_active;
|
||||
document.getElementById('deleteTaskBtn').style.display = 'inline-block';
|
||||
document.getElementById('taskModal').style.display = 'flex';
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('작업 조회 오류', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function closeTaskModal() {
|
||||
document.getElementById('taskModal').style.display = 'none';
|
||||
currentEditingTask = null;
|
||||
}
|
||||
|
||||
async function saveTask() {
|
||||
const taskId = document.getElementById('taskId').value;
|
||||
const data = {
|
||||
work_type_id: parseInt(document.getElementById('taskWorkTypeId').value) || null,
|
||||
task_name: document.getElementById('taskName').value.trim(),
|
||||
description: document.getElementById('taskDescription').value.trim() || null,
|
||||
is_active: document.getElementById('taskIsActive').checked ? 1 : 0
|
||||
};
|
||||
if (!data.task_name) { showToast('작업명을 입력하세요', 'error'); return; }
|
||||
|
||||
try {
|
||||
const res = taskId
|
||||
? await window.apiCall(`/tasks/${taskId}`, 'PUT', data)
|
||||
: await window.apiCall('/tasks', 'POST', data);
|
||||
if (res && res.success) {
|
||||
showToast(taskId ? '수정 완료' : '추가 완료');
|
||||
closeTaskModal();
|
||||
await loadAllData();
|
||||
} else {
|
||||
throw new Error(res?.message || '저장 실패');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(e.message || '저장 오류', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteTask(taskId) {
|
||||
const task = tasks.find(t => t.task_id === taskId);
|
||||
if (!task) return;
|
||||
if (confirm(`"${task.task_name}" 작업을 삭제하시겠습니까?`)) {
|
||||
deleteTaskById(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTask() {
|
||||
if (currentEditingTask) {
|
||||
confirmDeleteTask(currentEditingTask.task_id);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTaskById(taskId) {
|
||||
try {
|
||||
const res = await window.apiCall(`/tasks/${taskId}`, 'DELETE');
|
||||
if (res && res.success) {
|
||||
showToast('삭제 완료');
|
||||
closeTaskModal();
|
||||
await loadAllData();
|
||||
} else {
|
||||
throw new Error(res?.message || '삭제 실패');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(e.message || '삭제 오류', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 공정 모달 ==========
|
||||
function openWorkTypeModal() {
|
||||
currentEditingWorkType = null;
|
||||
document.getElementById('workTypeModalTitle').textContent = '공정 추가';
|
||||
document.getElementById('workTypeForm').reset();
|
||||
document.getElementById('workTypeId').value = '';
|
||||
document.getElementById('deleteWorkTypeBtn').style.display = 'none';
|
||||
document.getElementById('workTypeModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function editWorkType(id) {
|
||||
const wt = workTypes.find(w => w.id === id);
|
||||
if (!wt) return;
|
||||
currentEditingWorkType = wt;
|
||||
document.getElementById('workTypeModalTitle').textContent = '공정 수정';
|
||||
document.getElementById('workTypeId').value = wt.id;
|
||||
document.getElementById('workTypeName').value = wt.name || '';
|
||||
document.getElementById('workTypeCategory').value = wt.category || '';
|
||||
document.getElementById('workTypeDescription').value = wt.description || '';
|
||||
document.getElementById('deleteWorkTypeBtn').style.display = 'inline-block';
|
||||
document.getElementById('workTypeModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeWorkTypeModal() {
|
||||
document.getElementById('workTypeModal').style.display = 'none';
|
||||
currentEditingWorkType = null;
|
||||
}
|
||||
|
||||
async function saveWorkType() {
|
||||
const id = document.getElementById('workTypeId').value;
|
||||
const data = {
|
||||
name: document.getElementById('workTypeName').value.trim(),
|
||||
category: document.getElementById('workTypeCategory').value.trim() || null,
|
||||
description: document.getElementById('workTypeDescription').value.trim() || null
|
||||
};
|
||||
if (!data.name) { showToast('공정명을 입력하세요', 'error'); return; }
|
||||
|
||||
try {
|
||||
const res = id
|
||||
? await window.apiCall(`/daily-work-reports/work-types/${id}`, 'PUT', data)
|
||||
: await window.apiCall('/daily-work-reports/work-types', 'POST', data);
|
||||
if (res && res.success) {
|
||||
showToast(id ? '수정 완료' : '추가 완료');
|
||||
closeWorkTypeModal();
|
||||
await loadAllData();
|
||||
} else {
|
||||
throw new Error(res?.message || '저장 실패');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(e.message || '저장 오류', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteWorkType() {
|
||||
if (!currentEditingWorkType) return;
|
||||
const related = tasks.filter(t => t.work_type_id === currentEditingWorkType.id);
|
||||
if (related.length > 0) {
|
||||
showToast(`${related.length}개 작업이 연결되어 삭제 불가`, 'error');
|
||||
return;
|
||||
}
|
||||
if (!confirm(`"${currentEditingWorkType.name}" 공정을 삭제하시겠습니까?`)) return;
|
||||
|
||||
try {
|
||||
const res = await window.apiCall(`/daily-work-reports/work-types/${currentEditingWorkType.id}`, 'DELETE');
|
||||
if (res && res.success) {
|
||||
showToast('삭제 완료');
|
||||
closeWorkTypeModal();
|
||||
currentWorkTypeId = '';
|
||||
await loadAllData();
|
||||
} else {
|
||||
throw new Error(res?.message || '삭제 실패');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(e.message || '삭제 오류', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(message, type = 'success') {
|
||||
const existing = document.querySelector('.toast-msg');
|
||||
if (existing) existing.remove();
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast-msg';
|
||||
toast.textContent = message;
|
||||
toast.style.cssText = `
|
||||
position: fixed; top: 20px; right: 20px;
|
||||
padding: 0.75rem 1.25rem; border-radius: 0.25rem;
|
||||
color: white; font-size: 0.85rem; z-index: 2000;
|
||||
background: ${type === 'error' ? '#ef4444' : '#10b981'};
|
||||
`;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 2500);
|
||||
}
|
||||
|
||||
// 전역 노출
|
||||
window.filterByWorkType = filterByWorkType;
|
||||
window.openTaskModal = openTaskModal;
|
||||
window.closeTaskModal = closeTaskModal;
|
||||
window.editTask = editTask;
|
||||
window.saveTask = saveTask;
|
||||
window.deleteTask = deleteTask;
|
||||
window.confirmDeleteTask = confirmDeleteTask;
|
||||
window.openWorkTypeModal = openWorkTypeModal;
|
||||
window.closeWorkTypeModal = closeWorkTypeModal;
|
||||
window.editWorkType = editWorkType;
|
||||
window.saveWorkType = saveWorkType;
|
||||
window.deleteWorkType = deleteWorkType;
|
||||
window.refreshData = refreshData;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
495
deploy/tkfb-package/web-ui/pages/admin/workers.html
Normal file
495
deploy/tkfb-package/web-ui/pages/admin/workers.html
Normal file
@@ -0,0 +1,495 @@
|
||||
<!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=8">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<!-- 최적화된 로딩: API 설정 → 앱 초기화 (병렬 컴포넌트 로딩) -->
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<!-- instant.page: 링크 호버 시 페이지 프리로딩 -->
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
.department-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
gap: 1.5rem;
|
||||
min-height: calc(100vh - 200px);
|
||||
}
|
||||
|
||||
/* 부서 패널 */
|
||||
.department-panel {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.department-panel-header {
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.department-panel-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.department-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.department-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.department-item:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.department-item.active {
|
||||
background: #dbeafe;
|
||||
border-left: 3px solid #3b82f6;
|
||||
}
|
||||
|
||||
.department-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.department-name {
|
||||
font-weight: 500;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.department-count {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.department-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.department-item:hover .department-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
padding: 0.375rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background: #e5e7eb;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.btn-icon.danger:hover {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* 작업자 패널 */
|
||||
.worker-panel {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.worker-panel-header {
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.worker-panel-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.worker-toolbar {
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.worker-toolbar .search-input {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.worker-toolbar .filter-select {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.worker-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* 작업자 테이블 스타일 */
|
||||
.workers-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.workers-table th,
|
||||
.workers-table td {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.workers-table th {
|
||||
background: #f9fafb;
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.workers-table tr:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.worker-name-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.worker-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #3b82f6, #1d4ed8);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-badge.active {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.status-badge.inactive {
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.status-badge.resigned {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.account-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.account-badge.has-account {
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.account-badge.no-account {
|
||||
background: #f3f4f6;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* 빈 상태 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.empty-state h4 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 1024px) {
|
||||
.department-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.department-panel {
|
||||
max-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 부서 모달 스타일 */
|
||||
.form-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-check input[type="checkbox"] {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 레이아웃 -->
|
||||
<div class="page-container">
|
||||
<main class="main-content">
|
||||
<div class="dashboard-main">
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">작업자 관리</h1>
|
||||
<p class="page-description">부서별 작업자를 관리합니다. 부서를 선택하면 해당 부서의 작업자를 확인하고 관리할 수 있습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 부서 기반 레이아웃 -->
|
||||
<div class="department-layout">
|
||||
<!-- 왼쪽: 부서 목록 -->
|
||||
<div class="department-panel">
|
||||
<div class="department-panel-header">
|
||||
<h3>부서 목록</h3>
|
||||
<button class="btn btn-sm btn-primary" onclick="openDepartmentModal()">+ 부서 추가</button>
|
||||
</div>
|
||||
<div class="department-list" id="departmentList">
|
||||
<!-- 부서 목록이 여기에 렌더링됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 오른쪽: 작업자 목록 -->
|
||||
<div class="worker-panel">
|
||||
<div class="worker-panel-header">
|
||||
<h3 id="workerListTitle">부서를 선택하세요</h3>
|
||||
<button class="btn btn-sm btn-primary" id="addWorkerBtn" onclick="openWorkerModal()" style="display: none;">+ 작업자 추가</button>
|
||||
</div>
|
||||
<div class="worker-toolbar" id="workerToolbar" style="display: none;">
|
||||
<input type="text" class="search-input" id="workerSearch" placeholder="작업자 검색..." oninput="filterWorkers()">
|
||||
<select class="filter-select" id="statusFilter" onchange="filterWorkers()">
|
||||
<option value="">모든 상태</option>
|
||||
<option value="active">활성</option>
|
||||
<option value="inactive">비활성</option>
|
||||
<option value="resigned">퇴사</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="worker-list" id="workerList">
|
||||
<div class="empty-state">
|
||||
<h4>부서를 선택해주세요</h4>
|
||||
<p>왼쪽에서 부서를 선택하면 해당 부서의 작업자가 표시됩니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 부서 추가/수정 모달 -->
|
||||
<div id="departmentModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 500px;">
|
||||
<div class="modal-header">
|
||||
<h2 id="departmentModalTitle">새 부서 등록</h2>
|
||||
<button class="modal-close-btn" onclick="closeDepartmentModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="departmentForm" onsubmit="event.preventDefault(); saveDepartment();">
|
||||
<input type="hidden" id="departmentId">
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">부서명 *</label>
|
||||
<input type="text" id="departmentName" class="form-control" placeholder="예: 생산팀, 품질관리팀" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">상위 부서</label>
|
||||
<select id="parentDepartment" class="form-control">
|
||||
<option value="">없음 (최상위 부서)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">설명</label>
|
||||
<textarea id="departmentDescription" class="form-control" rows="2" placeholder="부서에 대한 설명"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">표시 순서</label>
|
||||
<input type="number" id="displayOrder" class="form-control" value="0" min="0">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label"> </label>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" id="isActiveDept" checked>
|
||||
<label for="isActiveDept">활성화</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeDepartmentModal()">취소</button>
|
||||
<button type="button" class="btn btn-danger" id="deleteDeptBtn" onclick="deleteDepartment()" style="display: none;">삭제</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveDepartment()">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업자 추가/수정 모달 -->
|
||||
<div id="workerModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h2 id="workerModalTitle">새 작업자 등록</h2>
|
||||
<button class="modal-close-btn" onclick="closeWorkerModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<form id="workerForm" onsubmit="event.preventDefault(); saveWorker();">
|
||||
<input type="hidden" id="workerId">
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">작업자명 *</label>
|
||||
<input type="text" id="workerName" class="form-control" placeholder="작업자 이름을 입력하세요" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">직책</label>
|
||||
<select id="jobType" class="form-control">
|
||||
<option value="worker">작업자</option>
|
||||
<option value="leader">그룹장</option>
|
||||
<option value="admin">관리자</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">입사일</label>
|
||||
<input type="date" id="joinDate" class="form-control">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">급여</label>
|
||||
<input type="number" id="salary" class="form-control" placeholder="월급여">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">연차</label>
|
||||
<input type="number" id="annualLeave" class="form-control" placeholder="연차 일수" value="0">
|
||||
</div>
|
||||
|
||||
<!-- 상태 관리 섹션 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label" style="font-weight: 600; margin-bottom: 0.75rem; display: block;">상태 관리</label>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 0.75rem;">
|
||||
<!-- 계정 생성/연동 -->
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
|
||||
<input type="checkbox" id="createAccount" style="margin: 0; cursor: pointer;">
|
||||
<span>계정 생성/연동</span>
|
||||
</label>
|
||||
<small style="color: #6b7280; font-size: 0.75rem; margin-top: -0.5rem; margin-left: 1.5rem;">
|
||||
체크 시 로그인 계정이 자동 생성됩니다 (ID: 이름 로마자 변환, 초기 비밀번호: 1234)
|
||||
</small>
|
||||
|
||||
<!-- 현장직/사무직 구분 -->
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
|
||||
<input type="checkbox" id="isActiveWorker" checked style="margin: 0; cursor: pointer;">
|
||||
<span>현장직 (활성화)</span>
|
||||
</label>
|
||||
<small style="color: #6b7280; font-size: 0.75rem; margin-top: -0.5rem; margin-left: 1.5rem;">
|
||||
체크: 현장직 (TBM, 작업보고서에 표시) / 체크 해제: 사무직
|
||||
</small>
|
||||
|
||||
<!-- 퇴사 처리 -->
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
|
||||
<input type="checkbox" id="isResigned" style="margin: 0; cursor: pointer;">
|
||||
<span style="color: #ef4444;">퇴사 처리</span>
|
||||
</label>
|
||||
<small style="color: #ef4444; font-size: 0.75rem; margin-top: -0.5rem; margin-left: 1.5rem;">
|
||||
퇴사한 작업자로 표시됩니다. TBM/작업 보고서에서 제외됩니다
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeWorkerModal()">취소</button>
|
||||
<button type="button" class="btn btn-danger" id="deleteWorkerBtn" onclick="deleteWorker()" style="display: none;">삭제</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveWorker()">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- worker-management.js만 로드 (navbar/sidebar는 app-init.js에서 처리) -->
|
||||
<script type="module" src="/js/worker-management.js?v=8"></script>
|
||||
</body>
|
||||
</html>
|
||||
429
deploy/tkfb-package/web-ui/pages/admin/workplaces.html
Normal file
429
deploy/tkfb-package/web-ui/pages/admin/workplaces.html
Normal file
@@ -0,0 +1,429 @@
|
||||
<!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=8">
|
||||
<link rel="stylesheet" href="/css/workplace-management.css?v=7">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 레이아웃 (기존 admin 레이아웃과 호환) -->
|
||||
<div class="page-container">
|
||||
<main class="main-content">
|
||||
<div class="wp-content">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="wp-page-header">
|
||||
<div class="wp-header-content">
|
||||
<h1 class="wp-page-title">
|
||||
<span class="wp-page-title-icon">🏭</span>
|
||||
작업장 관리
|
||||
</h1>
|
||||
<p class="wp-page-description">공장 및 작업장을 등록하고 설비 위치를 지도에서 관리합니다</p>
|
||||
</div>
|
||||
<div class="wp-header-actions">
|
||||
<button class="wp-btn wp-btn-primary" onclick="openCategoryModal()">
|
||||
<span class="wp-btn-icon">🏢</span>
|
||||
공장 추가
|
||||
</button>
|
||||
<button class="wp-btn wp-btn-primary" onclick="openWorkplaceModal()">
|
||||
<span class="wp-btn-icon">📍</span>
|
||||
작업장 추가
|
||||
</button>
|
||||
<button class="wp-btn wp-btn-secondary" onclick="refreshWorkplaces()">
|
||||
<span class="wp-btn-icon">🔄</span>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 통계 카드 -->
|
||||
<div class="wp-stats-row" id="statsRow">
|
||||
<div class="wp-stat-card">
|
||||
<div class="wp-stat-icon factory">🏢</div>
|
||||
<div class="wp-stat-content">
|
||||
<h3 id="factoryCount">0</h3>
|
||||
<p>공장</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wp-stat-card">
|
||||
<div class="wp-stat-icon workplace">📍</div>
|
||||
<div class="wp-stat-content">
|
||||
<h3 id="totalCount">0</h3>
|
||||
<p>전체 작업장</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wp-stat-card">
|
||||
<div class="wp-stat-icon active">✅</div>
|
||||
<div class="wp-stat-content">
|
||||
<h3 id="activeCount">0</h3>
|
||||
<p>활성 작업장</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wp-stat-card">
|
||||
<div class="wp-stat-icon equipment">⚙️</div>
|
||||
<div class="wp-stat-content">
|
||||
<h3 id="equipmentCount">0</h3>
|
||||
<p>등록된 설비</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 공장(카테고리) 탭 -->
|
||||
<div class="wp-factory-tabs" id="categoryTabs">
|
||||
<button class="wp-tab-btn active" data-category="" onclick="switchCategory('')">
|
||||
<span class="wp-tab-icon">🏗️</span>
|
||||
전체
|
||||
<span class="wp-tab-count" id="tabAllCount">0</span>
|
||||
</button>
|
||||
<!-- 공장 탭들이 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
|
||||
<!-- 공장 레이아웃 지도 관리 섹션 (카테고리가 선택된 경우에만 표시) -->
|
||||
<div class="wp-layout-section" id="layoutMapSection" style="display: none;">
|
||||
<div class="wp-layout-header">
|
||||
<h2 class="wp-layout-title">
|
||||
<span class="wp-layout-title-icon">🗺️</span>
|
||||
<span id="selectedCategoryName"></span> 레이아웃 지도
|
||||
</h2>
|
||||
<button class="wp-btn wp-btn-primary" onclick="openLayoutMapModal()">
|
||||
<span class="wp-btn-icon">⚙️</span>
|
||||
지도 설정
|
||||
</button>
|
||||
</div>
|
||||
<div class="wp-layout-body">
|
||||
<div id="layoutMapPreview" class="wp-layout-preview">
|
||||
<div class="wp-layout-empty">
|
||||
<div class="wp-layout-empty-icon">🗺️</div>
|
||||
<p>레이아웃 이미지가 아직 등록되지 않았습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업장 목록 -->
|
||||
<div class="wp-workplace-section">
|
||||
<div class="wp-section-header">
|
||||
<h2 class="wp-section-title">
|
||||
<span>📋</span>
|
||||
작업장 목록
|
||||
</h2>
|
||||
<div class="wp-section-stats">
|
||||
<span class="wp-section-stat">전체 <strong id="sectionTotalCount">0</strong>개</span>
|
||||
<span class="wp-section-stat">활성 <strong id="sectionActiveCount">0</strong>개</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wp-section-body">
|
||||
<div class="wp-grid" id="workplaceGrid">
|
||||
<!-- 작업장 카드들이 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 공장(카테고리) 추가/수정 모달 -->
|
||||
<div id="categoryModal" class="wp-modal-overlay" style="display: none;">
|
||||
<div class="wp-modal">
|
||||
<div class="wp-modal-header">
|
||||
<h2 class="wp-modal-title">
|
||||
<span>🏢</span>
|
||||
<span id="categoryModalTitle">공장 추가</span>
|
||||
</h2>
|
||||
<button class="wp-modal-close" onclick="closeCategoryModal()">×</button>
|
||||
</div>
|
||||
<div class="wp-modal-body">
|
||||
<form id="categoryForm" onsubmit="event.preventDefault(); saveCategory();">
|
||||
<input type="hidden" id="categoryId">
|
||||
<div class="wp-form-group">
|
||||
<label class="wp-form-label required">공장명</label>
|
||||
<input type="text" id="categoryName" class="wp-form-control" placeholder="예: 제 1공장" required>
|
||||
</div>
|
||||
<div class="wp-form-group">
|
||||
<label class="wp-form-label">설명</label>
|
||||
<textarea id="categoryDescription" class="wp-form-control" rows="3" placeholder="공장에 대한 설명을 입력하세요"></textarea>
|
||||
</div>
|
||||
<div class="wp-form-group">
|
||||
<label class="wp-form-label">표시 순서</label>
|
||||
<input type="number" id="categoryOrder" class="wp-form-control" value="0" min="0">
|
||||
<span class="wp-form-help">숫자가 작을수록 먼저 표시됩니다</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="wp-modal-footer">
|
||||
<button type="button" class="wp-btn wp-btn-outline" onclick="closeCategoryModal()">취소</button>
|
||||
<button type="button" class="wp-btn wp-btn-danger" id="deleteCategoryBtn" onclick="deleteCategory()" style="display: none;">삭제</button>
|
||||
<button type="button" class="wp-btn wp-btn-primary" onclick="saveCategory()">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업장 추가/수정 모달 -->
|
||||
<div id="workplaceModal" class="wp-modal-overlay" style="display: none;">
|
||||
<div class="wp-modal">
|
||||
<div class="wp-modal-header">
|
||||
<h2 class="wp-modal-title">
|
||||
<span>📍</span>
|
||||
<span id="workplaceModalTitle">작업장 추가</span>
|
||||
</h2>
|
||||
<button class="wp-modal-close" onclick="closeWorkplaceModal()">×</button>
|
||||
</div>
|
||||
<div class="wp-modal-body">
|
||||
<form id="workplaceForm" onsubmit="event.preventDefault(); saveWorkplace();">
|
||||
<input type="hidden" id="workplaceId">
|
||||
<div class="wp-form-group">
|
||||
<label class="wp-form-label">소속 공장</label>
|
||||
<select id="workplaceCategoryId" class="wp-form-control">
|
||||
<option value="">공장 선택</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="wp-form-group">
|
||||
<label class="wp-form-label required">작업장명</label>
|
||||
<input type="text" id="workplaceName" class="wp-form-control" placeholder="예: 서스작업장, 조립구역" required>
|
||||
</div>
|
||||
<div class="wp-form-group">
|
||||
<label class="wp-form-label">작업장 용도</label>
|
||||
<select id="workplacePurpose" class="wp-form-control">
|
||||
<option value="">선택 안 함</option>
|
||||
<option value="작업구역">작업구역</option>
|
||||
<option value="설비">설비</option>
|
||||
<option value="휴게시설">휴게시설</option>
|
||||
<option value="회의실">회의실</option>
|
||||
<option value="창고">창고</option>
|
||||
<option value="기타">기타</option>
|
||||
</select>
|
||||
<span class="wp-form-help">작업장의 주요 용도를 선택하세요</span>
|
||||
</div>
|
||||
<div class="wp-form-group">
|
||||
<label class="wp-form-label">표시 순서</label>
|
||||
<input type="number" id="displayPriority" class="wp-form-control" value="0" min="0">
|
||||
<span class="wp-form-help">숫자가 작을수록 먼저 표시됩니다</span>
|
||||
</div>
|
||||
<div class="wp-form-group">
|
||||
<label class="wp-form-label">설명</label>
|
||||
<textarea id="workplaceDescription" class="wp-form-control" rows="3" placeholder="작업장에 대한 설명을 입력하세요"></textarea>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="wp-modal-footer">
|
||||
<button type="button" class="wp-btn wp-btn-outline" onclick="closeWorkplaceModal()">취소</button>
|
||||
<button type="button" class="wp-btn wp-btn-danger" id="deleteWorkplaceBtn" onclick="deleteWorkplace()" style="display: none;">삭제</button>
|
||||
<button type="button" class="wp-btn wp-btn-primary" onclick="saveWorkplace()">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업장 지도 관리 모달 (간단 모드) -->
|
||||
<div id="workplaceMapModal" class="wp-modal-overlay" style="display: none;">
|
||||
<div class="wp-modal" style="max-width: 600px;">
|
||||
<div class="wp-modal-header">
|
||||
<h2 class="wp-modal-title">
|
||||
<span>🗺️</span>
|
||||
<span id="workplaceMapModalTitle">작업장 지도 관리</span>
|
||||
</h2>
|
||||
<button class="wp-modal-close" onclick="closeWorkplaceMapModal()">×</button>
|
||||
</div>
|
||||
<div class="wp-modal-body" style="padding: 24px;">
|
||||
<!-- 이미지 업로드 섹션 -->
|
||||
<div style="background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 12px; padding: 20px; margin-bottom: 20px;">
|
||||
<h3 style="font-size: 15px; font-weight: 600; margin-bottom: 12px; color: #334155;">📷 작업장 레이아웃 이미지</h3>
|
||||
<div class="wp-form-group">
|
||||
<div id="workplaceLayoutPreview" style="background: white; border: 2px dashed #cbd5e1; padding: 20px; border-radius: 8px; text-align: center; min-height: 120px;">
|
||||
<span style="color: #94a3b8;">업로드된 이미지가 없습니다</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px; align-items: center; margin-top: 12px;">
|
||||
<input type="file" id="workplaceLayoutFile" accept="image/*" class="wp-form-control" style="flex: 1;" onchange="previewWorkplaceLayoutImage(event)">
|
||||
<button type="button" class="wp-btn wp-btn-primary" onclick="uploadWorkplaceLayout()">업로드</button>
|
||||
</div>
|
||||
<span class="wp-form-help" style="margin-top: 8px; display: block;">JPG, PNG, GIF 형식 지원 (최대 5MB)</span>
|
||||
</div>
|
||||
|
||||
<!-- 설비 배치 버튼 -->
|
||||
<div style="background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); border-radius: 12px; padding: 24px; text-align: center;">
|
||||
<div style="font-size: 48px; margin-bottom: 12px;">⚙️</div>
|
||||
<h3 style="color: white; font-size: 18px; font-weight: 600; margin-bottom: 8px;">설비 위치 편집</h3>
|
||||
<p style="color: rgba(255,255,255,0.8); font-size: 14px; margin-bottom: 16px;">
|
||||
전체 화면에서 설비 위치를 쉽게 지정할 수 있습니다
|
||||
</p>
|
||||
<button type="button" class="wp-btn" style="background: white; color: #1d4ed8; font-weight: 600; padding: 12px 32px; font-size: 15px;" onclick="openFullscreenEquipmentEditor()">
|
||||
🖥️ 전체화면 편집 열기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 등록된 설비 요약 -->
|
||||
<div style="margin-top: 20px; background: white; border: 1px solid #e2e8f0; border-radius: 12px; padding: 16px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
|
||||
<h4 style="font-size: 14px; font-weight: 600; color: #334155; margin: 0;">📋 등록된 설비</h4>
|
||||
<span id="workplaceEquipmentCount" style="font-size: 12px; color: #64748b;">0개</span>
|
||||
</div>
|
||||
<div id="workplaceEquipmentList" style="max-height: 150px; overflow-y: auto;">
|
||||
<p style="color: #94a3b8; text-align: center; padding: 12px; font-size: 13px;">아직 정의된 설비가 없습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wp-modal-footer">
|
||||
<button type="button" class="wp-btn wp-btn-outline" onclick="closeWorkplaceMapModal()">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 전체화면 설비 배치 편집기 -->
|
||||
<div id="fullscreenEquipmentEditor" class="fullscreen-editor" style="display: none;">
|
||||
<div class="fullscreen-editor-header">
|
||||
<div class="fullscreen-editor-title">
|
||||
<span>⚙️</span>
|
||||
<span id="fullscreenEditorTitle">설비 위치 편집</span>
|
||||
</div>
|
||||
<div class="fullscreen-editor-actions">
|
||||
<button type="button" class="editor-btn editor-btn-secondary" onclick="toggleEditorSidebar()">
|
||||
<span id="sidebarToggleIcon">◀</span> 패널
|
||||
</button>
|
||||
<button type="button" class="editor-btn editor-btn-primary" onclick="closeFullscreenEditor()">
|
||||
✕ 닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fullscreen-editor-body">
|
||||
<!-- 메인 캔버스 영역 -->
|
||||
<div class="fullscreen-canvas-area" id="fullscreenCanvasArea">
|
||||
<div class="canvas-toolbar">
|
||||
<span class="toolbar-info">🖱️ 드래그로 영역 선택</span>
|
||||
<span class="toolbar-zoom" id="canvasZoomInfo">100%</span>
|
||||
</div>
|
||||
<div class="canvas-wrapper" id="fullscreenCanvasWrapper">
|
||||
<canvas id="fullscreenRegionCanvas"></canvas>
|
||||
</div>
|
||||
<div class="canvas-help">
|
||||
<span>💡 마우스로 드래그하여 설비 영역을 지정한 후, 오른쪽 패널에서 설비를 선택하고 저장하세요</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 사이드바 패널 -->
|
||||
<div class="fullscreen-sidebar" id="fullscreenSidebar">
|
||||
<!-- 설비 선택 -->
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-header">
|
||||
<h4>🔧 설비 선택</h4>
|
||||
<span id="fsAvailableEquipmentCount" class="badge badge-success">0개</span>
|
||||
</div>
|
||||
<div class="sidebar-section-body">
|
||||
<select id="fsEquipmentSelect" class="wp-form-control" onchange="fsToggleNewEquipmentFields()">
|
||||
<option value="">-- 기존 설비 선택 --</option>
|
||||
</select>
|
||||
<p class="form-help">이미 배치된 설비는 목록에 표시되지 않습니다</p>
|
||||
|
||||
<div id="fsNewEquipmentFields" class="new-equipment-box">
|
||||
<label>또는 새 설비 등록</label>
|
||||
<input type="text" id="fsEquipmentCode" class="wp-form-control" placeholder="설비 코드">
|
||||
<input type="text" id="fsEquipmentName" class="wp-form-control" placeholder="설비명">
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button type="button" class="wp-btn wp-btn-outline" onclick="fsClearCurrentRegion()">영역 지우기</button>
|
||||
<button type="button" class="wp-btn wp-btn-primary" onclick="fsSaveEquipmentRegion()">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 등록된 설비 목록 -->
|
||||
<div class="sidebar-section sidebar-section-flex">
|
||||
<div class="sidebar-section-header">
|
||||
<h4>📋 등록된 설비</h4>
|
||||
<span id="fsRegisteredCount" class="badge">0개</span>
|
||||
</div>
|
||||
<div class="sidebar-section-body sidebar-list" id="fsEquipmentList">
|
||||
<p class="empty-message">등록된 설비가 없습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 레이아웃 지도 설정 모달 -->
|
||||
<div id="layoutMapModal" class="wp-modal-overlay" style="display: none;">
|
||||
<div class="wp-modal" style="max-width: 90vw; max-height: 90vh; width: 1000px;">
|
||||
<div class="wp-modal-header">
|
||||
<h2 class="wp-modal-title">
|
||||
<span>🗺️</span>
|
||||
공장 레이아웃 지도 설정
|
||||
</h2>
|
||||
<button class="wp-modal-close" onclick="closeLayoutMapModal()">×</button>
|
||||
</div>
|
||||
<div class="wp-modal-body" style="overflow-y: auto;">
|
||||
<!-- Step 1: 이미지 업로드 -->
|
||||
<div style="border-bottom: 2px solid #e5e7eb; padding-bottom: 20px; margin-bottom: 20px;">
|
||||
<h3 style="font-size: 16px; font-weight: 600; margin-bottom: 12px;">Step 1. 공장 레이아웃 이미지 업로드</h3>
|
||||
<div class="wp-form-group">
|
||||
<label class="wp-form-label">현재 이미지</label>
|
||||
<div id="currentLayoutImage" style="background: #f9fafb; border: 2px dashed #cbd5e1; padding: 20px; border-radius: 8px; text-align: center;">
|
||||
<span style="color: #94a3b8;">업로드된 이미지가 없습니다</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wp-form-group">
|
||||
<label class="wp-form-label">새 이미지 업로드</label>
|
||||
<input type="file" id="layoutImageFile" accept="image/*" class="wp-form-control" onchange="previewLayoutImage(event)">
|
||||
<span class="wp-form-help">JPG, PNG, GIF 형식 지원 (최대 5MB)</span>
|
||||
</div>
|
||||
<button type="button" class="wp-btn wp-btn-primary" onclick="uploadLayoutImage()">이미지 업로드</button>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: 작업장 영역 정의 -->
|
||||
<div>
|
||||
<h3 style="font-size: 16px; font-weight: 600; margin-bottom: 12px;">Step 2. 작업장 영역 정의</h3>
|
||||
<p style="color: #64748b; font-size: 14px; margin-bottom: 16px;">
|
||||
이미지 위에 마우스로 드래그하여 각 작업장의 위치를 지정하세요
|
||||
</p>
|
||||
|
||||
<!-- 영역 그리기 캔버스 -->
|
||||
<div style="position: relative; display: inline-block; margin-bottom: 20px;" id="canvasContainer">
|
||||
<canvas id="regionCanvas" style="border: 2px solid #cbd5e1; cursor: crosshair; max-width: 100%;"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- 작업장 선택 및 영역 목록 -->
|
||||
<div class="wp-form-group">
|
||||
<label class="wp-form-label">작업장 선택</label>
|
||||
<select id="regionWorkplaceSelect" class="wp-form-control" style="margin-bottom: 12px;">
|
||||
<option value="">작업장을 선택하세요</option>
|
||||
</select>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button type="button" class="wp-btn wp-btn-outline" onclick="clearCurrentRegion()">현재 영역 지우기</button>
|
||||
<button type="button" class="wp-btn wp-btn-primary" onclick="saveRegion()">선택 영역 저장</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 정의된 영역 목록 -->
|
||||
<div class="wp-form-group">
|
||||
<label class="wp-form-label">정의된 영역 목록</label>
|
||||
<div id="regionList" style="background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px; min-height: 100px;">
|
||||
<!-- 영역 목록이 여기에 표시됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wp-modal-footer">
|
||||
<button type="button" class="wp-btn wp-btn-outline" onclick="closeLayoutMapModal()">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업장 관리 모듈 (리팩토링된 구조) -->
|
||||
<script src="/js/workplace-management/state.js?v=1"></script>
|
||||
<script src="/js/workplace-management/utils.js?v=1"></script>
|
||||
<script src="/js/workplace-management/api.js?v=1"></script>
|
||||
<script src="/js/workplace-management/index.js?v=1"></script>
|
||||
<!-- 기존 UI 로직 (점진적 마이그레이션) -->
|
||||
<script type="module" src="/js/workplace-management.js?v=9"></script>
|
||||
<script type="module" src="/js/workplace-layout-map.js?v=1"></script>
|
||||
</body>
|
||||
</html>
|
||||
762
deploy/tkfb-package/web-ui/pages/attendance/annual-overview.html
Normal file
762
deploy/tkfb-package/web-ui/pages/attendance/annual-overview.html
Normal file
@@ -0,0 +1,762 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>연간 연차 현황 | 테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<style>
|
||||
.page-wrapper {
|
||||
padding: 1.5rem;
|
||||
max-width: 1200px;
|
||||
}
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.page-title { font-size: 1.5rem; font-weight: 700; margin: 0; }
|
||||
.page-desc { color: #6b7280; font-size: 0.875rem; margin-top: 0.25rem; }
|
||||
.controls { display: flex; gap: 0.5rem; align-items: center; }
|
||||
.controls select {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.btn-primary { background: #3b82f6; color: white; }
|
||||
.btn-success { background: #10b981; color: white; }
|
||||
.btn-sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; }
|
||||
|
||||
/* 범례 */
|
||||
.legend-box {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #f9fafb;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: #6b7280;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.legend-item { display: flex; align-items: center; gap: 0.25rem; }
|
||||
.legend-dot {
|
||||
width: 12px; height: 12px; border-radius: 2px;
|
||||
}
|
||||
.dot-carryover { background: #fef3c7; border: 1px solid #f59e0b; }
|
||||
.dot-annual { background: #dbeafe; border: 1px solid #3b82f6; }
|
||||
.dot-longservice { background: #f3e8ff; border: 1px solid #a855f7; }
|
||||
.dot-special { background: #fce7f3; border: 1px solid #ec4899; }
|
||||
|
||||
/* 테이블 */
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
background: white;
|
||||
}
|
||||
.data-table th, .data-table td {
|
||||
padding: 0.6rem 0.5rem;
|
||||
text-align: center;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
.data-table th {
|
||||
background: #f9fafb;
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.data-table th.col-carryover { background: #fef3c7; color: #92400e; }
|
||||
.data-table th.col-annual { background: #dbeafe; color: #1e40af; }
|
||||
.data-table th.col-longservice { background: #f3e8ff; color: #7c3aed; }
|
||||
.data-table th.col-special { background: #fce7f3; color: #be185d; }
|
||||
.data-table th.col-total { background: #dcfce7; color: #166534; }
|
||||
.data-table td.worker-name { text-align: left; font-weight: 500; }
|
||||
.data-table tr:hover { background: #f9fafb; }
|
||||
|
||||
/* 입력 필드 */
|
||||
.num-input {
|
||||
width: 55px;
|
||||
padding: 0.25rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.25rem;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.num-input:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
.num-input.negative { color: #dc2626; }
|
||||
|
||||
/* 경조사 버튼 */
|
||||
.special-btn {
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
background: #fce7f3;
|
||||
color: #be185d;
|
||||
border: 1px solid #f9a8d4;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.special-btn:hover { background: #fbcfe8; }
|
||||
.special-count {
|
||||
display: inline-block;
|
||||
min-width: 20px;
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: #be185d;
|
||||
color: white;
|
||||
border-radius: 10px;
|
||||
font-size: 0.7rem;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
/* 잔여 */
|
||||
.remaining { font-weight: 700; }
|
||||
.remaining.positive { color: #059669; }
|
||||
.remaining.zero { color: #6b7280; }
|
||||
.remaining.negative { color: #dc2626; }
|
||||
|
||||
/* 저장 바 */
|
||||
.save-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.save-status { font-size: 0.875rem; color: #6b7280; }
|
||||
|
||||
/* 경조사 모달 */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 1000;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.modal-overlay.active { display: flex; }
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 0.75rem;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
.modal-title { font-size: 1rem; font-weight: 600; }
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
}
|
||||
.special-list { margin-bottom: 1rem; }
|
||||
.special-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
.special-item select {
|
||||
flex: 1;
|
||||
padding: 0.375rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.special-item input {
|
||||
width: 60px;
|
||||
padding: 0.375rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.25rem;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.special-item .delete-btn {
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.add-special-btn {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
background: #f3f4f6;
|
||||
border: 1px dashed #9ca3af;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
.add-special-btn:hover { background: #e5e7eb; }
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.loading { text-align: center; padding: 2rem; color: #6b7280; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="has-sidebar">
|
||||
<div id="navbar-container"></div>
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<main class="main-content">
|
||||
<div class="page-wrapper">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">연간 연차 현황</h1>
|
||||
<p class="page-desc">작업자별 연차 발생 및 사용 현황을 관리합니다</p>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<select id="yearSelect"></select>
|
||||
<button class="btn btn-primary" onclick="loadData()">조회</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 범례 -->
|
||||
<div class="legend-box">
|
||||
<span style="font-weight:500;">차감 우선순위:</span>
|
||||
<div class="legend-item"><div class="legend-dot dot-carryover"></div>1. 이월</div>
|
||||
<div class="legend-item"><div class="legend-dot dot-annual"></div>2. 정기연차</div>
|
||||
<div class="legend-item"><div class="legend-dot dot-longservice"></div>3. 장기근속</div>
|
||||
<div class="legend-item"><div class="legend-dot dot-special"></div>4. 경조사</div>
|
||||
</div>
|
||||
|
||||
<!-- 테이블 -->
|
||||
<div class="card">
|
||||
<div class="card-body" style="padding:0;overflow-x:auto;">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:40px">No</th>
|
||||
<th style="min-width:70px">이름</th>
|
||||
<th class="col-carryover" style="width:80px">이월</th>
|
||||
<th class="col-annual" style="width:80px">정기연차</th>
|
||||
<th class="col-longservice" style="width:80px">장기근속</th>
|
||||
<th class="col-special" style="width:100px">경조사</th>
|
||||
<th class="col-total" style="width:70px">총 발생</th>
|
||||
<th class="col-total" style="width:70px">총 사용</th>
|
||||
<th class="col-total" style="width:70px">잔여</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tableBody">
|
||||
<tr><td colspan="9" class="loading">로딩 중...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 저장 바 -->
|
||||
<div class="save-bar">
|
||||
<span class="save-status" id="saveStatus">변경사항이 있으면 저장 버튼을 눌러주세요</span>
|
||||
<button class="btn btn-success" onclick="saveAll()">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 경조사 모달 -->
|
||||
<div class="modal-overlay" id="specialModal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title" id="specialModalTitle">경조사 휴가</h3>
|
||||
<button class="modal-close" onclick="closeSpecialModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="special-list" id="specialList">
|
||||
<!-- 동적 생성 -->
|
||||
</div>
|
||||
<button class="add-special-btn" onclick="addSpecialItem()">+ 경조사 추가</button>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-primary" onclick="saveSpecialAndClose()">확인</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script>
|
||||
// axios 설정
|
||||
(function() {
|
||||
const check = setInterval(() => {
|
||||
if (window.API_BASE_URL) {
|
||||
clearInterval(check);
|
||||
axios.defaults.baseURL = window.API_BASE_URL;
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
}, 50);
|
||||
})();
|
||||
|
||||
// 전역 변수
|
||||
let workers = [];
|
||||
let currentYear = new Date().getFullYear();
|
||||
let vacationData = {}; // { workerId: { carryover, annual, longService, specials: [{type, days}], totalUsed } }
|
||||
let currentWorkerId = null;
|
||||
|
||||
// 경조사 유형
|
||||
const specialTypes = [
|
||||
{ code: 'WEDDING', name: '결혼', defaultDays: 5 },
|
||||
{ code: 'SPOUSE_BIRTH', name: '배우자 출산', defaultDays: 10 },
|
||||
{ code: 'CHILD_WEDDING', name: '자녀 결혼', defaultDays: 1 },
|
||||
{ code: 'CONDOLENCE_PARENT', name: '부모 사망', defaultDays: 5 },
|
||||
{ code: 'CONDOLENCE_SPOUSE_PARENT', name: '배우자 부모 사망', defaultDays: 5 },
|
||||
{ code: 'CONDOLENCE_GRANDPARENT', name: '조부모 사망', defaultDays: 3 },
|
||||
{ code: 'CONDOLENCE_SIBLING', name: '형제자매 사망', defaultDays: 3 },
|
||||
{ code: 'OTHER', name: '기타', defaultDays: 1 }
|
||||
];
|
||||
|
||||
// 초기화
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await waitForAxios();
|
||||
initYearSelector();
|
||||
await loadData();
|
||||
});
|
||||
|
||||
function waitForAxios() {
|
||||
return new Promise(resolve => {
|
||||
const check = setInterval(() => {
|
||||
if (axios.defaults.baseURL) { clearInterval(check); resolve(); }
|
||||
}, 50);
|
||||
setTimeout(() => { clearInterval(check); resolve(); }, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
function initYearSelector() {
|
||||
const select = document.getElementById('yearSelect');
|
||||
const now = new Date().getFullYear();
|
||||
for (let y = now - 2; y <= now + 1; y++) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = y;
|
||||
opt.textContent = `${y}년`;
|
||||
if (y === now) opt.selected = true;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
currentYear = parseInt(document.getElementById('yearSelect').value);
|
||||
document.getElementById('tableBody').innerHTML = '<tr><td colspan="9" class="loading">로딩 중...</td></tr>';
|
||||
|
||||
try {
|
||||
// 작업자 로드
|
||||
const workersRes = await axios.get('/workers?limit=100');
|
||||
workers = (workersRes.data.data || [])
|
||||
.filter(w => w.status === 'active' && w.employment_status === 'employed')
|
||||
.sort((a, b) => a.worker_name.localeCompare(b.worker_name, 'ko'));
|
||||
|
||||
// 휴가 잔액 로드
|
||||
let balances = [];
|
||||
try {
|
||||
const balancesRes = await axios.get(`/vacation-balances/year/${currentYear}`);
|
||||
balances = balancesRes.data.data || [];
|
||||
} catch (e) { console.log('휴가 잔액 로드 실패'); }
|
||||
|
||||
// 데이터 정리
|
||||
vacationData = {};
|
||||
workers.forEach(w => {
|
||||
vacationData[w.worker_id] = {
|
||||
carryover: 0,
|
||||
annual: 0,
|
||||
longService: 0,
|
||||
specials: [],
|
||||
totalUsed: 0
|
||||
};
|
||||
});
|
||||
|
||||
// 잔액 데이터 매핑
|
||||
balances.forEach(b => {
|
||||
if (!vacationData[b.worker_id]) return;
|
||||
const code = b.type_code || '';
|
||||
const data = vacationData[b.worker_id];
|
||||
|
||||
if (code === 'CARRYOVER' || b.type_name === '이월') {
|
||||
data.carryover = b.total_days || 0;
|
||||
data.totalUsed += b.used_days || 0;
|
||||
} else if (code === 'ANNUAL' || b.type_name === '정기연차' || b.type_name === '연차') {
|
||||
data.annual = b.total_days || 0;
|
||||
data.totalUsed += b.used_days || 0;
|
||||
} else if (code === 'LONG_SERVICE' || b.type_name === '장기근속') {
|
||||
data.longService = b.total_days || 0;
|
||||
data.totalUsed += b.used_days || 0;
|
||||
} else if (code.startsWith('SPECIAL_') || specialTypes.some(st => st.code === code)) {
|
||||
data.specials.push({
|
||||
type: code,
|
||||
typeName: b.type_name,
|
||||
days: b.total_days || 0,
|
||||
id: b.id
|
||||
});
|
||||
data.totalUsed += b.used_days || 0;
|
||||
}
|
||||
});
|
||||
|
||||
renderTable();
|
||||
} catch (error) {
|
||||
console.error('데이터 로드 오류:', error);
|
||||
document.getElementById('tableBody').innerHTML = '<tr><td colspan="9" class="loading" style="color:#ef4444;">데이터 로드 실패</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const tbody = document.getElementById('tableBody');
|
||||
|
||||
if (workers.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="9" class="loading">작업자가 없습니다</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = workers.map((w, idx) => {
|
||||
const d = vacationData[w.worker_id] || { carryover: 0, annual: 0, longService: 0, specials: [], totalUsed: 0 };
|
||||
const carryover = parseFloat(d.carryover) || 0;
|
||||
const annual = parseFloat(d.annual) || 0;
|
||||
const longService = parseFloat(d.longService) || 0;
|
||||
const totalUsed = parseFloat(d.totalUsed) || 0;
|
||||
const specialTotal = (d.specials || []).reduce((sum, s) => sum + (parseFloat(s.days) || 0), 0);
|
||||
const totalGenerated = carryover + annual + longService + specialTotal;
|
||||
const remaining = totalGenerated - totalUsed;
|
||||
const remainingClass = remaining > 0 ? 'positive' : remaining < 0 ? 'negative' : 'zero';
|
||||
|
||||
return `
|
||||
<tr data-worker-id="${w.worker_id}">
|
||||
<td>${idx + 1}</td>
|
||||
<td class="worker-name">${w.worker_name}</td>
|
||||
<td>
|
||||
<input type="number" class="num-input ${carryover < 0 ? 'negative' : ''}"
|
||||
value="${carryover}" step="0.5"
|
||||
data-field="carryover"
|
||||
onchange="updateField(${w.worker_id}, 'carryover', this.value)">
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" class="num-input"
|
||||
value="${annual}" step="0.5"
|
||||
data-field="annual"
|
||||
onchange="updateField(${w.worker_id}, 'annual', this.value)">
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" class="num-input"
|
||||
value="${longService}" step="0.5"
|
||||
data-field="longService"
|
||||
onchange="updateField(${w.worker_id}, 'longService', this.value)">
|
||||
</td>
|
||||
<td>
|
||||
<button class="special-btn" onclick="openSpecialModal(${w.worker_id}, '${w.worker_name}')">
|
||||
${(d.specials || []).length > 0 ? `${specialTotal}일` : '추가'}
|
||||
${(d.specials || []).length > 0 ? `<span class="special-count">${d.specials.length}</span>` : ''}
|
||||
</button>
|
||||
</td>
|
||||
<td style="font-weight:600;color:#059669;">${totalGenerated.toFixed(2)}</td>
|
||||
<td style="color:#6b7280;">${totalUsed > 0 ? totalUsed.toFixed(2) : '-'}</td>
|
||||
<td class="remaining ${remainingClass}">${remaining.toFixed(2)}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function updateField(workerId, field, value) {
|
||||
const val = parseFloat(value) || 0;
|
||||
if (!vacationData[workerId]) {
|
||||
vacationData[workerId] = { carryover: 0, annual: 0, longService: 0, specials: [], totalUsed: 0 };
|
||||
}
|
||||
vacationData[workerId][field] = val;
|
||||
|
||||
// 입력 스타일 업데이트
|
||||
const input = document.querySelector(`tr[data-worker-id="${workerId}"] input[data-field="${field}"]`);
|
||||
if (input) {
|
||||
input.classList.toggle('negative', val < 0);
|
||||
}
|
||||
|
||||
// 행 합계 업데이트
|
||||
updateRowTotals(workerId);
|
||||
markChanged();
|
||||
}
|
||||
|
||||
function updateRowTotals(workerId) {
|
||||
const row = document.querySelector(`tr[data-worker-id="${workerId}"]`);
|
||||
if (!row) return;
|
||||
|
||||
const d = vacationData[workerId];
|
||||
if (!d) return;
|
||||
|
||||
const carryover = parseFloat(d.carryover) || 0;
|
||||
const annual = parseFloat(d.annual) || 0;
|
||||
const longService = parseFloat(d.longService) || 0;
|
||||
const totalUsed = parseFloat(d.totalUsed) || 0;
|
||||
const specialTotal = (d.specials || []).reduce((sum, s) => sum + (parseFloat(s.days) || 0), 0);
|
||||
const totalGenerated = carryover + annual + longService + specialTotal;
|
||||
const remaining = totalGenerated - totalUsed;
|
||||
|
||||
const cells = row.querySelectorAll('td');
|
||||
cells[6].textContent = totalGenerated.toFixed(2);
|
||||
cells[7].textContent = totalUsed > 0 ? totalUsed.toFixed(2) : '-';
|
||||
cells[8].textContent = remaining.toFixed(2);
|
||||
cells[8].className = `remaining ${remaining > 0 ? 'positive' : remaining < 0 ? 'negative' : 'zero'}`;
|
||||
}
|
||||
|
||||
function markChanged() {
|
||||
document.getElementById('saveStatus').textContent = '변경사항이 있습니다. 저장 버튼을 눌러주세요.';
|
||||
document.getElementById('saveStatus').style.color = '#f59e0b';
|
||||
}
|
||||
|
||||
// ===== 경조사 모달 =====
|
||||
function openSpecialModal(workerId, workerName) {
|
||||
currentWorkerId = workerId;
|
||||
document.getElementById('specialModalTitle').textContent = `${workerName} - 경조사 휴가`;
|
||||
renderSpecialList();
|
||||
document.getElementById('specialModal').classList.add('active');
|
||||
}
|
||||
|
||||
function closeSpecialModal() {
|
||||
document.getElementById('specialModal').classList.remove('active');
|
||||
currentWorkerId = null;
|
||||
}
|
||||
|
||||
function renderSpecialList() {
|
||||
const container = document.getElementById('specialList');
|
||||
const specials = vacationData[currentWorkerId]?.specials || [];
|
||||
|
||||
if (specials.length === 0) {
|
||||
container.innerHTML = '<p style="text-align:center;color:#9ca3af;padding:1rem;">경조사 휴가가 없습니다</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = specials.map((s, idx) => `
|
||||
<div class="special-item" data-idx="${idx}">
|
||||
<select onchange="updateSpecialType(${idx}, this.value)">
|
||||
${specialTypes.map(st => `
|
||||
<option value="${st.code}" ${s.type === st.code ? 'selected' : ''}>${st.name}</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
<input type="number" value="${s.days}" step="0.5" min="0"
|
||||
onchange="updateSpecialDays(${idx}, this.value)">
|
||||
<span>일</span>
|
||||
<button class="delete-btn" onclick="deleteSpecialItem(${idx})">삭제</button>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function addSpecialItem() {
|
||||
if (!vacationData[currentWorkerId]) return;
|
||||
vacationData[currentWorkerId].specials.push({
|
||||
type: 'WEDDING',
|
||||
typeName: '결혼',
|
||||
days: 5
|
||||
});
|
||||
renderSpecialList();
|
||||
}
|
||||
|
||||
function updateSpecialType(idx, typeCode) {
|
||||
const specials = vacationData[currentWorkerId]?.specials;
|
||||
if (!specials || !specials[idx]) return;
|
||||
|
||||
const typeInfo = specialTypes.find(st => st.code === typeCode);
|
||||
specials[idx].type = typeCode;
|
||||
specials[idx].typeName = typeInfo?.name || typeCode;
|
||||
specials[idx].days = typeInfo?.defaultDays || specials[idx].days;
|
||||
|
||||
renderSpecialList();
|
||||
}
|
||||
|
||||
function updateSpecialDays(idx, value) {
|
||||
const specials = vacationData[currentWorkerId]?.specials;
|
||||
if (!specials || !specials[idx]) return;
|
||||
specials[idx].days = parseFloat(value) || 0;
|
||||
}
|
||||
|
||||
function deleteSpecialItem(idx) {
|
||||
const specials = vacationData[currentWorkerId]?.specials;
|
||||
if (!specials) return;
|
||||
specials.splice(idx, 1);
|
||||
renderSpecialList();
|
||||
}
|
||||
|
||||
function saveSpecialAndClose() {
|
||||
updateRowTotals(currentWorkerId);
|
||||
renderTable(); // 경조사 버튼 텍스트 업데이트
|
||||
markChanged();
|
||||
closeSpecialModal();
|
||||
}
|
||||
|
||||
// ESC로 모달 닫기
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') closeSpecialModal();
|
||||
});
|
||||
document.getElementById('specialModal').addEventListener('click', e => {
|
||||
if (e.target.id === 'specialModal') closeSpecialModal();
|
||||
});
|
||||
|
||||
// ===== 저장 =====
|
||||
async function saveAll() {
|
||||
const balancesToSave = [];
|
||||
|
||||
// 휴가 유형 ID 매핑 (서버에서 가져와야 하지만 일단 하드코딩)
|
||||
// 실제로는 vacation_types 테이블에서 조회해야 함
|
||||
const typeIdMap = {
|
||||
'CARRYOVER': null,
|
||||
'ANNUAL': null,
|
||||
'LONG_SERVICE': null
|
||||
};
|
||||
|
||||
// 휴가 유형 ID 조회
|
||||
try {
|
||||
const typesRes = await axios.get('/vacation-types');
|
||||
const types = typesRes.data.data || [];
|
||||
types.forEach(t => {
|
||||
if (t.type_code === 'CARRYOVER' || t.type_name === '이월') typeIdMap['CARRYOVER'] = t.id;
|
||||
if (t.type_code === 'ANNUAL' || t.type_name === '정기연차' || t.type_name === '연차') typeIdMap['ANNUAL'] = t.id;
|
||||
if (t.type_code === 'LONG_SERVICE' || t.type_name === '장기근속') typeIdMap['LONG_SERVICE'] = t.id;
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('휴가 유형 로드 실패:', e);
|
||||
}
|
||||
|
||||
// 필요한 유형이 없으면 생성 (deduct_days 필수)
|
||||
if (!typeIdMap['CARRYOVER']) {
|
||||
try {
|
||||
const res = await axios.post('/vacation-types', { type_code: 'CARRYOVER', type_name: '이월', deduct_days: 1, priority: 1 });
|
||||
typeIdMap['CARRYOVER'] = res.data.data?.id;
|
||||
} catch (e) { console.error('이월 유형 생성 실패'); }
|
||||
}
|
||||
if (!typeIdMap['ANNUAL']) {
|
||||
try {
|
||||
const res = await axios.post('/vacation-types', { type_code: 'ANNUAL', type_name: '정기연차', deduct_days: 1, priority: 2 });
|
||||
typeIdMap['ANNUAL'] = res.data.data?.id;
|
||||
} catch (e) { console.error('정기연차 유형 생성 실패'); }
|
||||
}
|
||||
if (!typeIdMap['LONG_SERVICE']) {
|
||||
try {
|
||||
const res = await axios.post('/vacation-types', { type_code: 'LONG_SERVICE', type_name: '장기근속', deduct_days: 1, priority: 3 });
|
||||
typeIdMap['LONG_SERVICE'] = res.data.data?.id;
|
||||
} catch (e) { console.error('장기근속 유형 생성 실패'); }
|
||||
}
|
||||
|
||||
// 데이터 수집
|
||||
for (const w of workers) {
|
||||
const d = vacationData[w.worker_id];
|
||||
if (!d) continue;
|
||||
|
||||
if (typeIdMap['CARRYOVER']) {
|
||||
balancesToSave.push({
|
||||
worker_id: w.worker_id,
|
||||
vacation_type_id: typeIdMap['CARRYOVER'],
|
||||
year: currentYear,
|
||||
total_days: d.carryover
|
||||
});
|
||||
}
|
||||
if (typeIdMap['ANNUAL']) {
|
||||
balancesToSave.push({
|
||||
worker_id: w.worker_id,
|
||||
vacation_type_id: typeIdMap['ANNUAL'],
|
||||
year: currentYear,
|
||||
total_days: d.annual
|
||||
});
|
||||
}
|
||||
if (typeIdMap['LONG_SERVICE']) {
|
||||
balancesToSave.push({
|
||||
worker_id: w.worker_id,
|
||||
vacation_type_id: typeIdMap['LONG_SERVICE'],
|
||||
year: currentYear,
|
||||
total_days: d.longService
|
||||
});
|
||||
}
|
||||
|
||||
// 경조사 데이터 저장
|
||||
for (const special of d.specials) {
|
||||
if (special.days > 0) {
|
||||
// 경조사 유형 ID 조회 또는 생성
|
||||
let specialTypeId = typeIdMap[special.type];
|
||||
if (!specialTypeId) {
|
||||
try {
|
||||
const typesRes = await axios.get('/vacation-types');
|
||||
const existingType = (typesRes.data.data || []).find(t => t.type_code === special.type);
|
||||
if (existingType) {
|
||||
specialTypeId = existingType.id;
|
||||
typeIdMap[special.type] = specialTypeId;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
if (!specialTypeId) {
|
||||
try {
|
||||
const typeInfo = specialTypes.find(st => st.code === special.type);
|
||||
const res = await axios.post('/vacation-types', {
|
||||
type_code: special.type,
|
||||
type_name: typeInfo?.name || special.type,
|
||||
deduct_days: typeInfo?.defaultDays || 1,
|
||||
priority: 4
|
||||
});
|
||||
specialTypeId = res.data.data?.id;
|
||||
typeIdMap[special.type] = specialTypeId;
|
||||
} catch (e) { console.error(`${special.type} 유형 생성 실패`); }
|
||||
}
|
||||
if (specialTypeId) {
|
||||
balancesToSave.push({
|
||||
worker_id: w.worker_id,
|
||||
vacation_type_id: specialTypeId,
|
||||
year: currentYear,
|
||||
total_days: special.days,
|
||||
notes: special.typeName || special.type
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (balancesToSave.length === 0) {
|
||||
alert('저장할 데이터가 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await axios.post('/vacation-balances/bulk-upsert', { balances: balancesToSave });
|
||||
if (res.data.success) {
|
||||
alert(res.data.message);
|
||||
document.getElementById('saveStatus').textContent = '저장 완료';
|
||||
document.getElementById('saveStatus').style.color = '#10b981';
|
||||
await loadData();
|
||||
} else {
|
||||
alert('저장 실패: ' + res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('저장 오류:', error);
|
||||
alert('저장 중 오류: ' + (error.response?.data?.message || error.message));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
398
deploy/tkfb-package/web-ui/pages/attendance/checkin.html
Normal file
398
deploy/tkfb-package/web-ui/pages/attendance/checkin.html
Normal file
@@ -0,0 +1,398 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>출근 체크 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
.page-wrapper {
|
||||
padding: 1.5rem;
|
||||
max-width: 1200px;
|
||||
}
|
||||
.page-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.page-desc {
|
||||
color: #64748b;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.controls input[type="date"] {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.btn-primary { background: #3b82f6; color: white; }
|
||||
.btn-outline { background: white; border: 1px solid #d1d5db; }
|
||||
.btn-success { background: #10b981; color: white; }
|
||||
|
||||
/* 요약 바 */
|
||||
.summary-bar {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
.summary-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.summary-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.dot-present { background: #10b981; }
|
||||
.dot-absent { background: #ef4444; }
|
||||
.dot-vacation { background: #3b82f6; }
|
||||
.summary-count { font-weight: 700; }
|
||||
.summary-label { color: #6b7280; font-size: 0.875rem; }
|
||||
|
||||
/* 작업자 목록 */
|
||||
.worker-list {
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
.worker-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
margin: 0.25rem;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 2rem;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.worker-chip:hover {
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
.worker-chip.present {
|
||||
border-color: #10b981;
|
||||
background: #ecfdf5;
|
||||
}
|
||||
.worker-chip.absent {
|
||||
border-color: #ef4444;
|
||||
background: #fef2f2;
|
||||
}
|
||||
.worker-chip.vacation {
|
||||
border-color: #3b82f6;
|
||||
background: #eff6ff;
|
||||
cursor: default;
|
||||
}
|
||||
.chip-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #d1d5db;
|
||||
}
|
||||
.worker-chip.present .chip-dot { background: #10b981; }
|
||||
.worker-chip.absent .chip-dot { background: #ef4444; }
|
||||
.worker-chip.vacation .chip-dot { background: #3b82f6; }
|
||||
|
||||
.save-section {
|
||||
text-align: center;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.btn-save {
|
||||
padding: 0.75rem 2rem;
|
||||
font-size: 1rem;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-save:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
.btn-save:disabled {
|
||||
background: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn-save.saved {
|
||||
background: #10b981;
|
||||
}
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.status-badge.saved {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
.status-badge.unsaved {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="has-sidebar">
|
||||
<div id="navbar-container"></div>
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<main class="main-content">
|
||||
<div class="page-wrapper">
|
||||
<h1 class="page-title">출근 체크</h1>
|
||||
<p class="page-desc">클릭하여 출근/결근 상태를 변경하세요</p>
|
||||
|
||||
<div class="controls">
|
||||
<input type="date" id="selectedDate">
|
||||
<button class="btn btn-primary" onclick="loadCheckinData()">새로고침</button>
|
||||
<button class="btn btn-outline" onclick="setAllPresent()">전체 출근</button>
|
||||
<button class="btn btn-outline" onclick="setAllAbsent()">전체 결근</button>
|
||||
</div>
|
||||
|
||||
<div class="summary-bar">
|
||||
<div class="summary-item">
|
||||
<span class="summary-dot dot-present"></span>
|
||||
<span class="summary-count" id="presentCount">0</span>
|
||||
<span class="summary-label">출근</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-dot dot-absent"></span>
|
||||
<span class="summary-count" id="absentCount">0</span>
|
||||
<span class="summary-label">결근</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-dot dot-vacation"></span>
|
||||
<span class="summary-count" id="vacationCount">0</span>
|
||||
<span class="summary-label">연차</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="worker-list" id="workerList">
|
||||
<!-- 작업자 칩이 여기에 렌더링됩니다 -->
|
||||
</div>
|
||||
|
||||
<div class="save-section">
|
||||
<div id="saveStatus" style="margin-bottom: 1rem;"></div>
|
||||
<button id="saveBtn" class="btn-save" onclick="saveCheckin()">출근 체크 저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script type="module">
|
||||
</script>
|
||||
<script>
|
||||
(function() {
|
||||
const checkApiConfig = setInterval(() => {
|
||||
if (window.API_BASE_URL) {
|
||||
clearInterval(checkApiConfig);
|
||||
axios.defaults.baseURL = window.API_BASE_URL;
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
}, 50);
|
||||
})();
|
||||
|
||||
let workers = [];
|
||||
let checkinStatus = {};
|
||||
let isAlreadySaved = false;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await waitForAxiosConfig();
|
||||
document.getElementById('selectedDate').value = new Date().toISOString().split('T')[0];
|
||||
loadCheckinData();
|
||||
|
||||
// 날짜 변경 시 자동 로드
|
||||
document.getElementById('selectedDate').addEventListener('change', loadCheckinData);
|
||||
});
|
||||
|
||||
function waitForAxiosConfig() {
|
||||
return new Promise((resolve) => {
|
||||
const check = setInterval(() => {
|
||||
if (axios.defaults.baseURL) {
|
||||
clearInterval(check);
|
||||
resolve();
|
||||
}
|
||||
}, 50);
|
||||
setTimeout(() => { clearInterval(check); resolve(); }, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadCheckinData() {
|
||||
const selectedDate = document.getElementById('selectedDate').value;
|
||||
if (!selectedDate) return alert('날짜를 선택해주세요.');
|
||||
|
||||
try {
|
||||
const [workersRes, checkinRes, recordsRes] = await Promise.all([
|
||||
axios.get('/workers?limit=100'),
|
||||
axios.get(`/attendance/checkin-list?date=${selectedDate}`).catch(() => ({ data: { data: [] } })),
|
||||
axios.get(`/attendance/daily-records?date=${selectedDate}`).catch(() => ({ data: { data: [] } }))
|
||||
]);
|
||||
|
||||
const allWorkers = workersRes.data.data || [];
|
||||
workers = allWorkers.filter(w => w.status === 'active' && (!w.employment_status || w.employment_status === 'employed'));
|
||||
const checkinList = checkinRes.data.data || [];
|
||||
const records = recordsRes.data.data || [];
|
||||
|
||||
// 이미 저장된 기록이 있는지 확인
|
||||
isAlreadySaved = records.length > 0;
|
||||
|
||||
checkinStatus = {};
|
||||
workers.forEach(w => {
|
||||
const checkin = checkinList.find(c => c.worker_id === w.worker_id);
|
||||
const record = records.find(r => r.worker_id === w.worker_id);
|
||||
|
||||
if (checkin?.vacation_status === 'approved' || record?.vacation_type_id) {
|
||||
checkinStatus[w.worker_id] = { status: 'vacation', vacationType: checkin?.vacation_type_name || record?.vacation_type_name || '연차' };
|
||||
} else if (record && record.is_present === 0) {
|
||||
checkinStatus[w.worker_id] = { status: 'absent' };
|
||||
} else if (record && record.is_present === 1) {
|
||||
checkinStatus[w.worker_id] = { status: 'present' };
|
||||
} else {
|
||||
// 기록이 없으면 기본 출근
|
||||
checkinStatus[w.worker_id] = { status: 'present' };
|
||||
}
|
||||
});
|
||||
|
||||
render();
|
||||
updateSaveStatus();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('데이터 로드 실패');
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
const container = document.getElementById('workerList');
|
||||
if (workers.length === 0) {
|
||||
container.innerHTML = '<p style="color:#6b7280;text-align:center;padding:2rem;">작업자가 없습니다</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = workers.map(w => {
|
||||
const s = checkinStatus[w.worker_id] || { status: 'present' };
|
||||
const label = s.status === 'present' ? '출근' : s.status === 'absent' ? '결근' : (s.vacationType || '연차');
|
||||
return `<span class="worker-chip ${s.status}" onclick="toggle(${w.worker_id})"><span class="chip-dot"></span>${w.worker_name} <small style="color:#6b7280">${label}</small></span>`;
|
||||
}).join('');
|
||||
|
||||
updateSummary();
|
||||
}
|
||||
|
||||
function toggle(id) {
|
||||
const s = checkinStatus[id];
|
||||
if (s.status === 'vacation') return;
|
||||
s.status = s.status === 'present' ? 'absent' : 'present';
|
||||
render();
|
||||
}
|
||||
|
||||
function setAllPresent() {
|
||||
workers.forEach(w => {
|
||||
if (checkinStatus[w.worker_id]?.status !== 'vacation') {
|
||||
checkinStatus[w.worker_id] = { status: 'present' };
|
||||
}
|
||||
});
|
||||
render();
|
||||
}
|
||||
|
||||
function setAllAbsent() {
|
||||
workers.forEach(w => {
|
||||
if (checkinStatus[w.worker_id]?.status !== 'vacation') {
|
||||
checkinStatus[w.worker_id] = { status: 'absent' };
|
||||
}
|
||||
});
|
||||
render();
|
||||
}
|
||||
|
||||
function updateSummary() {
|
||||
let p = 0, a = 0, v = 0;
|
||||
Object.values(checkinStatus).forEach(s => {
|
||||
if (s.status === 'present') p++;
|
||||
else if (s.status === 'absent') a++;
|
||||
else v++;
|
||||
});
|
||||
document.getElementById('presentCount').textContent = p;
|
||||
document.getElementById('absentCount').textContent = a;
|
||||
document.getElementById('vacationCount').textContent = v;
|
||||
}
|
||||
|
||||
function updateSaveStatus() {
|
||||
const statusEl = document.getElementById('saveStatus');
|
||||
const saveBtn = document.getElementById('saveBtn');
|
||||
|
||||
if (isAlreadySaved) {
|
||||
statusEl.innerHTML = '<span class="status-badge saved">이 날짜는 이미 출근 체크가 완료되었습니다</span>';
|
||||
saveBtn.textContent = '수정하여 다시 저장';
|
||||
saveBtn.classList.add('saved');
|
||||
} else {
|
||||
statusEl.innerHTML = '<span class="status-badge unsaved">아직 저장되지 않았습니다</span>';
|
||||
saveBtn.textContent = '출근 체크 저장';
|
||||
saveBtn.classList.remove('saved');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCheckin() {
|
||||
const date = document.getElementById('selectedDate').value;
|
||||
if (!date) return alert('날짜를 선택해주세요.');
|
||||
|
||||
// 이미 저장된 경우 확인
|
||||
if (isAlreadySaved) {
|
||||
if (!confirm('이미 저장된 데이터가 있습니다.\n수정하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 연차가 아닌 작업자들만 체크인 데이터로 전송
|
||||
const checkins = workers
|
||||
.filter(w => checkinStatus[w.worker_id]?.status !== 'vacation')
|
||||
.map(w => ({
|
||||
worker_id: w.worker_id,
|
||||
is_present: checkinStatus[w.worker_id]?.status === 'present'
|
||||
}));
|
||||
|
||||
try {
|
||||
const res = await axios.post('/attendance/checkins', { date, checkins });
|
||||
if (res.data.success) {
|
||||
alert(`${res.data.data.saved_count}명 출근 체크 저장 완료`);
|
||||
isAlreadySaved = true;
|
||||
updateSaveStatus();
|
||||
} else {
|
||||
alert('저장 실패: ' + (res.data.message || '알 수 없는 오류'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('저장 실패: ' + (e.response?.data?.message || e.message));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
392
deploy/tkfb-package/web-ui/pages/attendance/daily.html
Normal file
392
deploy/tkfb-package/web-ui/pages/attendance/daily.html
Normal file
@@ -0,0 +1,392 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>일일 출퇴근 입력 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="main-content">
|
||||
<div class="dashboard-main">
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">일일 출퇴근 입력</h1>
|
||||
<p class="page-description">오늘 출근한 작업자들의 출퇴근 기록을 입력합니다</p>
|
||||
</div>
|
||||
|
||||
<div class="page-actions">
|
||||
<input type="date" id="selectedDate" class="form-control" style="width: auto;">
|
||||
<button class="btn btn-primary" onclick="loadAttendanceRecords()">
|
||||
새로고침
|
||||
</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;">
|
||||
전체 저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script type="module">
|
||||
import '/js/api-config.js?v=3';
|
||||
</script>
|
||||
<script>
|
||||
// axios 기본 설정
|
||||
(function() {
|
||||
const checkApiConfig = setInterval(() => {
|
||||
if (window.API_BASE_URL) {
|
||||
clearInterval(checkApiConfig);
|
||||
axios.defaults.baseURL = window.API_BASE_URL;
|
||||
const token = localStorage.getItem('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?limit=100');
|
||||
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>
|
||||
1161
deploy/tkfb-package/web-ui/pages/attendance/monthly.html
Normal file
1161
deploy/tkfb-package/web-ui/pages/attendance/monthly.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,554 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>내 연차 정보 | 테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<style>
|
||||
.page-wrapper {
|
||||
padding: 1.5rem;
|
||||
max-width: 1000px;
|
||||
}
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.page-title { font-size: 1.5rem; font-weight: 700; margin: 0; }
|
||||
.page-desc { color: #6b7280; font-size: 0.875rem; margin-top: 0.25rem; }
|
||||
|
||||
/* 작업자 선택 (관리자용) */
|
||||
.admin-controls {
|
||||
display: none;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #fef3c7;
|
||||
border: 1px solid #f59e0b;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.admin-controls.visible { display: flex; }
|
||||
.admin-controls label { font-weight: 500; color: #92400e; }
|
||||
.admin-controls select {
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
/* 카드 그리드 */
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* 연차 카드 */
|
||||
.vacation-card {
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
.vacation-card h3 {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 1rem 0;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid #3b82f6;
|
||||
color: #1e40af;
|
||||
}
|
||||
.vacation-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
.vacation-item:last-child { border-bottom: none; }
|
||||
.vacation-item .label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #374151;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.vacation-item .dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.dot-carryover { background: #fbbf24; }
|
||||
.dot-annual { background: #3b82f6; }
|
||||
.dot-longservice { background: #a855f7; }
|
||||
.dot-special { background: #ec4899; }
|
||||
.vacation-item .days {
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.days.positive { color: #059669; }
|
||||
.days.zero { color: #9ca3af; }
|
||||
.days.negative { color: #dc2626; }
|
||||
|
||||
/* 총 합계 */
|
||||
.vacation-total {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 2px solid #e5e7eb;
|
||||
font-weight: 600;
|
||||
}
|
||||
.vacation-total .label { font-size: 0.9rem; color: #111827; }
|
||||
.vacation-total .days { font-size: 1.25rem; }
|
||||
|
||||
/* 연장근로 카드 */
|
||||
.overtime-card {
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
.overtime-card h3 {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 1rem 0;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid #f97316;
|
||||
color: #c2410c;
|
||||
}
|
||||
.overtime-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.overtime-controls select {
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.overtime-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.overtime-stat {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
background: #fff7ed;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.overtime-stat .value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #ea580c;
|
||||
}
|
||||
.overtime-stat .label {
|
||||
font-size: 0.75rem;
|
||||
color: #9a3412;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* 월별 상세 */
|
||||
.overtime-detail {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.overtime-day {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.4rem 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.overtime-day:last-child { border-bottom: none; }
|
||||
.overtime-day .date { color: #6b7280; }
|
||||
.overtime-day .hours { font-weight: 600; color: #ea580c; }
|
||||
|
||||
/* 로딩/에러 */
|
||||
.loading, .error, .no-data {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
.error { color: #dc2626; }
|
||||
|
||||
/* 안내 메시지 */
|
||||
.info-message {
|
||||
padding: 1rem;
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 0.5rem;
|
||||
color: #1e40af;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="has-sidebar">
|
||||
<div id="navbar-container"></div>
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<main class="main-content">
|
||||
<div class="page-wrapper">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">내 연차 정보</h1>
|
||||
<p class="page-desc" id="workerNameDisplay">연차 잔여 현황 및 월간 연장근로 시간을 확인합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 관리자용 작업자 선택 -->
|
||||
<div class="admin-controls" id="adminControls">
|
||||
<label>작업자 선택:</label>
|
||||
<select id="workerSelect" onchange="onWorkerChange()">
|
||||
<option value="">-- 선택 --</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 작업자 미연결 안내 -->
|
||||
<div class="info-message" id="noWorkerMessage" style="display:none;">
|
||||
현재 계정에 연결된 작업자 정보가 없습니다. 관리자에게 문의하세요.
|
||||
</div>
|
||||
|
||||
<!-- 정보 그리드 -->
|
||||
<div class="info-grid" id="infoGrid" style="display:none;">
|
||||
<!-- 연차 잔여 현황 -->
|
||||
<div class="vacation-card">
|
||||
<h3 id="vacationCardTitle">연차 잔여 현황</h3>
|
||||
<div id="vacationList">
|
||||
<div class="loading">로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 월간 연장근로 -->
|
||||
<div class="overtime-card">
|
||||
<h3>월간 연장근로 현황</h3>
|
||||
<div class="overtime-controls">
|
||||
<select id="yearSelect" onchange="loadOvertimeData()"></select>
|
||||
<select id="monthSelect" onchange="loadOvertimeData()"></select>
|
||||
</div>
|
||||
<div id="overtimeContent">
|
||||
<div class="loading">로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script>
|
||||
// axios 설정
|
||||
(function() {
|
||||
const check = setInterval(() => {
|
||||
if (window.API_BASE_URL) {
|
||||
clearInterval(check);
|
||||
axios.defaults.baseURL = window.API_BASE_URL;
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
}, 50);
|
||||
})();
|
||||
|
||||
// 전역 변수
|
||||
let currentUser = null;
|
||||
let currentWorkerId = null;
|
||||
let isAdmin = false;
|
||||
let workers = [];
|
||||
|
||||
// 초기화
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await waitForAxios();
|
||||
await initPage();
|
||||
});
|
||||
|
||||
function waitForAxios() {
|
||||
return new Promise(resolve => {
|
||||
const check = setInterval(() => {
|
||||
if (axios.defaults.baseURL) { clearInterval(check); resolve(); }
|
||||
}, 50);
|
||||
setTimeout(() => { clearInterval(check); resolve(); }, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
async function initPage() {
|
||||
// 현재 사용자 정보 가져오기
|
||||
const userStr = localStorage.getItem('user');
|
||||
if (userStr) {
|
||||
try {
|
||||
currentUser = JSON.parse(userStr);
|
||||
} catch (e) {
|
||||
console.error('사용자 정보 파싱 실패');
|
||||
}
|
||||
}
|
||||
|
||||
// 관리자 여부 확인
|
||||
const userRole = (currentUser?.role || currentUser?.access_level || '').toLowerCase();
|
||||
isAdmin = ['admin', 'system admin', 'system', 'system_admin'].includes(userRole);
|
||||
|
||||
// 연도/월 선택기 초기화
|
||||
initDateSelectors();
|
||||
|
||||
if (isAdmin) {
|
||||
// 관리자: 작업자 선택 UI 표시
|
||||
document.getElementById('adminControls').classList.add('visible');
|
||||
await loadWorkers();
|
||||
} else {
|
||||
// 일반 사용자: 본인 worker_id 사용
|
||||
if (currentUser?.worker_id) {
|
||||
currentWorkerId = currentUser.worker_id;
|
||||
document.getElementById('infoGrid').style.display = 'grid';
|
||||
await loadAllData();
|
||||
} else {
|
||||
// worker_id가 없는 경우
|
||||
document.getElementById('noWorkerMessage').style.display = 'block';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function initDateSelectors() {
|
||||
const now = new Date();
|
||||
const yearSelect = document.getElementById('yearSelect');
|
||||
const monthSelect = document.getElementById('monthSelect');
|
||||
|
||||
// 연도 (올해 ± 1년)
|
||||
for (let y = now.getFullYear() - 1; y <= now.getFullYear() + 1; y++) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = y;
|
||||
opt.textContent = `${y}년`;
|
||||
if (y === now.getFullYear()) opt.selected = true;
|
||||
yearSelect.appendChild(opt);
|
||||
}
|
||||
|
||||
// 월
|
||||
for (let m = 1; m <= 12; m++) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m;
|
||||
opt.textContent = `${m}월`;
|
||||
if (m === now.getMonth() + 1) opt.selected = true;
|
||||
monthSelect.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorkers() {
|
||||
try {
|
||||
const res = await axios.get('/workers?limit=100');
|
||||
workers = (res.data.data || [])
|
||||
.filter(w => w.status === 'active' && w.employment_status === 'employed')
|
||||
.sort((a, b) => a.worker_name.localeCompare(b.worker_name, 'ko'));
|
||||
|
||||
const select = document.getElementById('workerSelect');
|
||||
workers.forEach(w => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = w.worker_id;
|
||||
opt.textContent = w.worker_name;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('작업자 목록 로드 실패:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function onWorkerChange() {
|
||||
const workerId = document.getElementById('workerSelect').value;
|
||||
if (!workerId) {
|
||||
document.getElementById('infoGrid').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
currentWorkerId = parseInt(workerId);
|
||||
const worker = workers.find(w => w.worker_id === currentWorkerId);
|
||||
document.getElementById('workerNameDisplay').textContent =
|
||||
`${worker?.worker_name || ''}님의 연차 잔여 현황 및 월간 연장근로 시간`;
|
||||
document.getElementById('infoGrid').style.display = 'grid';
|
||||
await loadAllData();
|
||||
}
|
||||
|
||||
async function loadAllData() {
|
||||
await Promise.all([
|
||||
loadVacationData(),
|
||||
loadOvertimeData()
|
||||
]);
|
||||
}
|
||||
|
||||
// ===== 연차 잔여 현황 =====
|
||||
async function loadVacationData() {
|
||||
const container = document.getElementById('vacationList');
|
||||
container.innerHTML = '<div class="loading">로딩 중...</div>';
|
||||
|
||||
const year = new Date().getFullYear();
|
||||
document.getElementById('vacationCardTitle').textContent = `연차 잔여 현황 (${year}년)`;
|
||||
|
||||
try {
|
||||
const res = await axios.get(`/vacation-balances/worker/${currentWorkerId}/year/${year}`);
|
||||
const balances = res.data.data || [];
|
||||
|
||||
if (balances.length === 0) {
|
||||
container.innerHTML = '<div class="no-data">등록된 연차 정보가 없습니다</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// 유형별 정리
|
||||
const typeOrder = ['CARRYOVER', 'ANNUAL', 'LONG_SERVICE'];
|
||||
const typeNames = {
|
||||
'CARRYOVER': '이월',
|
||||
'ANNUAL': '정기연차',
|
||||
'LONG_SERVICE': '장기근속'
|
||||
};
|
||||
const dotClasses = {
|
||||
'CARRYOVER': 'dot-carryover',
|
||||
'ANNUAL': 'dot-annual',
|
||||
'LONG_SERVICE': 'dot-longservice'
|
||||
};
|
||||
|
||||
let totalDays = 0;
|
||||
let usedDays = 0;
|
||||
let html = '';
|
||||
|
||||
// 정렬된 순서로 표시
|
||||
const sortedBalances = balances.sort((a, b) => {
|
||||
const aIdx = typeOrder.indexOf(a.type_code);
|
||||
const bIdx = typeOrder.indexOf(b.type_code);
|
||||
if (aIdx === -1 && bIdx === -1) return 0;
|
||||
if (aIdx === -1) return 1;
|
||||
if (bIdx === -1) return -1;
|
||||
return aIdx - bIdx;
|
||||
});
|
||||
|
||||
sortedBalances.forEach(b => {
|
||||
const total = parseFloat(b.total_days) || 0;
|
||||
const used = parseFloat(b.used_days) || 0;
|
||||
const remaining = total - used;
|
||||
totalDays += total;
|
||||
usedDays += used;
|
||||
|
||||
const dotClass = dotClasses[b.type_code] || 'dot-special';
|
||||
const typeName = typeNames[b.type_code] || b.type_name || b.type_code;
|
||||
const remainingClass = remaining > 0 ? 'positive' : remaining < 0 ? 'negative' : 'zero';
|
||||
|
||||
html += `
|
||||
<div class="vacation-item">
|
||||
<span class="label">
|
||||
<span class="dot ${dotClass}"></span>
|
||||
${typeName}
|
||||
</span>
|
||||
<span class="days ${remainingClass}">
|
||||
${remaining.toFixed(1)}일
|
||||
<small style="color:#9ca3af;font-weight:normal;">(${total.toFixed(1)} - ${used.toFixed(1)})</small>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
// 총 합계
|
||||
const totalRemaining = totalDays - usedDays;
|
||||
const totalClass = totalRemaining > 0 ? 'positive' : totalRemaining < 0 ? 'negative' : 'zero';
|
||||
|
||||
html += `
|
||||
<div class="vacation-total">
|
||||
<span class="label">총 잔여</span>
|
||||
<span class="days ${totalClass}">${totalRemaining.toFixed(1)}일</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
} catch (e) {
|
||||
console.error('연차 데이터 로드 실패:', e);
|
||||
container.innerHTML = '<div class="error">데이터를 불러오는데 실패했습니다</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 월간 연장근로 =====
|
||||
async function loadOvertimeData() {
|
||||
const container = document.getElementById('overtimeContent');
|
||||
container.innerHTML = '<div class="loading">로딩 중...</div>';
|
||||
|
||||
const year = parseInt(document.getElementById('yearSelect').value);
|
||||
const month = parseInt(document.getElementById('monthSelect').value);
|
||||
|
||||
// 해당 월의 시작일/종료일
|
||||
const startDate = `${year}-${String(month).padStart(2, '0')}-01`;
|
||||
const lastDay = new Date(year, month, 0).getDate();
|
||||
const endDate = `${year}-${String(month).padStart(2, '0')}-${lastDay}`;
|
||||
|
||||
try {
|
||||
// 근태 기록에서 연장근로 데이터 조회
|
||||
const res = await axios.get(`/attendance/records?start_date=${startDate}&end_date=${endDate}&worker_id=${currentWorkerId}`);
|
||||
const records = res.data.data || [];
|
||||
|
||||
// 8시간 초과분 계산
|
||||
let totalOvertimeHours = 0;
|
||||
const overtimeDays = [];
|
||||
|
||||
records.forEach(r => {
|
||||
const hours = parseFloat(r.total_work_hours) || 0;
|
||||
if (hours > 8) {
|
||||
const overtime = hours - 8;
|
||||
totalOvertimeHours += overtime;
|
||||
overtimeDays.push({
|
||||
date: r.record_date,
|
||||
hours: overtime
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 총 근무일수
|
||||
const workDays = records.filter(r => (parseFloat(r.total_work_hours) || 0) > 0).length;
|
||||
|
||||
// 렌더링
|
||||
let html = `
|
||||
<div class="overtime-summary">
|
||||
<div class="overtime-stat">
|
||||
<div class="value">${totalOvertimeHours.toFixed(1)}h</div>
|
||||
<div class="label">총 연장근로</div>
|
||||
</div>
|
||||
<div class="overtime-stat">
|
||||
<div class="value">${overtimeDays.length}일</div>
|
||||
<div class="label">연장근로 일수</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (overtimeDays.length > 0) {
|
||||
html += '<div class="overtime-detail">';
|
||||
overtimeDays.sort((a, b) => a.date.localeCompare(b.date)).forEach(d => {
|
||||
const dateObj = new Date(d.date);
|
||||
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
const dayName = dayNames[dateObj.getDay()];
|
||||
const displayDate = `${dateObj.getMonth() + 1}/${dateObj.getDate()} (${dayName})`;
|
||||
|
||||
html += `
|
||||
<div class="overtime-day">
|
||||
<span class="date">${displayDate}</span>
|
||||
<span class="hours">+${d.hours.toFixed(1)}h</span>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
html += '</div>';
|
||||
} else {
|
||||
html += '<div class="no-data" style="padding:1rem;">연장근로 기록이 없습니다</div>';
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
} catch (e) {
|
||||
console.error('연장근로 데이터 로드 실패:', e);
|
||||
container.innerHTML = '<div class="error">데이터를 불러오는데 실패했습니다</div>';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<script type="module" src="/js/vacation-allocation.js" defer></script>
|
||||
</head>
|
||||
|
||||
<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">×</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">×</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>
|
||||
@@ -0,0 +1,264 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>휴가 승인 관리 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
.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">휴가 승인 관리</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()">
|
||||
조회
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="resetFilter()">
|
||||
전체
|
||||
</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">
|
||||
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/attendance/vacation-request.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>
|
||||
291
deploy/tkfb-package/web-ui/pages/attendance/vacation-input.html
Normal file
291
deploy/tkfb-package/web-ui/pages/attendance/vacation-input.html
Normal 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/admin-pages.css?v=7">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="main-content">
|
||||
<div class="dashboard-main">
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">휴가 직접 입력</h1>
|
||||
<p class="page-description">관리자 권한으로 작업자의 휴가 정보를 직접 입력합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 휴가 직접 입력 폼 -->
|
||||
<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;">
|
||||
즉시 입력 (자동 승인)
|
||||
</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()">
|
||||
새로고침
|
||||
</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">
|
||||
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/attendance/vacation-request.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>
|
||||
@@ -0,0 +1,458 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>휴가 관리 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
.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">휴가 관리</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;">
|
||||
즉시 입력 (자동 승인)
|
||||
</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()">
|
||||
새로고침
|
||||
</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()">
|
||||
조회
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="resetFilter()">
|
||||
전체
|
||||
</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">
|
||||
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/attendance/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>
|
||||
@@ -0,0 +1,269 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>휴가 신청 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="main-content">
|
||||
<div class="dashboard-main">
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">휴가 신청</h1>
|
||||
<p class="page-description">휴가를 신청하고 신청 내역을 확인합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 휴가 잔여 현황 -->
|
||||
<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;">
|
||||
신청하기
|
||||
</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">
|
||||
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>
|
||||
700
deploy/tkfb-package/web-ui/pages/attendance/work-status.html
Normal file
700
deploy/tkfb-package/web-ui/pages/attendance/work-status.html
Normal file
@@ -0,0 +1,700 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>근무 현황 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<style>
|
||||
.page-wrapper {
|
||||
padding: 1.5rem;
|
||||
max-width: 1400px;
|
||||
}
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.page-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.controls input[type="date"] {
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.btn {
|
||||
padding: 0.4rem 0.75rem;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.btn-primary { background: #3b82f6; color: white; }
|
||||
.btn-outline { background: white; border: 1px solid #d1d5db; }
|
||||
.btn-success { background: #10b981; color: white; }
|
||||
|
||||
/* 요약 */
|
||||
.summary-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 0;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: #6b7280;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
.summary-row span { display: flex; align-items: center; gap: 0.25rem; }
|
||||
.summary-row .dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
}
|
||||
.dot-normal { background: #10b981; }
|
||||
.dot-annual { background: #3b82f6; }
|
||||
.dot-half { background: #22c55e; }
|
||||
.dot-quarter { background: #eab308; }
|
||||
.dot-early { background: #ef4444; }
|
||||
.dot-overtime { background: #f97316; }
|
||||
|
||||
/* 테이블 */
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
.data-table th, .data-table td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
.data-table th {
|
||||
background: #f9fafb;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.data-table tr:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
.data-table tr.saved {
|
||||
background: #f0fdf4;
|
||||
}
|
||||
.data-table tr.leave {
|
||||
background: #fefce8; /* 연한 노랑 - 연차/반차/반반차 */
|
||||
}
|
||||
.data-table tr.absent {
|
||||
background: #fef2f2; /* 연한 빨강 - 결근 (연차정보 없음) */
|
||||
}
|
||||
.data-table tr.absent-no-leave {
|
||||
background: #fee2e2; /* 더 진한 빨강 - 출근체크 안하고 연차정보도 없음 */
|
||||
}
|
||||
.leave-tag {
|
||||
font-size: 0.65rem;
|
||||
color: #a16207;
|
||||
background: #fef3c7;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
.status-leave { color: #a16207; }
|
||||
.status-absent-warning { color: #dc2626; font-weight: 600; }
|
||||
.worker-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
.saved-tag {
|
||||
font-size: 0.65rem;
|
||||
color: #10b981;
|
||||
background: #dcfce7;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
.type-select {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
min-width: 100px;
|
||||
}
|
||||
.overtime-input {
|
||||
width: 50px;
|
||||
padding: 0.25rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.25rem;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.hours-cell {
|
||||
text-align: center;
|
||||
min-width: 60px;
|
||||
}
|
||||
.status-present { color: #10b981; }
|
||||
.status-absent { color: #ef4444; }
|
||||
.status-not-hired { color: #9ca3af; font-style: italic; }
|
||||
.data-table tr.not-hired {
|
||||
background: #f3f4f6;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.data-table tr.not-hired .type-select,
|
||||
.data-table tr.not-hired .overtime-input {
|
||||
display: none;
|
||||
}
|
||||
.not-hired-tag {
|
||||
font-size: 0.65rem;
|
||||
color: #6b7280;
|
||||
background: #e5e7eb;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* 저장 영역 */
|
||||
.save-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.save-status {
|
||||
font-size: 0.8rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
.save-status.saved { color: #10b981; }
|
||||
.save-status.unsaved { color: #f59e0b; }
|
||||
.btn-save {
|
||||
padding: 0.5rem 1.5rem;
|
||||
font-size: 0.875rem;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-save:hover { background: #2563eb; }
|
||||
.btn-save:disabled { background: #9ca3af; cursor: not-allowed; }
|
||||
|
||||
.warning-box {
|
||||
background: #fef3c7;
|
||||
border: 1px solid #fcd34d;
|
||||
color: #92400e;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.25rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.warning-box a { color: #92400e; font-weight: 500; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="has-sidebar">
|
||||
<div id="navbar-container"></div>
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<main class="main-content">
|
||||
<div class="page-wrapper">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">근무 현황</h1>
|
||||
<div class="controls">
|
||||
<input type="date" id="selectedDate">
|
||||
<button class="btn btn-primary" onclick="loadWorkStatus()">조회</button>
|
||||
<button class="btn btn-outline" onclick="setAllNormal()">전체 정시근무</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="noCheckinWarning" class="warning-box" style="display:none;">
|
||||
출근 체크가 완료되지 않았습니다. 먼저 <a href="/pages/attendance/checkin.html">출근 체크</a>를 진행해주세요.
|
||||
</div>
|
||||
|
||||
<div class="summary-row">
|
||||
<span><span class="dot dot-normal"></span> 정시 <strong id="normalCount">0</strong></span>
|
||||
<span><span class="dot dot-annual"></span> 연차 <strong id="annualCount">0</strong></span>
|
||||
<span><span class="dot dot-half"></span> 반차 <strong id="halfCount">0</strong></span>
|
||||
<span><span class="dot dot-quarter"></span> 반반차 <strong id="quarterCount">0</strong></span>
|
||||
<span><span class="dot dot-early"></span> 조퇴 <strong id="earlyCount">0</strong></span>
|
||||
<span><span class="dot dot-overtime"></span> 연장 <strong id="overtimeCount">0</strong></span>
|
||||
<span><span class="dot" style="background:#dc2626;"></span> 결근 <strong id="absentCount">0</strong></span>
|
||||
</div>
|
||||
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:30px">#</th>
|
||||
<th>이름</th>
|
||||
<th>출근</th>
|
||||
<th>근태구분</th>
|
||||
<th class="hours-cell">기본</th>
|
||||
<th class="hours-cell">연장</th>
|
||||
<th class="hours-cell">합계</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="workerTableBody">
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="save-bar">
|
||||
<span id="saveStatus" class="save-status"></span>
|
||||
<button id="saveBtn" class="btn-save" onclick="saveWorkStatus()">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script>
|
||||
(function() {
|
||||
const checkApiConfig = setInterval(() => {
|
||||
if (window.API_BASE_URL) {
|
||||
clearInterval(checkApiConfig);
|
||||
axios.defaults.baseURL = window.API_BASE_URL;
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
}, 50);
|
||||
})();
|
||||
|
||||
let workers = [];
|
||||
let workStatus = {};
|
||||
let hasCheckinData = false;
|
||||
let isAlreadySaved = false;
|
||||
let isSaving = false;
|
||||
|
||||
const attendanceTypes = [
|
||||
{ value: 'normal', label: '정시근무', hours: 8, isLeave: false },
|
||||
{ value: 'annual', label: '연차', hours: 0, isLeave: true },
|
||||
{ value: 'half', label: '반차', hours: 4, isLeave: true },
|
||||
{ value: 'quarter', label: '반반차', hours: 2, isLeave: true },
|
||||
{ value: 'early', label: '조퇴', hours: 0, isLeave: false },
|
||||
{ value: 'overtime', label: '연장근로', hours: 8, isLeave: false }
|
||||
];
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await waitForAxiosConfig();
|
||||
document.getElementById('selectedDate').value = new Date().toISOString().split('T')[0];
|
||||
loadWorkStatus();
|
||||
});
|
||||
|
||||
function waitForAxiosConfig() {
|
||||
return new Promise((resolve) => {
|
||||
const check = setInterval(() => {
|
||||
if (axios.defaults.baseURL) {
|
||||
clearInterval(check);
|
||||
resolve();
|
||||
}
|
||||
}, 50);
|
||||
setTimeout(() => { clearInterval(check); resolve(); }, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
function formatDisplayDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
const [year, month, day] = dateStr.split('-');
|
||||
return `${year}.${month}.${day}`;
|
||||
}
|
||||
|
||||
async function loadWorkStatus() {
|
||||
const selectedDate = document.getElementById('selectedDate').value;
|
||||
if (!selectedDate) return alert('날짜를 선택해주세요.');
|
||||
|
||||
try {
|
||||
const [workersRes, recordsRes] = await Promise.all([
|
||||
axios.get('/workers?limit=100'),
|
||||
axios.get(`/attendance/daily-records?date=${selectedDate}`).catch(() => ({ data: { data: [] } }))
|
||||
]);
|
||||
|
||||
workers = (workersRes.data.data || []).filter(w => w.status === 'active' && (!w.employment_status || w.employment_status === 'employed'));
|
||||
const records = recordsRes.data.data || [];
|
||||
|
||||
hasCheckinData = records.length > 0;
|
||||
isAlreadySaved = records.some(r => r.attendance_type_id || r.total_work_hours > 0);
|
||||
document.getElementById('noCheckinWarning').style.display = hasCheckinData ? 'none' : 'block';
|
||||
|
||||
const selectedDate = document.getElementById('selectedDate').value;
|
||||
|
||||
workStatus = {};
|
||||
workers.forEach(w => {
|
||||
const record = records.find(r => r.worker_id === w.worker_id);
|
||||
|
||||
// 입사일 이전인지 확인
|
||||
const joinDate = w.join_date ? w.join_date.split('T')[0] : null;
|
||||
const isBeforeJoin = joinDate && selectedDate < joinDate;
|
||||
|
||||
if (isBeforeJoin) {
|
||||
// 입사 전 날짜
|
||||
workStatus[w.worker_id] = {
|
||||
isPresent: false,
|
||||
type: 'not_hired',
|
||||
hours: 0,
|
||||
overtimeHours: 0,
|
||||
isSaved: false,
|
||||
hasLeaveInfo: false,
|
||||
isNotHired: true,
|
||||
joinDate: joinDate
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (record) {
|
||||
let type = 'normal';
|
||||
let overtimeHours = 0;
|
||||
|
||||
// 1. 휴가 유형 확인 (vacation_type_id 또는 vacation_type_code)
|
||||
if (record.vacation_type_id || record.vacation_type_code) {
|
||||
const vacationCodeMap = {
|
||||
'ANNUAL_FULL': 'annual',
|
||||
'ANNUAL_HALF': 'half',
|
||||
'ANNUAL_QUARTER': 'quarter',
|
||||
1: 'annual',
|
||||
2: 'half',
|
||||
3: 'quarter'
|
||||
};
|
||||
type = vacationCodeMap[record.vacation_type_code] || vacationCodeMap[record.vacation_type_id] || 'annual';
|
||||
}
|
||||
// 2. 근태 유형 확인
|
||||
// work_attendance_types: 1=NORMAL, 2=LATE, 3=EARLY_LEAVE, 4=ABSENT, 5=VACATION
|
||||
else if (record.attendance_type_code || record.attendance_type_id) {
|
||||
const codeMap = {
|
||||
'NORMAL': 'normal',
|
||||
'REGULAR': 'normal',
|
||||
'VACATION': 'annual',
|
||||
'EARLY_LEAVE': 'early',
|
||||
'ABSENT': 'normal', // 결근은 화면에서 따로 처리
|
||||
1: 'normal', // NORMAL
|
||||
2: 'normal', // LATE (지각도 출근으로 처리)
|
||||
3: 'early', // EARLY_LEAVE
|
||||
4: 'normal', // ABSENT (결근은 화면에서 따로 처리)
|
||||
5: 'annual' // VACATION
|
||||
};
|
||||
type = codeMap[record.attendance_type_code] || codeMap[record.attendance_type_id] || 'normal';
|
||||
}
|
||||
// 3. 출근 안 한 경우 (is_present가 0이고 휴가 정보 없으면 결근 상태로 표시)
|
||||
else if (record.is_present === 0) {
|
||||
type = 'normal'; // 기본값, 사용자가 수정해야 함
|
||||
}
|
||||
|
||||
// 연장근로 확인
|
||||
if (record.total_work_hours > 8 && type === 'normal') {
|
||||
type = 'overtime';
|
||||
overtimeHours = record.total_work_hours - 8;
|
||||
}
|
||||
|
||||
const typeInfo = attendanceTypes.find(t => t.value === type);
|
||||
|
||||
workStatus[w.worker_id] = {
|
||||
isPresent: record.is_present === 1 || typeInfo?.isLeave,
|
||||
type: type,
|
||||
hours: typeInfo !== undefined ? typeInfo.hours : 8,
|
||||
overtimeHours: overtimeHours,
|
||||
isSaved: record.attendance_type_id != null || record.total_work_hours > 0 || record.vacation_type_id != null,
|
||||
hasLeaveInfo: typeInfo?.isLeave || false
|
||||
};
|
||||
} else {
|
||||
// 출근 체크 기록이 없는 경우 - 결근 상태
|
||||
workStatus[w.worker_id] = {
|
||||
isPresent: false,
|
||||
type: 'normal',
|
||||
hours: 8,
|
||||
overtimeHours: 0,
|
||||
isSaved: false,
|
||||
hasLeaveInfo: false
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
render();
|
||||
updateSummary();
|
||||
updateSaveStatus();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('데이터 로드 실패');
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
const tbody = document.getElementById('workerTableBody');
|
||||
|
||||
if (workers.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:#6b7280;padding:2rem;">작업자가 없습니다</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = workers.map((w, idx) => {
|
||||
const s = workStatus[w.worker_id];
|
||||
|
||||
// 미입사 상태 처리
|
||||
if (s.isNotHired) {
|
||||
return `
|
||||
<tr class="not-hired">
|
||||
<td>${idx + 1}</td>
|
||||
<td>
|
||||
<span class="worker-name">${w.worker_name}</span>
|
||||
<span class="not-hired-tag">미입사</span>
|
||||
</td>
|
||||
<td class="status-not-hired">-</td>
|
||||
<td><span style="color:#9ca3af;font-size:0.8rem;">입사일: ${formatDisplayDate(s.joinDate)}</span></td>
|
||||
<td class="hours-cell">-</td>
|
||||
<td class="hours-cell">-</td>
|
||||
<td class="hours-cell">-</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
const typeInfo = attendanceTypes.find(t => t.value === s.type);
|
||||
const isLeaveType = typeInfo?.isLeave || false;
|
||||
const showOvertimeInput = s.type === 'overtime';
|
||||
const baseHours = s.hours;
|
||||
const totalHours = s.type === 'overtime' ? baseHours + s.overtimeHours : baseHours;
|
||||
|
||||
// 행 클래스 결정
|
||||
let rowClass = '';
|
||||
if (s.isSaved) {
|
||||
rowClass = isLeaveType ? 'leave' : 'saved';
|
||||
} else if (!s.isPresent) {
|
||||
// 출근 안 했는데 연차 정보도 없으면 경고
|
||||
rowClass = isLeaveType ? 'leave' : 'absent-no-leave';
|
||||
}
|
||||
|
||||
// 출근 상태 텍스트 및 클래스 결정
|
||||
let statusText = '';
|
||||
let statusClass = '';
|
||||
if (isLeaveType) {
|
||||
statusText = typeInfo.label;
|
||||
statusClass = 'status-leave';
|
||||
} else if (s.isPresent) {
|
||||
statusText = '출근';
|
||||
statusClass = 'status-present';
|
||||
} else {
|
||||
statusText = '⚠️ 결근';
|
||||
statusClass = 'status-absent-warning';
|
||||
}
|
||||
|
||||
// 태그 표시
|
||||
let tag = '';
|
||||
if (s.isSaved) {
|
||||
tag = '<span class="saved-tag">저장됨</span>';
|
||||
} else if (isLeaveType) {
|
||||
tag = '<span class="leave-tag">연차</span>';
|
||||
}
|
||||
|
||||
return `
|
||||
<tr class="${rowClass}">
|
||||
<td>${idx + 1}</td>
|
||||
<td>
|
||||
<span class="worker-name">${w.worker_name}</span>
|
||||
${tag}
|
||||
</td>
|
||||
<td class="${statusClass}">
|
||||
${statusText}
|
||||
</td>
|
||||
<td>
|
||||
<select class="type-select" onchange="updateType(${w.worker_id}, this.value)">
|
||||
${attendanceTypes.map(t => `
|
||||
<option value="${t.value}" ${s.type === t.value ? 'selected' : ''}>${t.label}</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</td>
|
||||
<td class="hours-cell">${baseHours}h</td>
|
||||
<td class="hours-cell">
|
||||
${showOvertimeInput ? `
|
||||
<input type="number" class="overtime-input" value="${s.overtimeHours}" min="0" max="8" step="0.5"
|
||||
onchange="updateOvertime(${w.worker_id}, this.value)">
|
||||
` : '-'}
|
||||
</td>
|
||||
<td class="hours-cell"><strong>${totalHours}h</strong></td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function updateType(workerId, value) {
|
||||
const typeInfo = attendanceTypes.find(t => t.value === value);
|
||||
workStatus[workerId].type = value;
|
||||
workStatus[workerId].hours = typeInfo !== undefined ? typeInfo.hours : 8;
|
||||
workStatus[workerId].hasLeaveInfo = typeInfo?.isLeave || false;
|
||||
|
||||
// 연차 유형 선택 시 출근 상태로 간주 (결근 아님)
|
||||
if (typeInfo?.isLeave) {
|
||||
workStatus[workerId].isPresent = true;
|
||||
}
|
||||
|
||||
if (value === 'overtime') {
|
||||
workStatus[workerId].overtimeHours = workStatus[workerId].overtimeHours || 2;
|
||||
} else {
|
||||
workStatus[workerId].overtimeHours = 0;
|
||||
}
|
||||
|
||||
render();
|
||||
updateSummary();
|
||||
}
|
||||
|
||||
function updateOvertime(workerId, value) {
|
||||
workStatus[workerId].overtimeHours = parseFloat(value) || 0;
|
||||
render();
|
||||
updateSummary();
|
||||
}
|
||||
|
||||
function setAllNormal() {
|
||||
workers.forEach(w => {
|
||||
workStatus[w.worker_id].type = 'normal';
|
||||
workStatus[w.worker_id].hours = 8;
|
||||
workStatus[w.worker_id].overtimeHours = 0;
|
||||
});
|
||||
render();
|
||||
updateSummary();
|
||||
}
|
||||
|
||||
function updateSummary() {
|
||||
let normal = 0, annual = 0, half = 0, quarter = 0, early = 0, overtime = 0, absent = 0, notHired = 0;
|
||||
|
||||
Object.values(workStatus).forEach(s => {
|
||||
// 미입사자 제외
|
||||
if (s.isNotHired) {
|
||||
notHired++;
|
||||
return;
|
||||
}
|
||||
|
||||
const typeInfo = attendanceTypes.find(t => t.value === s.type);
|
||||
const isLeaveType = typeInfo?.isLeave || false;
|
||||
|
||||
// 출근 안 했고 연차 정보도 없으면 결근
|
||||
if (!s.isPresent && !isLeaveType) {
|
||||
absent++;
|
||||
}
|
||||
|
||||
switch (s.type) {
|
||||
case 'normal': if (s.isPresent) normal++; break;
|
||||
case 'annual': annual++; break;
|
||||
case 'half': half++; break;
|
||||
case 'quarter': quarter++; break;
|
||||
case 'early': early++; break;
|
||||
case 'overtime': overtime++; break;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('normalCount').textContent = normal;
|
||||
document.getElementById('annualCount').textContent = annual;
|
||||
document.getElementById('halfCount').textContent = half;
|
||||
document.getElementById('quarterCount').textContent = quarter;
|
||||
document.getElementById('earlyCount').textContent = early;
|
||||
document.getElementById('overtimeCount').textContent = overtime;
|
||||
document.getElementById('absentCount').textContent = absent;
|
||||
}
|
||||
|
||||
function updateSaveStatus() {
|
||||
const statusEl = document.getElementById('saveStatus');
|
||||
const saveBtn = document.getElementById('saveBtn');
|
||||
|
||||
if (isAlreadySaved) {
|
||||
statusEl.innerHTML = '이 날짜의 근무 현황이 저장되어 있습니다';
|
||||
statusEl.className = 'save-status saved';
|
||||
saveBtn.textContent = '수정 저장';
|
||||
} else {
|
||||
statusEl.innerHTML = '아직 저장되지 않았습니다';
|
||||
statusEl.className = 'save-status unsaved';
|
||||
saveBtn.textContent = '저장';
|
||||
}
|
||||
}
|
||||
|
||||
async function saveWorkStatus() {
|
||||
const date = document.getElementById('selectedDate').value;
|
||||
if (!date) return alert('날짜를 선택해주세요.');
|
||||
|
||||
if (isSaving) return;
|
||||
|
||||
const saveBtn = document.getElementById('saveBtn');
|
||||
|
||||
// work_attendance_types: 1=NORMAL, 2=LATE, 3=EARLY_LEAVE, 4=ABSENT, 5=VACATION
|
||||
const typeIdMap = {
|
||||
'normal': 1, // NORMAL
|
||||
'annual': 5, // VACATION
|
||||
'half': 5, // VACATION
|
||||
'quarter': 5, // VACATION
|
||||
'early': 3, // EARLY_LEAVE
|
||||
'overtime': 1 // NORMAL (시간으로 구분)
|
||||
};
|
||||
|
||||
// vacation_types: 1=ANNUAL_FULL, 2=ANNUAL_HALF, 3=ANNUAL_QUARTER
|
||||
const vacationTypeIdMap = {
|
||||
'annual': 1,
|
||||
'half': 2,
|
||||
'quarter': 3,
|
||||
};
|
||||
|
||||
// 미입사자 제외하고 저장할 데이터 생성
|
||||
const recordsToSave = workers
|
||||
.filter(w => !workStatus[w.worker_id]?.isNotHired)
|
||||
.map(w => {
|
||||
const s = workStatus[w.worker_id];
|
||||
const totalHours = s.type === 'overtime' ? s.hours + s.overtimeHours : s.hours;
|
||||
|
||||
return {
|
||||
record_date: date,
|
||||
worker_id: w.worker_id,
|
||||
attendance_type_id: typeIdMap[s.type] || 1,
|
||||
vacation_type_id: vacationTypeIdMap[s.type] || null,
|
||||
total_work_hours: totalHours,
|
||||
overtime_approved: s.type === 'overtime',
|
||||
notes: s.type === 'overtime' ? `연장근로 ${s.overtimeHours}시간` : null
|
||||
};
|
||||
});
|
||||
|
||||
isSaving = true;
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = '저장 중...';
|
||||
|
||||
try {
|
||||
let ok = 0, fail = 0;
|
||||
for (const r of recordsToSave) {
|
||||
try {
|
||||
await axios.post('/attendance/records', r);
|
||||
ok++;
|
||||
} catch (e) {
|
||||
console.error('저장 실패:', e);
|
||||
fail++;
|
||||
}
|
||||
}
|
||||
|
||||
if (fail === 0) {
|
||||
alert(`${ok}명 저장 완료`);
|
||||
isAlreadySaved = true;
|
||||
workers.forEach(w => {
|
||||
if (workStatus[w.worker_id]) {
|
||||
workStatus[w.worker_id].isSaved = true;
|
||||
}
|
||||
});
|
||||
render();
|
||||
updateSaveStatus();
|
||||
} else if (ok > 0) {
|
||||
alert(`${ok}명 성공, ${fail}명 실패`);
|
||||
} else {
|
||||
alert('저장에 실패했습니다');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('저장 중 오류가 발생했습니다');
|
||||
} finally {
|
||||
isSaving = false;
|
||||
saveBtn.disabled = false;
|
||||
updateSaveStatus();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
514
deploy/tkfb-package/web-ui/pages/dashboard.html
Normal file
514
deploy/tkfb-package/web-ui/pages/dashboard.html
Normal file
@@ -0,0 +1,514 @@
|
||||
<!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="preconnect" href="http://localhost:20005" crossorigin>
|
||||
<link rel="preload" href="/css/design-system.css" as="style">
|
||||
<link rel="preload" href="/js/api-base.js" as="script">
|
||||
<link rel="preload" href="/js/app-init.js?v=2" as="script">
|
||||
|
||||
<!-- 모던 디자인 시스템 적용 -->
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/modern-dashboard.css?v=2">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
|
||||
<!-- 최적화된 로딩: API 설정 → 앱 초기화 (병렬 컴포넌트 로딩) -->
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></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>
|
||||
<!-- instant.page: 링크 호버 시 페이지 프리로딩 -->
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- 메인 컨테이너 -->
|
||||
<div class="dashboard-container">
|
||||
|
||||
<!-- 네비게이션 헤더 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="dashboard-main">
|
||||
|
||||
<!-- 작업장 현황 -->
|
||||
<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="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 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>
|
||||
</section>
|
||||
|
||||
<!-- 임시 이동된 설비 현황 -->
|
||||
<section class="moved-equipment-section" style="margin-top: 24px;">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="card-title">🚚 임시 이동된 설비</h2>
|
||||
<button class="btn btn-outline btn-sm" onclick="loadMovedEquipments()">새로고침</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="movedEquipmentList" class="moved-equipment-grid">
|
||||
<!-- 동적 로드 -->
|
||||
</div>
|
||||
<div id="noMovedEquipment" style="display: none; text-align: center; padding: 40px; color: var(--gray-500);">
|
||||
<div style="font-size: 48px; margin-bottom: 12px;">✅</div>
|
||||
<p>임시 이동된 설비가 없습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- 푸터 -->
|
||||
<footer class="dashboard-footer">
|
||||
<div class="footer-content">
|
||||
<p class="footer-text">
|
||||
© 2025 (주)테크니컬코리아. 모든 권리 보유.
|
||||
</p>
|
||||
<div class="footer-links">
|
||||
<a href="#" class="footer-link">도움말</a>
|
||||
<a href="#" class="footer-link">문의하기</a>
|
||||
<a href="#" class="footer-link">개인정보처리방침</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 알림 토스트 -->
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
|
||||
<!-- 작업장 상세 정보 모달 -->
|
||||
<div id="workplaceDetailModal" class="workplace-modal-overlay">
|
||||
<div class="workplace-modal-container">
|
||||
<!-- 모달 헤더 -->
|
||||
<div class="workplace-modal-header">
|
||||
<div class="workplace-modal-title-section">
|
||||
<h2 id="modalWorkplaceName" class="workplace-modal-title"></h2>
|
||||
<p id="modalWorkplaceDesc" class="workplace-modal-subtitle"></p>
|
||||
</div>
|
||||
<button class="workplace-modal-close" onclick="closeWorkplaceModal()">×</button>
|
||||
</div>
|
||||
|
||||
<!-- 모달 바디 -->
|
||||
<div class="workplace-modal-body">
|
||||
<!-- 탭 네비게이션 -->
|
||||
<div class="workplace-modal-tabs">
|
||||
<button class="workplace-tab active" data-tab="overview" onclick="switchWorkplaceTab('overview')">
|
||||
<span class="tab-icon">📊</span>
|
||||
<span class="tab-text">현황 개요</span>
|
||||
</button>
|
||||
<button class="workplace-tab" data-tab="workers" onclick="switchWorkplaceTab('workers')">
|
||||
<span class="tab-icon">👷</span>
|
||||
<span class="tab-text">작업자</span>
|
||||
<span id="workerCountBadge" class="tab-badge">0</span>
|
||||
</button>
|
||||
<button class="workplace-tab" data-tab="visitors" onclick="switchWorkplaceTab('visitors')">
|
||||
<span class="tab-icon">🚪</span>
|
||||
<span class="tab-text">방문자</span>
|
||||
<span id="visitorCountBadge" class="tab-badge">0</span>
|
||||
</button>
|
||||
<button class="workplace-tab" data-tab="detail-map" onclick="switchWorkplaceTab('detail-map')">
|
||||
<span class="tab-icon">🗺️</span>
|
||||
<span class="tab-text">상세 지도</span>
|
||||
</button>
|
||||
<button class="workplace-tab" data-tab="moved-eq" onclick="switchWorkplaceTab('moved-eq')">
|
||||
<span class="tab-icon">🚚</span>
|
||||
<span class="tab-text">이동 설비</span>
|
||||
<span id="movedEqCountBadge" class="tab-badge" style="display:none;">0</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 탭 콘텐츠 -->
|
||||
<div class="workplace-tab-contents">
|
||||
<!-- 현황 개요 탭 -->
|
||||
<div id="tab-overview" class="workplace-tab-content active">
|
||||
<!-- 요약 카드 -->
|
||||
<div class="workplace-summary-cards">
|
||||
<div class="summary-card workers">
|
||||
<div class="summary-icon">👷</div>
|
||||
<div class="summary-info">
|
||||
<span class="summary-value" id="summaryWorkerCount">0</span>
|
||||
<span class="summary-label">작업자</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-card visitors">
|
||||
<div class="summary-icon">🚪</div>
|
||||
<div class="summary-info">
|
||||
<span class="summary-value" id="summaryVisitorCount">0</span>
|
||||
<span class="summary-label">방문자</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-card tasks">
|
||||
<div class="summary-icon">📋</div>
|
||||
<div class="summary-info">
|
||||
<span class="summary-value" id="summaryTaskCount">0</span>
|
||||
<span class="summary-label">작업 수</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 현재 작업 목록 -->
|
||||
<div class="workplace-section">
|
||||
<h4 class="section-title">
|
||||
<span class="section-icon">🔧</span>
|
||||
진행 중인 작업
|
||||
</h4>
|
||||
<div id="currentTasksList" class="current-tasks-list">
|
||||
<p class="empty-message">진행 중인 작업이 없습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 설비 현황 (간략) -->
|
||||
<div class="workplace-section">
|
||||
<h4 class="section-title">
|
||||
<span class="section-icon">⚙️</span>
|
||||
설비 현황
|
||||
</h4>
|
||||
<div id="equipmentSummary" class="equipment-summary">
|
||||
<p class="empty-message">설비 정보를 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업자 탭 -->
|
||||
<div id="tab-workers" class="workplace-tab-content">
|
||||
<div id="internalWorkersList" class="workers-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- 방문자 탭 -->
|
||||
<div id="tab-visitors" class="workplace-tab-content">
|
||||
<div id="externalVisitorsList" class="visitors-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- 상세 지도 탭 -->
|
||||
<div id="tab-detail-map" class="workplace-tab-content">
|
||||
<div id="detailMapContainer" class="detail-map-container">
|
||||
<div class="detail-map-placeholder">
|
||||
<span class="placeholder-icon">🗺️</span>
|
||||
<p>상세 지도를 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="detailMapLegend" class="detail-map-legend"></div>
|
||||
</div>
|
||||
|
||||
<!-- 이동 설비 탭 -->
|
||||
<div id="tab-moved-eq" class="workplace-tab-content">
|
||||
<div class="moved-eq-tab-content">
|
||||
<!-- 이 작업장으로 이동해 온 설비 -->
|
||||
<div class="workplace-section">
|
||||
<h4 class="section-title">
|
||||
<span class="section-icon">📥</span>
|
||||
이 작업장으로 이동해 온 설비
|
||||
</h4>
|
||||
<div id="movedInEquipmentList" class="moved-eq-list">
|
||||
<p class="empty-message">없음</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 이 작업장에서 다른 곳으로 이동한 설비 -->
|
||||
<div class="workplace-section">
|
||||
<h4 class="section-title">
|
||||
<span class="section-icon">📤</span>
|
||||
다른 곳으로 이동한 설비
|
||||
</h4>
|
||||
<div id="movedOutEquipmentList" class="moved-eq-list">
|
||||
<p class="empty-message">없음</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 모달 푸터 -->
|
||||
<div class="workplace-modal-footer">
|
||||
<button class="btn btn-outline" onclick="openPatrolPage()">
|
||||
<span>🔍</span> 순회점검
|
||||
</button>
|
||||
<button class="btn btn-primary" onclick="closeWorkplaceModal()">닫기</button>
|
||||
</div>
|
||||
|
||||
<!-- 설비 상세 슬라이드 패널 -->
|
||||
<div id="equipmentSlidePanel" class="equipment-slide-panel">
|
||||
<div class="slide-panel-header">
|
||||
<button class="slide-panel-back" onclick="closeEquipmentPanel()">←</button>
|
||||
<div class="slide-panel-title-section">
|
||||
<h3 id="panelEquipmentTitle" class="slide-panel-title"></h3>
|
||||
<span id="panelEquipmentStatus" class="slide-panel-status"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="slide-panel-body">
|
||||
<!-- 기본 정보 -->
|
||||
<div class="panel-section">
|
||||
<div class="panel-info-grid" id="panelEquipmentInfo"></div>
|
||||
</div>
|
||||
|
||||
<!-- 사진 -->
|
||||
<div class="panel-section">
|
||||
<div class="panel-section-header">
|
||||
<h4>설비 사진</h4>
|
||||
<button class="btn-icon-sm" onclick="openPanelPhotoUpload()">+</button>
|
||||
</div>
|
||||
<div class="panel-photo-grid" id="panelPhotoGrid">
|
||||
<div class="panel-empty">등록된 사진이 없습니다</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 액션 버튼 -->
|
||||
<div class="panel-actions">
|
||||
<button class="panel-action-btn move" onclick="openPanelMoveModal()">
|
||||
<span>↔</span> 임시이동
|
||||
</button>
|
||||
<button class="panel-action-btn repair" onclick="openPanelRepairModal()">
|
||||
<span>🔧</span> 수리신청
|
||||
</button>
|
||||
<button class="panel-action-btn export" onclick="openPanelExportModal()">
|
||||
<span>🚚</span> 외부반출
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 수리 이력 -->
|
||||
<div class="panel-section">
|
||||
<h4 class="panel-section-title">수리 이력</h4>
|
||||
<div id="panelRepairHistory" class="panel-history-list">
|
||||
<div class="panel-empty">수리 이력이 없습니다</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 외부반출 이력 -->
|
||||
<div class="panel-section">
|
||||
<h4 class="panel-section-title">외부반출 이력</h4>
|
||||
<div id="panelExternalHistory" class="panel-history-list">
|
||||
<div class="panel-empty">외부반출 이력이 없습니다</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 설비 사진 업로드 모달 -->
|
||||
<div id="panelPhotoModal" class="mini-modal-overlay" style="display:none;">
|
||||
<div class="mini-modal">
|
||||
<div class="mini-modal-header">
|
||||
<h4>사진 추가</h4>
|
||||
<button onclick="closePanelPhotoModal()">×</button>
|
||||
</div>
|
||||
<div class="mini-modal-body">
|
||||
<input type="file" id="panelPhotoInput" accept="image/*" onchange="previewPanelPhoto(event)">
|
||||
<div id="panelPhotoPreview" class="mini-photo-preview"></div>
|
||||
<input type="text" id="panelPhotoDesc" class="form-control" placeholder="설명 (선택)">
|
||||
</div>
|
||||
<div class="mini-modal-footer">
|
||||
<button class="btn btn-secondary btn-sm" onclick="closePanelPhotoModal()">취소</button>
|
||||
<button class="btn btn-primary btn-sm" onclick="uploadPanelPhoto()">업로드</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 설비 임시이동 모달 -->
|
||||
<div id="panelMoveModal" class="mini-modal-overlay" style="display:none;">
|
||||
<div class="mini-modal" style="max-width:700px;">
|
||||
<div class="mini-modal-header">
|
||||
<h4 id="panelMoveTitle">설비 임시 이동</h4>
|
||||
<button onclick="closePanelMoveModal()">×</button>
|
||||
</div>
|
||||
<div class="mini-modal-body" style="padding:0;">
|
||||
<!-- Step 1: 공장 선택 (대분류 지도) -->
|
||||
<div id="moveStep1" class="move-step-content">
|
||||
<div class="move-step-header">
|
||||
<span class="step-badge">1</span>
|
||||
<span>공장 선택</span>
|
||||
</div>
|
||||
<div class="move-factory-grid" id="moveFactoryGrid">
|
||||
<!-- 공장 카드들 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: 작업장 선택 (공장 레이아웃 지도) -->
|
||||
<div id="moveStep2" class="move-step-content" style="display:none;">
|
||||
<div class="move-step-header">
|
||||
<button class="btn-step-back" onclick="moveBackToStep1()">←</button>
|
||||
<span class="step-badge">2</span>
|
||||
<span id="moveStep2Title">작업장 선택</span>
|
||||
</div>
|
||||
<p class="move-help-text">지도에서 이동할 작업장을 클릭하세요</p>
|
||||
<div class="move-layout-map" id="moveLayoutMapContainer"></div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: 위치 선택 (상세 지도) -->
|
||||
<div id="moveStep3" class="move-step-content" style="display:none;">
|
||||
<div class="move-step-header">
|
||||
<button class="btn-step-back" onclick="moveBackToStep2()">←</button>
|
||||
<span class="step-badge">3</span>
|
||||
<span id="moveStep3Title">위치 선택</span>
|
||||
</div>
|
||||
<p class="move-help-text">지도에서 설비를 배치할 위치를 클릭하세요</p>
|
||||
<div class="move-detail-map" id="moveDetailMapContainer"></div>
|
||||
<div class="form-group" style="padding:12px;">
|
||||
<input type="text" id="panelMoveReason" class="form-control" placeholder="이동 사유 (선택)">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mini-modal-footer">
|
||||
<button class="btn btn-secondary btn-sm" onclick="closePanelMoveModal()">취소</button>
|
||||
<button class="btn btn-primary btn-sm" id="panelMoveConfirmBtn" onclick="confirmPanelMove()" disabled>이동 확인</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 설비 수리신청 모달 -->
|
||||
<div id="panelRepairModal" class="mini-modal-overlay" style="display:none;">
|
||||
<div class="mini-modal">
|
||||
<div class="mini-modal-header">
|
||||
<h4>수리 신청</h4>
|
||||
<button onclick="closePanelRepairModal()">×</button>
|
||||
</div>
|
||||
<div class="mini-modal-body">
|
||||
<div class="form-group">
|
||||
<label>수리 유형</label>
|
||||
<select id="panelRepairItem" class="form-control" onchange="onRepairTypeChange()">
|
||||
<option value="">선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="newRepairTypeGroup" style="display:none;">
|
||||
<label>새 유형 이름</label>
|
||||
<input type="text" id="newRepairTypeName" class="form-control" placeholder="새로운 수리 유형 입력">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>상세 내용</label>
|
||||
<textarea id="panelRepairDesc" class="form-control" rows="3" placeholder="수리 필요 내용"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>사진 첨부</label>
|
||||
<input type="file" id="panelRepairPhotoInput" accept="image/*" multiple>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mini-modal-footer">
|
||||
<button class="btn btn-secondary btn-sm" onclick="closePanelRepairModal()">취소</button>
|
||||
<button class="btn btn-primary btn-sm" onclick="submitPanelRepair()">신청</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 설비 외부반출 모달 -->
|
||||
<div id="panelExportModal" class="mini-modal-overlay" style="display:none;">
|
||||
<div class="mini-modal">
|
||||
<div class="mini-modal-header">
|
||||
<h4>외부 반출</h4>
|
||||
<button onclick="closePanelExportModal()">×</button>
|
||||
</div>
|
||||
<div class="mini-modal-body">
|
||||
<div class="form-group">
|
||||
<label class="checkbox-inline">
|
||||
<input type="checkbox" id="panelIsRepairExport"> 수리 외주
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>반출일</label>
|
||||
<input type="date" id="panelExportDate" class="form-control">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>반입 예정일</label>
|
||||
<input type="date" id="panelExpectedReturn" class="form-control">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>반출처</label>
|
||||
<input type="text" id="panelExportDest" class="form-control" placeholder="업체명">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>반출 사유</label>
|
||||
<textarea id="panelExportReason" class="form-control" rows="2"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mini-modal-footer">
|
||||
<button class="btn btn-secondary btn-sm" onclick="closePanelExportModal()">취소</button>
|
||||
<button class="btn btn-primary btn-sm" onclick="submitPanelExport()">반출</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 설비 반입 모달 -->
|
||||
<div id="panelReturnModal" class="mini-modal-overlay" style="display:none;">
|
||||
<div class="mini-modal" style="max-width:350px;">
|
||||
<div class="mini-modal-header">
|
||||
<h4>설비 반입</h4>
|
||||
<button onclick="closePanelReturnModal()">×</button>
|
||||
</div>
|
||||
<div class="mini-modal-body">
|
||||
<input type="hidden" id="panelReturnLogId">
|
||||
<div class="form-group">
|
||||
<label>반입일</label>
|
||||
<input type="date" id="panelReturnDate" class="form-control">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>반입 후 상태</label>
|
||||
<select id="panelReturnStatus" class="form-control">
|
||||
<option value="active">정상 가동</option>
|
||||
<option value="maintenance">점검 필요</option>
|
||||
<option value="repair_needed">추가 수리 필요</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mini-modal-footer">
|
||||
<button class="btn btn-secondary btn-sm" onclick="closePanelReturnModal()">취소</button>
|
||||
<button class="btn btn-primary btn-sm" onclick="submitPanelReturn()">반입</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
202
deploy/tkfb-package/web-ui/pages/inspection/daily-patrol.html
Normal file
202
deploy/tkfb-package/web-ui/pages/inspection/daily-patrol.html
Normal file
@@ -0,0 +1,202 @@
|
||||
<!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=8">
|
||||
<link rel="stylesheet" href="/css/daily-patrol.css?v=4">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 레이아웃 -->
|
||||
<div class="page-container">
|
||||
<main class="main-content">
|
||||
<div class="dashboard-main">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">일일순회점검</h1>
|
||||
<p class="page-description">작업장을 순회하며 안전 및 정리정돈 상태를 점검합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 점검 시작 영역 -->
|
||||
<div class="patrol-start-section">
|
||||
<!-- 오늘 점검 현황 요약 -->
|
||||
<div id="todayStatusSummary" class="today-status-summary">
|
||||
<!-- JS에서 렌더링 -->
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary btn-lg" id="startPatrolBtn" onclick="showFactorySelection()">
|
||||
<span class="btn-icon">▶</span> 순회점검 시작
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 공장 선택 영역 (점검 시작 후 표시) -->
|
||||
<div id="factorySelectionArea" class="factory-selection-area" style="display: none;">
|
||||
<div class="factory-selection-header">
|
||||
<h3>공장을 선택하세요</h3>
|
||||
<p class="factory-selection-subtitle" id="patrolSessionInfo"><!-- JS에서 렌더링 --></p>
|
||||
</div>
|
||||
<div id="factoryCardsContainer" class="factory-cards-container">
|
||||
<!-- JS에서 공장 카드 렌더링 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 점검 진행 영역 (세션 시작 후 표시) -->
|
||||
<div id="patrolArea" class="patrol-area" style="display: none;">
|
||||
<!-- 세션 정보 -->
|
||||
<div id="sessionInfo" class="session-info-bar">
|
||||
<!-- JS에서 렌더링 -->
|
||||
</div>
|
||||
|
||||
<!-- 지도 및 체크리스트 영역 -->
|
||||
<div class="patrol-content">
|
||||
<!-- 작업장 지도 (좌측) -->
|
||||
<div class="patrol-map-section">
|
||||
<div class="map-header">
|
||||
<h3>작업장 지도</h3>
|
||||
<div class="map-legend">
|
||||
<span class="legend-item completed"><span class="dot"></span> 점검완료</span>
|
||||
<span class="legend-item in-progress"><span class="dot"></span> 점검중</span>
|
||||
<span class="legend-item pending"><span class="dot"></span> 미점검</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="patrolMapContainer" class="patrol-map-container">
|
||||
<!-- 지도 이미지 및 작업장 마커 -->
|
||||
</div>
|
||||
<!-- 작업장 목록 (지도 대신 사용 가능) -->
|
||||
<div id="workplaceListContainer" class="workplace-list-container">
|
||||
<!-- 작업장 목록 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 체크리스트 영역 (우측) -->
|
||||
<div class="patrol-checklist-section">
|
||||
<div id="checklistHeader" class="checklist-header">
|
||||
<h3>체크리스트</h3>
|
||||
<p class="checklist-subtitle">작업장을 선택하면 체크리스트가 표시됩니다</p>
|
||||
</div>
|
||||
<div id="checklistContent" class="checklist-content">
|
||||
<!-- 체크리스트 항목들 -->
|
||||
<div class="checklist-placeholder">
|
||||
<p>좌측 지도에서 점검할 작업장을 선택해주세요</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="checklistActions" class="checklist-actions" style="display: none;">
|
||||
<button type="button" class="btn btn-secondary" onclick="saveChecklistDraft()">임시저장</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveChecklist()">저장 후 다음</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 물품 현황 영역 -->
|
||||
<div id="itemsSection" class="items-section" style="display: none;">
|
||||
<div class="items-header">
|
||||
<h3><span id="selectedWorkplaceName">작업장</span> 물품 현황</h3>
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="toggleItemEditMode()">
|
||||
<span id="itemEditModeText">편집모드</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="itemsMapContainer" class="items-map-container">
|
||||
<!-- 작업장 상세 지도 및 물품 마커 -->
|
||||
</div>
|
||||
<div id="itemsLegend" class="items-legend">
|
||||
<!-- 물품 유형 범례 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 순회점검 완료 버튼 -->
|
||||
<div class="patrol-complete-section">
|
||||
<div class="form-group">
|
||||
<label for="patrolNotes">특이사항</label>
|
||||
<textarea id="patrolNotes" class="form-control" rows="2" placeholder="순회 중 발견한 특이사항을 기록하세요..."></textarea>
|
||||
</div>
|
||||
<button type="button" class="btn btn-success btn-lg" onclick="completePatrol()">
|
||||
순회점검 완료
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 물품 추가/수정 모달 -->
|
||||
<div id="itemModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 500px;">
|
||||
<div class="modal-header">
|
||||
<h2 id="itemModalTitle">물품 추가</h2>
|
||||
<button class="btn-close" onclick="closeItemModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="itemForm">
|
||||
<input type="hidden" id="itemId">
|
||||
<div class="form-group">
|
||||
<label for="itemType">물품 유형 *</label>
|
||||
<select id="itemType" class="form-control" required>
|
||||
<!-- JS에서 옵션 추가 -->
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="itemName">물품명/설명</label>
|
||||
<input type="text" id="itemName" class="form-control" placeholder="예: A사 용기 10개">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="itemQuantity">수량</label>
|
||||
<input type="number" id="itemQuantity" class="form-control" value="1" min="1">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeItemModal()">취소</button>
|
||||
<button type="button" class="btn btn-danger" id="deleteItemBtn" onclick="deleteItem()" style="display: none;">삭제</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveItem()">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script type="module">
|
||||
import '/js/api-config.js?v=3';
|
||||
</script>
|
||||
<script>
|
||||
(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 src="/js/daily-patrol.js?v=6"></script>
|
||||
</body>
|
||||
</html>
|
||||
297
deploy/tkfb-package/web-ui/pages/inspection/zone-detail.html
Normal file
297
deploy/tkfb-package/web-ui/pages/inspection/zone-detail.html
Normal file
@@ -0,0 +1,297 @@
|
||||
<!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=8">
|
||||
<link rel="stylesheet" href="/css/zone-detail.css?v=3">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 레이아웃 -->
|
||||
<div class="page-container">
|
||||
<main class="main-content">
|
||||
<div class="dashboard-main">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="zone-header">
|
||||
<div class="zone-header-left">
|
||||
<button class="btn btn-back" onclick="goBack()">
|
||||
<span>←</span> 돌아가기
|
||||
</button>
|
||||
</div>
|
||||
<div class="zone-header-center">
|
||||
<h1 id="zoneName" class="zone-title">작업장</h1>
|
||||
<p id="zoneCategory" class="zone-subtitle">공장</p>
|
||||
</div>
|
||||
<div class="zone-header-right">
|
||||
<span id="currentDate" class="current-date"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 요약 카드 -->
|
||||
<div id="summaryCards" class="summary-cards">
|
||||
<!-- JS에서 렌더링 -->
|
||||
</div>
|
||||
|
||||
<!-- 탭 네비게이션 -->
|
||||
<div class="tab-navigation">
|
||||
<button class="tab-btn active" data-tab="map" onclick="switchTab('map')">
|
||||
🗺️ 구역 현황
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="issues" onclick="switchTab('issues')">
|
||||
🚨 안전신고/부적합
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="equipment" onclick="switchTab('equipment')">
|
||||
⚙️ 설비/수리
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="visits" onclick="switchTab('visits')">
|
||||
🚶 출입현황
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="tbm" onclick="switchTab('tbm')">
|
||||
📋 TBM
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="patrol" onclick="switchTab('patrol')">
|
||||
🔍 순회점검
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 탭 콘텐츠 -->
|
||||
<div class="tab-contents">
|
||||
<!-- 구역 현황 탭 -->
|
||||
<div id="tab-map" class="tab-content active">
|
||||
<div class="map-editor-section">
|
||||
<div class="map-editor-header">
|
||||
<h3>구역 현황</h3>
|
||||
<div class="map-editor-actions">
|
||||
<button class="btn btn-primary btn-sm" id="addItemBtn" onclick="startAddItem()">
|
||||
➕ 현황 등록
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="map-editor-container">
|
||||
<div id="zoneMapContainer" class="zone-map-container">
|
||||
<div class="map-placeholder">지도를 로딩 중...</div>
|
||||
</div>
|
||||
<div class="map-legend">
|
||||
<div class="legend-title">주의 수준</div>
|
||||
<div class="legend-items">
|
||||
<div class="legend-item"><span class="legend-color" style="background: #10b981;"></span> 양호</div>
|
||||
<div class="legend-item"><span class="legend-color" style="background: #f59e0b;"></span> 주의</div>
|
||||
<div class="legend-item"><span class="legend-color" style="background: #ef4444;"></span> 관리필요</div>
|
||||
</div>
|
||||
<div class="legend-title" style="margin-top: 1rem;">설비 상태</div>
|
||||
<div class="legend-items">
|
||||
<div class="legend-item"><span style="margin-right: 4px;">⚙️</span> 정상 가동</div>
|
||||
<div class="legend-item"><span style="margin-right: 4px;">🔧</span> 수리 필요</div>
|
||||
<div class="legend-item"><span style="margin-right: 4px;">⚠️</span> 점검중</div>
|
||||
<div class="legend-item"><span style="margin-right: 4px;">📤</span> 타 작업장 이동</div>
|
||||
<div class="legend-item"><span style="margin-right: 4px;">📥</span> 임시 배치</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="zoneItemsList" class="zone-items-list">
|
||||
<!-- JS에서 렌더링 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 안전신고/부적합 탭 -->
|
||||
<div id="tab-issues" class="tab-content">
|
||||
<div id="issuesContent" class="content-loading">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 설비/수리 탭 -->
|
||||
<div id="tab-equipment" class="tab-content">
|
||||
<div id="equipmentContent" class="content-loading">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 출입현황 탭 -->
|
||||
<div id="tab-visits" class="tab-content">
|
||||
<div id="visitsContent" class="content-loading">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TBM 탭 -->
|
||||
<div id="tab-tbm" class="tab-content">
|
||||
<div id="tbmContent" class="content-loading">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 순회점검 탭 -->
|
||||
<div id="tab-patrol" class="tab-content">
|
||||
<div id="patrolContent" class="content-loading">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 현황 등록/수정 모달 -->
|
||||
<div id="zoneItemModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 520px;">
|
||||
<div class="modal-header">
|
||||
<h2 id="zoneItemModalTitle">현황 등록</h2>
|
||||
<button class="btn-close" onclick="closeZoneItemModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="zoneItemForm">
|
||||
<input type="hidden" id="zoneItemId">
|
||||
<input type="hidden" id="zoneItemX">
|
||||
<input type="hidden" id="zoneItemY">
|
||||
<input type="hidden" id="zoneItemWidth">
|
||||
<input type="hidden" id="zoneItemHeight">
|
||||
|
||||
<!-- 프로젝트 여부 -->
|
||||
<div class="form-group">
|
||||
<label>프로젝트 여부 *</label>
|
||||
<div class="radio-group">
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="zoneItemProjectType" value="project" onchange="onProjectTypeChange(this.value)">
|
||||
<span class="radio-text">프로젝트</span>
|
||||
</label>
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="zoneItemProjectType" value="non_project" onchange="onProjectTypeChange(this.value)" checked>
|
||||
<span class="radio-text">프로젝트 아님</span>
|
||||
</label>
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="zoneItemProjectType" value="unknown" onchange="onProjectTypeChange(this.value)">
|
||||
<span class="radio-text">판단 못함</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 프로젝트 선택 (프로젝트일 경우만 표시) -->
|
||||
<div class="form-group" id="projectSelectGroup" style="display: none;">
|
||||
<label for="zoneItemProject">프로젝트 선택</label>
|
||||
<select id="zoneItemProject" class="form-control">
|
||||
<option value="">프로젝트를 선택하세요</option>
|
||||
<!-- JS에서 동적 로드 -->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 명칭 -->
|
||||
<div class="form-group">
|
||||
<label for="zoneItemName">명칭 *</label>
|
||||
<input type="text" id="zoneItemName" class="form-control" placeholder="예: A사 제품, 작업 자재, 이동 설비" required>
|
||||
</div>
|
||||
|
||||
<!-- 상태/유형 + 주의수준 -->
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="flex: 1.5;">
|
||||
<label for="zoneItemType">상태/유형</label>
|
||||
<div class="select-with-add">
|
||||
<select id="zoneItemType" class="form-control">
|
||||
<option value="working">작업중</option>
|
||||
<option value="temp_storage">임시적치</option>
|
||||
<option value="moved_equipment">이동설비</option>
|
||||
<option value="unreported">미신고품</option>
|
||||
</select>
|
||||
<button type="button" class="btn-add-option" onclick="addCustomType()" title="유형 추가">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="flex: 1;">
|
||||
<label for="zoneItemWarning">주의 수준</label>
|
||||
<select id="zoneItemWarning" class="form-control">
|
||||
<option value="good">양호</option>
|
||||
<option value="caution">주의</option>
|
||||
<option value="needs_management">관리필요</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 상세 설명 -->
|
||||
<div class="form-group">
|
||||
<label for="zoneItemDesc">상세 설명</label>
|
||||
<textarea id="zoneItemDesc" class="form-control" rows="2" placeholder="현황에 대한 상세 설명, 주의사항, 담당자 등"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- 사진 등록 -->
|
||||
<div class="form-group">
|
||||
<label>사진</label>
|
||||
<div class="photo-upload-area">
|
||||
<input type="file" id="zoneItemPhoto" accept="image/*" multiple onchange="onPhotoSelected(event)" style="display: none;">
|
||||
<div id="photoPreviewList" class="photo-preview-list">
|
||||
<!-- 미리보기 이미지들 -->
|
||||
</div>
|
||||
<button type="button" class="btn-add-photo" onclick="document.getElementById('zoneItemPhoto').click()">
|
||||
<span class="photo-icon">📷</span>
|
||||
<span>사진 추가</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 표시 색상 -->
|
||||
<div class="form-group">
|
||||
<label>표시 색상</label>
|
||||
<div class="color-picker-row">
|
||||
<input type="color" id="zoneItemColor" class="form-control color-input" value="#3b82f6">
|
||||
<div class="color-presets">
|
||||
<button type="button" class="color-preset" style="background: #10b981;" onclick="setItemColor('#10b981')" title="양호"></button>
|
||||
<button type="button" class="color-preset" style="background: #f59e0b;" onclick="setItemColor('#f59e0b')" title="주의"></button>
|
||||
<button type="button" class="color-preset" style="background: #ef4444;" onclick="setItemColor('#ef4444')" title="관리필요"></button>
|
||||
<button type="button" class="color-preset" style="background: #3b82f6;" onclick="setItemColor('#3b82f6')" title="기본"></button>
|
||||
<button type="button" class="color-preset" style="background: #8b5cf6;" onclick="setItemColor('#8b5cf6')" title="기타"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeZoneItemModal()">취소</button>
|
||||
<button type="button" class="btn btn-danger" id="deleteZoneItemBtn" onclick="deleteZoneItem()" style="display: none;">삭제</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveZoneItem()">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script type="module">
|
||||
import '/js/api-config.js?v=3';
|
||||
</script>
|
||||
<script>
|
||||
(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.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
if (error.response?.status === 401) {
|
||||
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
|
||||
window.location.href = '/pages/login.html';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
}, 50);
|
||||
})();
|
||||
</script>
|
||||
<script src="/js/zone-detail.js?v=6"></script>
|
||||
</body>
|
||||
</html>
|
||||
303
deploy/tkfb-package/web-ui/pages/profile/info.html
Normal file
303
deploy/tkfb-package/web-ui/pages/profile/info.html
Normal file
@@ -0,0 +1,303 @@
|
||||
<!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/main-layout.css">
|
||||
|
||||
<style>
|
||||
.profile-page {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 3rem;
|
||||
color: white;
|
||||
margin: 0 auto 20px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.profile-name {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.profile-role {
|
||||
font-size: 1.2rem;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.profile-cards {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 1.05rem;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #1976d2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #1565c0;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f5f5f5;
|
||||
color: #333;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: #ff9800;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background: #f57c00;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* 통계 카드 */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.stat-box {
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #1976d2;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.profile-page {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.profile-name {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-layout-no-sidebar">
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="profile-page">
|
||||
<div class="profile-header">
|
||||
<div class="profile-avatar" id="profileAvatar"></div>
|
||||
<h1 class="profile-name" id="profileName">사용자</h1>
|
||||
<p class="profile-role" id="profileRole">역할</p>
|
||||
</div>
|
||||
|
||||
<div class="profile-cards">
|
||||
<!-- 기본 정보 -->
|
||||
<div class="profile-card">
|
||||
<h2 class="card-title">기본 정보</h2>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="info-label">사용자 ID</span>
|
||||
<span class="info-value" id="userId">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">사용자명</span>
|
||||
<span class="info-value" id="username">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">이름</span>
|
||||
<span class="info-value" id="fullName">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">권한 레벨</span>
|
||||
<span class="info-value" id="accessLevel">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">작업자 ID</span>
|
||||
<span class="info-value" id="workerId">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">가입일</span>
|
||||
<span class="info-value" id="createdAt">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 활동 정보 -->
|
||||
<div class="profile-card">
|
||||
<h2 class="card-title">활동 정보</h2>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="info-label">마지막 로그인</span>
|
||||
<span class="info-value" id="lastLogin">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">이메일</span>
|
||||
<span class="info-value" id="email">-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 간단한 통계 (준비중) -->
|
||||
<div class="stats-grid" style="margin-top: 24px; opacity: 0.5;">
|
||||
<div class="stat-box">
|
||||
<span class="stat-number">-</span>
|
||||
<span class="stat-label">작업 보고서</span>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<span class="stat-number">-</span>
|
||||
<span class="stat-label">이번 달 활동</span>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<span class="stat-number">-</span>
|
||||
<span class="stat-label">팀 기여도</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 빠른 작업 -->
|
||||
<div class="profile-card">
|
||||
<h2 class="card-title">빠른 작업</h2>
|
||||
<div class="action-buttons">
|
||||
<a href="/pages/profile/password.html" class="action-btn btn-warning">
|
||||
비밀번호 변경
|
||||
</a>
|
||||
<button class="action-btn btn-secondary" disabled>
|
||||
프로필 수정 (준비중)
|
||||
</button>
|
||||
<button class="action-btn btn-secondary" disabled>
|
||||
설정 (준비중)
|
||||
</button>
|
||||
<a href="javascript:history.back()" class="action-btn btn-secondary">
|
||||
<span>←</span>
|
||||
<span>돌아가기</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/my-profile.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
373
deploy/tkfb-package/web-ui/pages/profile/password.html
Normal file
373
deploy/tkfb-package/web-ui/pages/profile/password.html
Normal file
@@ -0,0 +1,373 @@
|
||||
<!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/main-layout.css">
|
||||
|
||||
<style>
|
||||
/* 페이지 전용 스타일 */
|
||||
.password-page {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.page-title h1 {
|
||||
font-size: 2rem;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.page-title p {
|
||||
color: #666;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* 카드 스타일 */
|
||||
.password-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background: linear-gradient(135deg, #ff9800 0%, #f57c00 100%);
|
||||
color: white;
|
||||
padding: 24px 32px;
|
||||
}
|
||||
|
||||
.card-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
/* 알림 박스 */
|
||||
.info-box {
|
||||
background: #e3f2fd;
|
||||
border: 1px solid #90caf9;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.info-box h4 {
|
||||
margin: 0 0 12px 0;
|
||||
color: #1565c0;
|
||||
font-size: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.info-box ul {
|
||||
margin: 0;
|
||||
padding-left: 24px;
|
||||
color: #0d47a1;
|
||||
}
|
||||
|
||||
.info-box li {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* 폼 스타일 */
|
||||
.password-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-size: 0.95rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 14px 48px 14px 16px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: #ff9800;
|
||||
box-shadow: 0 0 0 3px rgba(255, 152, 0, 0.1);
|
||||
}
|
||||
|
||||
.password-toggle {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
font-size: 1.2rem;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.password-toggle:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 버튼 */
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 14px 28px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #ff9800;
|
||||
color: white;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #f57c00;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(255, 152, 0, 0.3);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f5f5f5;
|
||||
color: #666;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
/* 메시지 */
|
||||
.message-box {
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
animation: slideDown 0.3s ease-out;
|
||||
}
|
||||
|
||||
.message-box.error {
|
||||
background: #ffebee;
|
||||
border: 1px solid #ffcdd2;
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.message-box.success {
|
||||
background: #e8f5e9;
|
||||
border: 1px solid #c8e6c9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 하단 링크 */
|
||||
.back-link {
|
||||
text-align: center;
|
||||
margin-top: 32px;
|
||||
padding-top: 32px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.back-link a {
|
||||
color: #1976d2;
|
||||
text-decoration: none;
|
||||
font-size: 0.95rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.back-link a:hover {
|
||||
color: #1565c0;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 768px) {
|
||||
.password-page {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-layout-no-sidebar">
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="password-page">
|
||||
<div class="page-title">
|
||||
<h1>비밀번호 변경</h1>
|
||||
<p>계정 보안을 위해 정기적으로 비밀번호를 변경해주세요</p>
|
||||
</div>
|
||||
|
||||
<div class="password-card">
|
||||
<div class="card-header">
|
||||
<h2>새 비밀번호 설정</h2>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<!-- 메시지 영역 -->
|
||||
<div id="message-area"></div>
|
||||
|
||||
<!-- 안내 정보 -->
|
||||
<div class="info-box">
|
||||
<h4>비밀번호 요구사항</h4>
|
||||
<ul>
|
||||
<li>최소 6자 이상 입력해주세요</li>
|
||||
<li>영문 대/소문자, 숫자, 특수문자를 조합하면 더 안전합니다</li>
|
||||
<li>개인정보나 쉬운 단어는 피해주세요</li>
|
||||
<li>이전 비밀번호와 다르게 설정해주세요</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 비밀번호 변경 폼 -->
|
||||
<form id="changePasswordForm" class="password-form">
|
||||
<div class="form-group">
|
||||
<label for="currentPassword">현재 비밀번호</label>
|
||||
<div class="input-wrapper">
|
||||
<input
|
||||
type="password"
|
||||
id="currentPassword"
|
||||
class="form-control"
|
||||
placeholder="현재 비밀번호를 입력하세요"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
<button type="button" class="password-toggle" data-target="currentPassword">보기</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="newPassword">새 비밀번호</label>
|
||||
<div class="input-wrapper">
|
||||
<input
|
||||
type="password"
|
||||
id="newPassword"
|
||||
class="form-control"
|
||||
placeholder="새 비밀번호를 입력하세요"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<button type="button" class="password-toggle" data-target="newPassword">보기</button>
|
||||
</div>
|
||||
<div id="passwordStrength"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="confirmPassword">새 비밀번호 확인</label>
|
||||
<div class="input-wrapper">
|
||||
<input
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
class="form-control"
|
||||
placeholder="새 비밀번호를 다시 입력하세요"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<button type="button" class="password-toggle" data-target="confirmPassword">보기</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary" id="submitBtn">
|
||||
비밀번호 변경
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" id="resetBtn">
|
||||
초기화
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="back-link">
|
||||
<a href="javascript:history.back()">
|
||||
<span>←</span>
|
||||
<span>이전 페이지로 돌아가기</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/change-password.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
596
deploy/tkfb-package/web-ui/pages/safety/checklist-manage.html
Normal file
596
deploy/tkfb-package/web-ui/pages/safety/checklist-manage.html
Normal file
@@ -0,0 +1,596 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>안전 체크리스트 관리 - TK-FB</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/common.css">
|
||||
<style>
|
||||
.page-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #111827);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: linear-gradient(135deg, #3b82f6, #2563eb);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-add:hover {
|
||||
background: linear-gradient(135deg, #2563eb, #1d4ed8);
|
||||
}
|
||||
|
||||
/* 탭 메뉴 */
|
||||
.tab-menu {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
transition: all 0.2s;
|
||||
margin-bottom: -2px;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
color: #3b82f6;
|
||||
border-bottom-color: #3b82f6;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 체크리스트 카드 */
|
||||
.checklist-group {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
margin-bottom: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.25rem;
|
||||
background: #f9fafb;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.group-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.group-count {
|
||||
background: #e5e7eb;
|
||||
color: #6b7280;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.checklist-items {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.checklist-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.875rem 1.25rem;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.checklist-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.item-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-weight: 500;
|
||||
color: #111827;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.item-meta {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.item-badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge-required {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.badge-optional {
|
||||
background: #e5e7eb;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
background: #eff6ff;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.btn-edit:hover {
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
/* 필터/검색 */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
/* 모달 */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 1000;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-input, .form-select, .form-textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.form-input:focus, .form-select:focus, .form-textarea:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
min-height: 80px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-radio-group {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.form-radio {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-radio input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-checkbox input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.conditional-fields {
|
||||
display: none;
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.conditional-fields.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #e5e7eb;
|
||||
color: #374151;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #d1d5db;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* 날씨 아이콘 */
|
||||
.weather-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.weather-icon.rain::before { content: '[rain]'; }
|
||||
.weather-icon.snow::before { content: '[snow]'; }
|
||||
.weather-icon.heat::before { content: '[heat]'; }
|
||||
.weather-icon.cold::before { content: '[cold]'; }
|
||||
.weather-icon.wind::before { content: '[wind]'; }
|
||||
.weather-icon.fog::before { content: '[fog]'; }
|
||||
.weather-icon.dust::before { content: '[dust]'; }
|
||||
.weather-icon.clear::before { content: '[clear]'; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.checklist-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">안전 체크리스트 관리</h1>
|
||||
<button class="btn-add" onclick="openAddModal()">
|
||||
<span>+</span> 항목 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 탭 메뉴 -->
|
||||
<div class="tab-menu">
|
||||
<button class="tab-btn active" data-tab="basic" onclick="switchTab('basic')">
|
||||
기본 사항
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="weather" onclick="switchTab('weather')">
|
||||
날씨별
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="task" onclick="switchTab('task')">
|
||||
작업별
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 기본 사항 탭 -->
|
||||
<div id="basicTab" class="tab-content active">
|
||||
<div id="basicChecklistContainer">
|
||||
<!-- 동적 생성 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 날씨별 탭 -->
|
||||
<div id="weatherTab" class="tab-content">
|
||||
<div class="filter-bar">
|
||||
<select id="weatherFilter" class="filter-select" onchange="filterByWeather()">
|
||||
<option value="">모든 날씨 조건</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="weatherChecklistContainer">
|
||||
<!-- 동적 생성 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업별 탭 -->
|
||||
<div id="taskTab" class="tab-content">
|
||||
<div class="filter-bar">
|
||||
<select id="workTypeFilter" class="filter-select" onchange="filterByWorkType()">
|
||||
<option value="">공정 선택</option>
|
||||
</select>
|
||||
<select id="taskFilter" class="filter-select" onchange="filterByTask()">
|
||||
<option value="">작업 선택</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="taskChecklistContainer">
|
||||
<!-- 동적 생성 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 추가/수정 모달 -->
|
||||
<div id="checkModal" class="modal-overlay">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title" id="modalTitle">체크 항목 추가</h2>
|
||||
<button class="modal-close" onclick="closeModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="checkForm">
|
||||
<input type="hidden" id="checkId">
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">유형</label>
|
||||
<div class="form-radio-group">
|
||||
<label class="form-radio">
|
||||
<input type="radio" name="checkType" value="basic" checked onchange="toggleConditionalFields()">
|
||||
<span>기본</span>
|
||||
</label>
|
||||
<label class="form-radio">
|
||||
<input type="radio" name="checkType" value="weather" onchange="toggleConditionalFields()">
|
||||
<span>날씨별</span>
|
||||
</label>
|
||||
<label class="form-radio">
|
||||
<input type="radio" name="checkType" value="task" onchange="toggleConditionalFields()">
|
||||
<span>작업별</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 기본 유형: 카테고리 선택 -->
|
||||
<div id="basicFields" class="conditional-fields show">
|
||||
<div class="form-group">
|
||||
<label class="form-label">카테고리</label>
|
||||
<select id="checkCategory" class="form-select">
|
||||
<option value="PPE">PPE (개인보호장비)</option>
|
||||
<option value="EQUIPMENT">EQUIPMENT (장비점검)</option>
|
||||
<option value="ENVIRONMENT">ENVIRONMENT (작업환경)</option>
|
||||
<option value="EMERGENCY">EMERGENCY (비상대응)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 날씨별 유형: 날씨 조건 선택 -->
|
||||
<div id="weatherFields" class="conditional-fields">
|
||||
<div class="form-group">
|
||||
<label class="form-label">날씨 조건</label>
|
||||
<select id="weatherCondition" class="form-select">
|
||||
<!-- 동적 로드 -->
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업별 유형: 공정/작업 선택 -->
|
||||
<div id="taskFields" class="conditional-fields">
|
||||
<div class="form-group">
|
||||
<label class="form-label">공정</label>
|
||||
<select id="modalWorkType" class="form-select" onchange="loadModalTasks()">
|
||||
<option value="">공정 선택</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">작업</label>
|
||||
<select id="modalTask" class="form-select">
|
||||
<option value="">작업 선택</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">체크 항목</label>
|
||||
<input type="text" id="checkItem" class="form-input" placeholder="예: 안전모 착용 확인" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">설명 (선택)</label>
|
||||
<textarea id="checkDescription" class="form-textarea" placeholder="항목에 대한 상세 설명"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-checkbox">
|
||||
<input type="checkbox" id="isRequired" checked>
|
||||
<span>필수 체크 항목</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">표시 순서</label>
|
||||
<input type="number" id="displayOrder" class="form-input" value="0" min="0">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn-secondary" onclick="closeModal()">취소</button>
|
||||
<button type="button" class="btn-primary" onclick="saveCheck()">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<script type="module" src="/js/safety-checklist-manage.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
453
deploy/tkfb-package/web-ui/pages/safety/issue-detail.html
Normal file
453
deploy/tkfb-package/web-ui/pages/safety/issue-detail.html
Normal file
@@ -0,0 +1,453 @@
|
||||
<!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/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
/* 상태 배지 */
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.375rem 1rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-badge.reported { background: #dbeafe; color: #1d4ed8; }
|
||||
.status-badge.received { background: #fed7aa; color: #c2410c; }
|
||||
.status-badge.in_progress { background: #e9d5ff; color: #7c3aed; }
|
||||
.status-badge.completed { background: #d1fae5; color: #047857; }
|
||||
.status-badge.closed { background: #f3f4f6; color: #4b5563; }
|
||||
|
||||
/* 유형 배지 */
|
||||
.type-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.type-badge.nonconformity { background: #fff7ed; color: #c2410c; }
|
||||
.type-badge.safety { background: #fef2f2; color: #b91c1c; }
|
||||
|
||||
/* 심각도 배지 */
|
||||
.severity-badge {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.severity-badge.critical { background: #fef2f2; color: #b91c1c; }
|
||||
.severity-badge.high { background: #fff7ed; color: #c2410c; }
|
||||
.severity-badge.medium { background: #fefce8; color: #a16207; }
|
||||
.severity-badge.low { background: #f3f4f6; color: #4b5563; }
|
||||
|
||||
/* 상세 섹션 */
|
||||
.detail-section {
|
||||
background: white;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
/* 정보 그리드 */
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
padding: 0.875rem;
|
||||
background: #f9fafb;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
margin-bottom: 0.25rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
/* 사진 갤러리 */
|
||||
.photo-gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.photo-item {
|
||||
aspect-ratio: 1;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.photo-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.photo-item:hover img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* 상태 타임라인 */
|
||||
.status-timeline {
|
||||
position: relative;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.status-timeline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0.375rem;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
position: relative;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.timeline-item:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.timeline-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -1.125rem;
|
||||
top: 0.25rem;
|
||||
width: 0.625rem;
|
||||
height: 0.625rem;
|
||||
border-radius: 50%;
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.timeline-status {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.timeline-meta {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* 액션 버튼 */
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 1px solid #d1d5db;
|
||||
background: white;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:hover { background: #f9fafb; }
|
||||
.action-btn.primary { background: #3b82f6; color: white; border-color: #3b82f6; }
|
||||
.action-btn.primary:hover { background: #2563eb; }
|
||||
.action-btn.success { background: #10b981; color: white; border-color: #10b981; }
|
||||
.action-btn.success:hover { background: #059669; }
|
||||
.action-btn.danger { background: #ef4444; color: white; border-color: #ef4444; }
|
||||
.action-btn.danger:hover { background: #dc2626; }
|
||||
|
||||
/* 모달 */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-overlay.visible { display: flex; }
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.75rem;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.modal-form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.modal-form-group label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.modal-form-group input,
|
||||
.modal-form-group select,
|
||||
.modal-form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.modal-form-group input:focus,
|
||||
.modal-form-group select:focus,
|
||||
.modal-form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
/* 사진 확대 모달 */
|
||||
.photo-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
z-index: 1001;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.photo-modal.visible { display: flex; }
|
||||
|
||||
.photo-modal img {
|
||||
max-width: 90%;
|
||||
max-height: 90%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.photo-modal-close {
|
||||
position: absolute;
|
||||
top: 1.25rem;
|
||||
right: 1.25rem;
|
||||
color: white;
|
||||
font-size: 2rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 뒤로가기 링크 */
|
||||
.back-link {
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 헤더 */
|
||||
.detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.detail-id {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 768px) {
|
||||
.info-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="work-report-container">
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<main class="work-report-main">
|
||||
<div class="dashboard-main">
|
||||
<a href="#" class="back-link" onclick="goBackToList(); return false;">
|
||||
← 목록으로
|
||||
</a>
|
||||
|
||||
<div class="detail-header">
|
||||
<div>
|
||||
<div class="detail-id" id="reportId"></div>
|
||||
<h1 class="detail-title" id="reportTitle">로딩 중...</h1>
|
||||
</div>
|
||||
<span class="status-badge" id="statusBadge"></span>
|
||||
</div>
|
||||
|
||||
<!-- 기본 정보 -->
|
||||
<div class="detail-section">
|
||||
<h2 class="section-title">신고 정보</h2>
|
||||
<div class="info-grid" id="basicInfo"></div>
|
||||
</div>
|
||||
|
||||
<!-- 신고 내용 -->
|
||||
<div class="detail-section">
|
||||
<h2 class="section-title">신고 내용</h2>
|
||||
<div id="issueContent"></div>
|
||||
</div>
|
||||
|
||||
<!-- 사진 -->
|
||||
<div class="detail-section" id="photoSection" style="display: none;">
|
||||
<h2 class="section-title">첨부 사진</h2>
|
||||
<div class="photo-gallery" id="photoGallery"></div>
|
||||
</div>
|
||||
|
||||
<!-- 처리 정보 -->
|
||||
<div class="detail-section" id="processSection" style="display: none;">
|
||||
<h2 class="section-title">처리 정보</h2>
|
||||
<div id="processInfo"></div>
|
||||
</div>
|
||||
|
||||
<!-- 상태 이력 -->
|
||||
<div class="detail-section">
|
||||
<h2 class="section-title">상태 변경 이력</h2>
|
||||
<div class="status-timeline" id="statusTimeline"></div>
|
||||
</div>
|
||||
|
||||
<!-- 액션 버튼 -->
|
||||
<div class="action-buttons" id="actionButtons"></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 담당자 배정 모달 -->
|
||||
<div class="modal-overlay" id="assignModal">
|
||||
<div class="modal-content">
|
||||
<h3 class="modal-title">담당자 배정</h3>
|
||||
<div class="modal-form-group">
|
||||
<label>담당 부서</label>
|
||||
<input type="text" id="assignDepartment" placeholder="담당 부서 입력">
|
||||
</div>
|
||||
<div class="modal-form-group">
|
||||
<label>담당자</label>
|
||||
<select id="assignUser">
|
||||
<option value="">담당자 선택</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="action-btn" onclick="closeAssignModal()">취소</button>
|
||||
<button class="action-btn primary" onclick="submitAssign()">배정</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 처리 완료 모달 -->
|
||||
<div class="modal-overlay" id="completeModal">
|
||||
<div class="modal-content">
|
||||
<h3 class="modal-title">처리 완료</h3>
|
||||
<div class="modal-form-group">
|
||||
<label>처리 내용</label>
|
||||
<textarea id="resolutionNotes" rows="4" placeholder="처리 내용을 입력하세요"></textarea>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="action-btn" onclick="closeCompleteModal()">취소</button>
|
||||
<button class="action-btn success" onclick="submitComplete()">완료 처리</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 사진 확대 모달 -->
|
||||
<div class="photo-modal" id="photoModal" onclick="closePhotoModal()">
|
||||
<span class="photo-modal-close">×</span>
|
||||
<img id="photoModalImg" src="" alt="">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/issue-detail.js?v=1"></script>
|
||||
</body>
|
||||
</html>
|
||||
618
deploy/tkfb-package/web-ui/pages/safety/issue-report.html
Normal file
618
deploy/tkfb-package/web-ui/pages/safety/issue-report.html
Normal file
@@ -0,0 +1,618 @@
|
||||
<!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/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
.issue-form-container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.step-indicator {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 32px;
|
||||
padding: 16px;
|
||||
background: var(--gray-50);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--gray-400);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.step.active {
|
||||
color: var(--primary-600);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.step.completed {
|
||||
color: var(--green-600);
|
||||
}
|
||||
|
||||
.step-number {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--gray-200);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.step.active .step-number {
|
||||
background: var(--primary-500);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.step.completed .step-number {
|
||||
background: var(--green-500);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.form-section-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
/* 지도 선택 영역 */
|
||||
.map-container {
|
||||
position: relative;
|
||||
min-height: 400px;
|
||||
background: var(--gray-100);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#issueMapCanvas {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.selected-location-info {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
background: var(--primary-50);
|
||||
border-radius: var(--radius-md);
|
||||
border-left: 4px solid var(--primary-500);
|
||||
}
|
||||
|
||||
.selected-location-info.empty {
|
||||
background: var(--gray-50);
|
||||
border-left-color: var(--gray-300);
|
||||
color: var(--gray-500);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.custom-location-toggle {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.custom-location-toggle input[type="checkbox"] {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.custom-location-input {
|
||||
margin-top: 12px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.custom-location-input.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 유형 선택 버튼 */
|
||||
.type-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.type-btn {
|
||||
padding: 24px;
|
||||
border: 2px solid var(--gray-200);
|
||||
border-radius: var(--radius-lg);
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.type-btn:hover {
|
||||
border-color: var(--primary-300);
|
||||
background: var(--primary-50);
|
||||
}
|
||||
|
||||
.type-btn.selected {
|
||||
border-color: var(--primary-500);
|
||||
background: var(--primary-50);
|
||||
}
|
||||
|
||||
.type-btn.nonconformity.selected {
|
||||
border-color: var(--orange-500);
|
||||
background: var(--orange-50);
|
||||
}
|
||||
|
||||
.type-btn.safety.selected {
|
||||
border-color: var(--red-500);
|
||||
background: var(--red-50);
|
||||
}
|
||||
|
||||
.type-btn-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.type-btn-desc {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
/* 카테고리 선택 */
|
||||
.category-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.category-btn {
|
||||
padding: 16px;
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: var(--radius-md);
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
text-align: center;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.category-btn:hover {
|
||||
border-color: var(--primary-300);
|
||||
background: var(--gray-50);
|
||||
}
|
||||
|
||||
.category-btn.selected {
|
||||
border-color: var(--primary-500);
|
||||
background: var(--primary-50);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 사전 정의 항목 선택 */
|
||||
.item-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.item-btn {
|
||||
padding: 12px 20px;
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: 9999px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.item-btn:hover {
|
||||
border-color: var(--primary-300);
|
||||
background: var(--gray-50);
|
||||
}
|
||||
|
||||
.item-btn.selected {
|
||||
border-color: var(--primary-500);
|
||||
background: var(--primary-500);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.item-btn[data-severity="critical"] {
|
||||
border-color: var(--red-300);
|
||||
}
|
||||
|
||||
.item-btn[data-severity="critical"].selected {
|
||||
background: var(--red-500);
|
||||
border-color: var(--red-500);
|
||||
}
|
||||
|
||||
.item-btn[data-severity="high"] {
|
||||
border-color: var(--orange-300);
|
||||
}
|
||||
|
||||
.item-btn[data-severity="high"].selected {
|
||||
background: var(--orange-500);
|
||||
border-color: var(--orange-500);
|
||||
}
|
||||
|
||||
/* 사진 업로드 */
|
||||
.photo-upload-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.photo-slot {
|
||||
aspect-ratio: 1;
|
||||
border: 2px dashed var(--gray-300);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--gray-50);
|
||||
}
|
||||
|
||||
.photo-slot:hover {
|
||||
border-color: var(--primary-500);
|
||||
background: var(--primary-50);
|
||||
}
|
||||
|
||||
.photo-slot.has-photo {
|
||||
border-style: solid;
|
||||
border-color: var(--green-500);
|
||||
}
|
||||
|
||||
.photo-slot img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.photo-slot .add-icon {
|
||||
font-size: 24px;
|
||||
color: var(--gray-400);
|
||||
}
|
||||
|
||||
.photo-slot .remove-btn {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: var(--red-500);
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.photo-slot.has-photo .remove-btn {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.photo-slot .add-icon {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.photo-slot.has-photo .add-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 추가 설명 */
|
||||
.additional-textarea {
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--gray-300);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-base);
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.additional-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-500);
|
||||
box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.1);
|
||||
}
|
||||
|
||||
/* 제출 버튼 */
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
padding: 16px 48px;
|
||||
background: var(--primary-500);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-submit:hover {
|
||||
background: var(--primary-600);
|
||||
}
|
||||
|
||||
.btn-submit:disabled {
|
||||
background: var(--gray-300);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
padding: 16px 32px;
|
||||
background: white;
|
||||
color: var(--gray-600);
|
||||
border: 1px solid var(--gray-300);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-base);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-cancel:hover {
|
||||
background: var(--gray-50);
|
||||
}
|
||||
|
||||
/* 작업 선택 모달 */
|
||||
.work-selection-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.work-selection-modal.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.work-selection-content {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: var(--radius-lg);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.work-selection-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.work-option {
|
||||
padding: 16px;
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 12px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.work-option:hover {
|
||||
border-color: var(--primary-500);
|
||||
background: var(--primary-50);
|
||||
}
|
||||
|
||||
.work-option-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.work-option-desc {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 768px) {
|
||||
.type-buttons {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.photo-upload-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.step-indicator {
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.step-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<main class="main-content">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">문제 신고</h1>
|
||||
<p class="page-description">작업 중 발견된 부적합 사항 또는 안전 문제를 신고합니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="issue-form-container">
|
||||
<!-- 단계 표시 -->
|
||||
<div class="step-indicator">
|
||||
<div class="step active" data-step="1">
|
||||
<span class="step-number">1</span>
|
||||
<span class="step-text">위치 선택</span>
|
||||
</div>
|
||||
<div class="step" data-step="2">
|
||||
<span class="step-number">2</span>
|
||||
<span class="step-text">유형 선택</span>
|
||||
</div>
|
||||
<div class="step" data-step="3">
|
||||
<span class="step-number">3</span>
|
||||
<span class="step-text">항목 선택</span>
|
||||
</div>
|
||||
<div class="step" data-step="4">
|
||||
<span class="step-number">4</span>
|
||||
<span class="step-text">사진/설명</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 1: 위치 선택 -->
|
||||
<div class="form-section" id="step1Section">
|
||||
<h2 class="form-section-title">1. 발생 위치 선택</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="factorySelect">공장 선택</label>
|
||||
<select id="factorySelect">
|
||||
<option value="">공장을 선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="map-container">
|
||||
<canvas id="issueMapCanvas"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="selected-location-info empty" id="selectedLocationInfo">
|
||||
지도에서 작업장을 클릭하여 위치를 선택하세요
|
||||
</div>
|
||||
|
||||
<div class="custom-location-toggle">
|
||||
<input type="checkbox" id="useCustomLocation">
|
||||
<label for="useCustomLocation">지도에 없는 위치 직접 입력</label>
|
||||
</div>
|
||||
|
||||
<div class="custom-location-input" id="customLocationInput">
|
||||
<input type="text" id="customLocation" placeholder="위치를 입력하세요 (예: 야적장 입구, 주차장 등)">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: 문제 유형 선택 -->
|
||||
<div class="form-section" id="step2Section">
|
||||
<h2 class="form-section-title">2. 문제 유형 선택</h2>
|
||||
|
||||
<div class="type-buttons">
|
||||
<div class="type-btn nonconformity" data-type="nonconformity">
|
||||
<div class="type-btn-title">부적합 사항</div>
|
||||
<div class="type-btn-desc">자재, 설계, 검사 관련 문제</div>
|
||||
</div>
|
||||
<div class="type-btn safety" data-type="safety">
|
||||
<div class="type-btn-title">안전 관련</div>
|
||||
<div class="type-btn-desc">보호구, 위험구역, 안전수칙 관련</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="categoryContainer" style="display: none;">
|
||||
<label style="font-weight: 600; margin-bottom: 12px; display: block;">세부 카테고리</label>
|
||||
<div class="category-grid" id="categoryGrid"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: 신고 항목 선택 -->
|
||||
<div class="form-section" id="step3Section">
|
||||
<h2 class="form-section-title">3. 신고 항목 선택</h2>
|
||||
<p style="color: var(--gray-500); margin-bottom: 16px;">해당하는 항목을 선택하세요. 여러 개 선택 가능합니다.</p>
|
||||
|
||||
<div class="item-grid" id="itemGrid">
|
||||
<p style="color: var(--gray-400);">먼저 카테고리를 선택하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: 사진 및 추가 설명 -->
|
||||
<div class="form-section" id="step4Section">
|
||||
<h2 class="form-section-title">4. 사진 및 추가 설명</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label>사진 첨부 (최대 5장)</label>
|
||||
<div class="photo-upload-grid">
|
||||
<div class="photo-slot" data-index="0">
|
||||
<span class="add-icon">+</span>
|
||||
<button class="remove-btn" type="button">×</button>
|
||||
</div>
|
||||
<div class="photo-slot" data-index="1">
|
||||
<span class="add-icon">+</span>
|
||||
<button class="remove-btn" type="button">×</button>
|
||||
</div>
|
||||
<div class="photo-slot" data-index="2">
|
||||
<span class="add-icon">+</span>
|
||||
<button class="remove-btn" type="button">×</button>
|
||||
</div>
|
||||
<div class="photo-slot" data-index="3">
|
||||
<span class="add-icon">+</span>
|
||||
<button class="remove-btn" type="button">×</button>
|
||||
</div>
|
||||
<div class="photo-slot" data-index="4">
|
||||
<span class="add-icon">+</span>
|
||||
<button class="remove-btn" type="button">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" id="photoInput" accept="image/*" capture="environment" style="display: none;">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="additionalDescription">추가 설명 (선택)</label>
|
||||
<textarea id="additionalDescription" class="additional-textarea" placeholder="추가로 설명이 필요한 내용을 입력하세요..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 제출 버튼 -->
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn-cancel" onclick="history.back()">취소</button>
|
||||
<button type="button" class="btn-submit" id="submitBtn" onclick="submitReport()">신고 제출</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 작업 선택 모달 -->
|
||||
<div class="work-selection-modal" id="workSelectionModal">
|
||||
<div class="work-selection-content">
|
||||
<h3 class="work-selection-title">작업 선택</h3>
|
||||
<p style="margin-bottom: 16px; color: var(--gray-600);">이 위치에 등록된 작업이 있습니다. 연결할 작업을 선택하세요.</p>
|
||||
<div id="workOptionsList"></div>
|
||||
<button type="button" onclick="closeWorkModal()" style="width: 100%; padding: 12px; margin-top: 8px; background: var(--gray-100); border: none; border-radius: var(--radius-md); cursor: pointer;">
|
||||
작업 연결 없이 진행
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/work-issue-report.js?v=1"></script>
|
||||
</body>
|
||||
</html>
|
||||
291
deploy/tkfb-package/web-ui/pages/safety/management.html
Normal file
291
deploy/tkfb-package/web-ui/pages/safety/management.html
Normal 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/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
.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 src="/js/safety-management.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
306
deploy/tkfb-package/web-ui/pages/safety/report-status.html
Normal file
306
deploy/tkfb-package/web-ui/pages/safety/report-status.html
Normal file
@@ -0,0 +1,306 @@
|
||||
<!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/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
/* 통계 카드 */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
padding: 1.25rem;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.stat-card.reported .stat-number { color: #3b82f6; }
|
||||
.stat-card.received .stat-number { color: #f97316; }
|
||||
.stat-card.in_progress .stat-number { color: #8b5cf6; }
|
||||
.stat-card.completed .stat-number { color: #10b981; }
|
||||
|
||||
/* 필터 바 */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem 1.25rem;
|
||||
background: white;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.filter-bar select,
|
||||
.filter-bar input {
|
||||
padding: 0.625rem 0.875rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.filter-bar select:focus,
|
||||
.filter-bar input:focus {
|
||||
outline: none;
|
||||
border-color: #ef4444;
|
||||
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.btn-new-report {
|
||||
margin-left: auto;
|
||||
padding: 0.625rem 1.25rem;
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-new-report:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
/* 신고 목록 */
|
||||
.issue-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.issue-card {
|
||||
background: white;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.25rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.issue-card:hover {
|
||||
border-color: #fecaca;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.issue-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.issue-id {
|
||||
font-size: 0.875rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.issue-status {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.issue-status.reported {
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.issue-status.received {
|
||||
background: #fed7aa;
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.issue-status.in_progress {
|
||||
background: #e9d5ff;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.issue-status.completed {
|
||||
background: #d1fae5;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.issue-status.closed {
|
||||
background: #f3f4f6;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.issue-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.issue-category-badge {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
margin-right: 0.5rem;
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.issue-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.issue-meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.issue-photos {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.issue-photos img {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
object-fit: cover;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
/* 빈 상태 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 1.5rem;
|
||||
color: #6b7280;
|
||||
background: white;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.empty-state-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.filter-bar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.btn-new-report {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
</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" id="statsGrid">
|
||||
<div class="stat-card reported">
|
||||
<div class="stat-number" id="statReported">-</div>
|
||||
<div class="stat-label">신고</div>
|
||||
</div>
|
||||
<div class="stat-card received">
|
||||
<div class="stat-number" id="statReceived">-</div>
|
||||
<div class="stat-label">접수</div>
|
||||
</div>
|
||||
<div class="stat-card in_progress">
|
||||
<div class="stat-number" id="statProgress">-</div>
|
||||
<div class="stat-label">처리중</div>
|
||||
</div>
|
||||
<div class="stat-card completed">
|
||||
<div class="stat-number" id="statCompleted">-</div>
|
||||
<div class="stat-label">완료</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 필터 바 -->
|
||||
<div class="filter-bar">
|
||||
<select id="filterStatus">
|
||||
<option value="">전체 상태</option>
|
||||
<option value="reported">신고</option>
|
||||
<option value="received">접수</option>
|
||||
<option value="in_progress">처리중</option>
|
||||
<option value="completed">완료</option>
|
||||
<option value="closed">종료</option>
|
||||
</select>
|
||||
|
||||
<input type="date" id="filterStartDate" title="시작일">
|
||||
<input type="date" id="filterEndDate" title="종료일">
|
||||
|
||||
<a href="/pages/safety/report.html?type=safety" class="btn-new-report">
|
||||
+ 안전 신고
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 신고 목록 -->
|
||||
<div class="issue-list" id="issueList">
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-title">로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/safety-report-list.js?v=2"></script>
|
||||
</body>
|
||||
</html>
|
||||
778
deploy/tkfb-package/web-ui/pages/safety/report.html
Normal file
778
deploy/tkfb-package/web-ui/pages/safety/report.html
Normal file
@@ -0,0 +1,778 @@
|
||||
<!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/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
/* 스텝 인디케이터 */
|
||||
.step-indicator {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
margin-bottom: 2rem;
|
||||
padding: 1.5rem;
|
||||
background: white;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #9ca3af;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.step-connector {
|
||||
width: 60px;
|
||||
height: 2px;
|
||||
background: #e5e7eb;
|
||||
margin: 0 1rem;
|
||||
}
|
||||
|
||||
.step.active .step-connector,
|
||||
.step.completed .step-connector {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.step.active {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.step.completed {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: #f3f4f6;
|
||||
border: 2px solid #e5e7eb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.step.active .step-number {
|
||||
background: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.step.completed .step-number {
|
||||
background: #10b981;
|
||||
border-color: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.step-text {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 폼 섹션 */
|
||||
.form-section {
|
||||
background: white;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.form-section-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 1.25rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-section-title .section-number {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* 지도 컨테이너 */
|
||||
.map-container {
|
||||
position: relative;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#issueMapCanvas {
|
||||
display: block;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.selected-location-info {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 0.5rem;
|
||||
color: #1e40af;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.selected-location-info.empty {
|
||||
background: #f9fafb;
|
||||
border-color: #e5e7eb;
|
||||
color: #6b7280;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.custom-location-toggle {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: #f9fafb;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.custom-location-toggle input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: #3b82f6;
|
||||
}
|
||||
|
||||
.custom-location-input {
|
||||
margin-top: 0.75rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.custom-location-input.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.custom-location-input input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.custom-location-input input:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
/* 유형 선택 버튼 */
|
||||
.type-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.type-btn {
|
||||
padding: 1.5rem;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 0.75rem;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.type-btn:hover {
|
||||
border-color: #d1d5db;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.type-btn.selected {
|
||||
border-color: #3b82f6;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.type-btn.nonconformity.selected {
|
||||
border-color: #f97316;
|
||||
background: #fff7ed;
|
||||
}
|
||||
|
||||
.type-btn.safety.selected {
|
||||
border-color: #ef4444;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.type-btn-icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.type-btn-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.type-btn-desc {
|
||||
font-size: 0.8rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* 카테고리 선택 */
|
||||
#categoryContainer {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.category-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.category-btn {
|
||||
padding: 1rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.category-btn:hover {
|
||||
border-color: #3b82f6;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.category-btn.selected {
|
||||
border-color: #3b82f6;
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
/* 항목 선택 */
|
||||
.item-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.item-btn {
|
||||
padding: 0.625rem 1rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 9999px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.item-btn:hover {
|
||||
border-color: #3b82f6;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.item-btn.selected {
|
||||
border-color: #3b82f6;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.item-btn[data-severity="critical"].selected {
|
||||
background: #ef4444;
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.item-btn[data-severity="high"].selected {
|
||||
background: #f97316;
|
||||
border-color: #f97316;
|
||||
}
|
||||
|
||||
.item-btn.custom-input-btn {
|
||||
border-style: dashed;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.item-btn.custom-input-btn:hover {
|
||||
border-color: #3b82f6;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.item-btn.custom-input-btn.selected {
|
||||
border-style: solid;
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
/* 직접 입력 영역 */
|
||||
.custom-item-input {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: #f0f9ff;
|
||||
border: 1px solid #bae6fd;
|
||||
border-radius: 0.5rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.custom-item-input input {
|
||||
flex: 1;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.custom-item-input input:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.btn-confirm-custom {
|
||||
padding: 0.625rem 1rem;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-confirm-custom:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-cancel-custom {
|
||||
padding: 0.625rem 1rem;
|
||||
background: white;
|
||||
color: #4b5563;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-cancel-custom:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
/* 사진 업로드 */
|
||||
.photo-upload-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.photo-slot {
|
||||
aspect-ratio: 1;
|
||||
border: 2px dashed #d1d5db;
|
||||
border-radius: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.photo-slot:hover {
|
||||
border-color: #3b82f6;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.photo-slot.has-photo {
|
||||
border-style: solid;
|
||||
border-color: #10b981;
|
||||
}
|
||||
|
||||
.photo-slot img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.photo-slot .add-icon {
|
||||
font-size: 1.5rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.photo-slot .remove-btn {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.photo-slot.has-photo .remove-btn {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.photo-slot.has-photo .add-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 추가 설명 */
|
||||
.additional-textarea {
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.additional-textarea:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
/* 제출 버튼 */
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
padding: 0.875rem 2rem;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-submit:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-submit:disabled {
|
||||
background: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
padding: 0.875rem 1.5rem;
|
||||
background: white;
|
||||
color: #4b5563;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-cancel:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
/* 작업 선택 모달 */
|
||||
.work-selection-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.work-selection-modal.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.work-selection-content {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.75rem;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.work-selection-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.work-option {
|
||||
padding: 1rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.work-option:hover {
|
||||
border-color: #3b82f6;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.work-option-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.work-option-desc {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 768px) {
|
||||
.type-buttons {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.photo-upload-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.step-indicator {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.step-connector {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.step-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</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="step-indicator">
|
||||
<div class="step active" data-step="1">
|
||||
<span class="step-number">1</span>
|
||||
<span class="step-text">위치 선택</span>
|
||||
</div>
|
||||
<div class="step-connector"></div>
|
||||
<div class="step" data-step="2">
|
||||
<span class="step-number">2</span>
|
||||
<span class="step-text">유형 선택</span>
|
||||
</div>
|
||||
<div class="step-connector"></div>
|
||||
<div class="step" data-step="3">
|
||||
<span class="step-number">3</span>
|
||||
<span class="step-text">항목 선택</span>
|
||||
</div>
|
||||
<div class="step-connector"></div>
|
||||
<div class="step" data-step="4">
|
||||
<span class="step-number">4</span>
|
||||
<span class="step-text">사진/설명</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 1: 위치 선택 -->
|
||||
<div class="form-section" id="step1Section">
|
||||
<h2 class="form-section-title">
|
||||
<span class="section-number">1</span>
|
||||
발생 위치 선택
|
||||
</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">공장 선택</label>
|
||||
<select id="factorySelect" class="form-control">
|
||||
<option value="">공장을 선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="map-container">
|
||||
<canvas id="issueMapCanvas"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="selected-location-info empty" id="selectedLocationInfo">
|
||||
지도에서 작업장을 클릭하여 위치를 선택하세요
|
||||
</div>
|
||||
|
||||
<div class="custom-location-toggle">
|
||||
<input type="checkbox" id="useCustomLocation">
|
||||
<label for="useCustomLocation">지도에 없는 위치 직접 입력</label>
|
||||
</div>
|
||||
|
||||
<div class="custom-location-input" id="customLocationInput">
|
||||
<input type="text" id="customLocation" placeholder="위치를 입력하세요 (예: 야적장 입구, 주차장 등)">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: 문제 유형 선택 -->
|
||||
<div class="form-section" id="step2Section">
|
||||
<h2 class="form-section-title">
|
||||
<span class="section-number">2</span>
|
||||
문제 유형 선택
|
||||
</h2>
|
||||
|
||||
<div class="type-buttons">
|
||||
<div class="type-btn nonconformity" data-type="nonconformity">
|
||||
<div class="type-btn-icon">📋</div>
|
||||
<div class="type-btn-title">부적합 사항</div>
|
||||
<div class="type-btn-desc">자재, 설계, 검사 관련 문제</div>
|
||||
</div>
|
||||
<div class="type-btn safety" data-type="safety">
|
||||
<div class="type-btn-icon">⚠</div>
|
||||
<div class="type-btn-title">안전 문제</div>
|
||||
<div class="type-btn-desc">보호구, 위험구역, 안전수칙 관련</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="categoryContainer" style="display: none;">
|
||||
<label class="form-label">세부 카테고리</label>
|
||||
<div class="category-grid" id="categoryGrid"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: 신고 항목 선택 -->
|
||||
<div class="form-section" id="step3Section">
|
||||
<h2 class="form-section-title">
|
||||
<span class="section-number">3</span>
|
||||
신고 항목 선택
|
||||
</h2>
|
||||
<p style="color: #6b7280; margin-bottom: 1rem; font-size: 0.875rem;">해당하는 항목을 선택하거나 직접 입력하세요.</p>
|
||||
|
||||
<div class="item-grid" id="itemGrid">
|
||||
<p style="color: #9ca3af;">먼저 카테고리를 선택하세요</p>
|
||||
</div>
|
||||
|
||||
<!-- 직접 입력 영역 -->
|
||||
<div class="custom-item-input" id="customItemInput" style="display: none;">
|
||||
<input type="text" id="customItemName" placeholder="신고 항목을 직접 입력하세요..." maxlength="100">
|
||||
<button type="button" class="btn-confirm-custom" onclick="confirmCustomItem()">확인</button>
|
||||
<button type="button" class="btn-cancel-custom" onclick="cancelCustomItem()">취소</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: 사진 및 추가 설명 -->
|
||||
<div class="form-section" id="step4Section">
|
||||
<h2 class="form-section-title">
|
||||
<span class="section-number">4</span>
|
||||
사진 및 추가 설명
|
||||
</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">사진 첨부 (최대 5장)</label>
|
||||
<div class="photo-upload-grid">
|
||||
<div class="photo-slot" data-index="0">
|
||||
<span class="add-icon">+</span>
|
||||
<button class="remove-btn" type="button">×</button>
|
||||
</div>
|
||||
<div class="photo-slot" data-index="1">
|
||||
<span class="add-icon">+</span>
|
||||
<button class="remove-btn" type="button">×</button>
|
||||
</div>
|
||||
<div class="photo-slot" data-index="2">
|
||||
<span class="add-icon">+</span>
|
||||
<button class="remove-btn" type="button">×</button>
|
||||
</div>
|
||||
<div class="photo-slot" data-index="3">
|
||||
<span class="add-icon">+</span>
|
||||
<button class="remove-btn" type="button">×</button>
|
||||
</div>
|
||||
<div class="photo-slot" data-index="4">
|
||||
<span class="add-icon">+</span>
|
||||
<button class="remove-btn" type="button">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" id="photoInput" accept="image/*" capture="environment" style="display: none;">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="additionalDescription">추가 설명 (선택)</label>
|
||||
<textarea id="additionalDescription" class="additional-textarea" placeholder="추가로 설명이 필요한 내용을 입력하세요..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 제출 버튼 -->
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn-cancel" onclick="history.back()">취소</button>
|
||||
<button type="button" class="btn-submit" id="submitBtn" onclick="submitReport()">신고 제출</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 작업 선택 모달 -->
|
||||
<div class="work-selection-modal" id="workSelectionModal">
|
||||
<div class="work-selection-content">
|
||||
<h3 class="work-selection-title">작업 선택</h3>
|
||||
<p style="margin-bottom: 1rem; color: #6b7280; font-size: 0.875rem;">이 위치에 등록된 작업이 있습니다. 연결할 작업을 선택하세요.</p>
|
||||
<div id="workOptionsList"></div>
|
||||
<button type="button" onclick="closeWorkModal()" style="width: 100%; padding: 0.75rem; margin-top: 0.5rem; background: #f3f4f6; border: none; border-radius: 0.5rem; cursor: pointer; font-size: 0.875rem;">
|
||||
작업 연결 없이 진행
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/issue-report.js?v=2"></script>
|
||||
</body>
|
||||
</html>
|
||||
327
deploy/tkfb-package/web-ui/pages/safety/training-conduct.html
Normal file
327
deploy/tkfb-package/web-ui/pages/safety/training-conduct.html
Normal 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/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
.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 src="/js/safety-training-conduct.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
371
deploy/tkfb-package/web-ui/pages/safety/visit-request.html
Normal file
371
deploy/tkfb-package/web-ui/pages/safety/visit-request.html
Normal 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/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
.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 src="/js/visit-request.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1
deploy/tkfb-package/web-ui/pages/work/.gitkeep
Normal file
1
deploy/tkfb-package/web-ui/pages/work/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# Placeholder file to create work directory
|
||||
2859
deploy/tkfb-package/web-ui/pages/work/analysis.html
Normal file
2859
deploy/tkfb-package/web-ui/pages/work/analysis.html
Normal file
File diff suppressed because it is too large
Load Diff
306
deploy/tkfb-package/web-ui/pages/work/nonconformity.html
Normal file
306
deploy/tkfb-package/web-ui/pages/work/nonconformity.html
Normal file
@@ -0,0 +1,306 @@
|
||||
<!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/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
/* 통계 카드 */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
padding: 1.25rem;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.stat-card.reported .stat-number { color: #3b82f6; }
|
||||
.stat-card.received .stat-number { color: #f97316; }
|
||||
.stat-card.in_progress .stat-number { color: #8b5cf6; }
|
||||
.stat-card.completed .stat-number { color: #10b981; }
|
||||
|
||||
/* 필터 바 */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem 1.25rem;
|
||||
background: white;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.filter-bar select,
|
||||
.filter-bar input {
|
||||
padding: 0.625rem 0.875rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.filter-bar select:focus,
|
||||
.filter-bar input:focus {
|
||||
outline: none;
|
||||
border-color: #f97316;
|
||||
box-shadow: 0 0 0 3px rgba(249, 115, 22, 0.1);
|
||||
}
|
||||
|
||||
.btn-new-report {
|
||||
margin-left: auto;
|
||||
padding: 0.625rem 1.25rem;
|
||||
background: #f97316;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-new-report:hover {
|
||||
background: #ea580c;
|
||||
}
|
||||
|
||||
/* 신고 목록 */
|
||||
.issue-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.issue-card {
|
||||
background: white;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.25rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.issue-card:hover {
|
||||
border-color: #fed7aa;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.issue-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.issue-id {
|
||||
font-size: 0.875rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.issue-status {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.issue-status.reported {
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.issue-status.received {
|
||||
background: #fed7aa;
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.issue-status.in_progress {
|
||||
background: #e9d5ff;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.issue-status.completed {
|
||||
background: #d1fae5;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.issue-status.closed {
|
||||
background: #f3f4f6;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.issue-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.issue-category-badge {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
margin-right: 0.5rem;
|
||||
background: #fff7ed;
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.issue-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.issue-meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.issue-photos {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.issue-photos img {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
object-fit: cover;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
/* 빈 상태 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 1.5rem;
|
||||
color: #6b7280;
|
||||
background: white;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.empty-state-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.filter-bar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.btn-new-report {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
</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" id="statsGrid">
|
||||
<div class="stat-card reported">
|
||||
<div class="stat-number" id="statReported">-</div>
|
||||
<div class="stat-label">신고</div>
|
||||
</div>
|
||||
<div class="stat-card received">
|
||||
<div class="stat-number" id="statReceived">-</div>
|
||||
<div class="stat-label">접수</div>
|
||||
</div>
|
||||
<div class="stat-card in_progress">
|
||||
<div class="stat-number" id="statProgress">-</div>
|
||||
<div class="stat-label">처리중</div>
|
||||
</div>
|
||||
<div class="stat-card completed">
|
||||
<div class="stat-number" id="statCompleted">-</div>
|
||||
<div class="stat-label">완료</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 필터 바 -->
|
||||
<div class="filter-bar">
|
||||
<select id="filterStatus">
|
||||
<option value="">전체 상태</option>
|
||||
<option value="reported">신고</option>
|
||||
<option value="received">접수</option>
|
||||
<option value="in_progress">처리중</option>
|
||||
<option value="completed">완료</option>
|
||||
<option value="closed">종료</option>
|
||||
</select>
|
||||
|
||||
<input type="date" id="filterStartDate" title="시작일">
|
||||
<input type="date" id="filterEndDate" title="종료일">
|
||||
|
||||
<a href="/pages/safety/report.html?type=nonconformity" class="btn-new-report">
|
||||
+ 부적합 신고
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 신고 목록 -->
|
||||
<div class="issue-list" id="issueList">
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-title">로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/nonconformity-list.js?v=2"></script>
|
||||
</body>
|
||||
</html>
|
||||
182
deploy/tkfb-package/web-ui/pages/work/report-create.html
Normal file
182
deploy/tkfb-package/web-ui/pages/work/report-create.html
Normal file
@@ -0,0 +1,182 @@
|
||||
<!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/daily-work-report.css?v=12">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<!-- 최적화된 로딩 -->
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="work-report-container">
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="work-report-main">
|
||||
<!-- 탭 메뉴 -->
|
||||
<div class="tab-menu" style="margin-bottom: var(--space-6);">
|
||||
<button class="tab-btn active" id="tbmReportTab" onclick="switchTab('tbm')">
|
||||
작업보고서 작성
|
||||
</button>
|
||||
<button class="tab-btn" id="completedReportTab" onclick="switchTab('completed')">
|
||||
작성 완료 보고서
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 메시지 영역 -->
|
||||
<div id="message-container"></div>
|
||||
|
||||
<!-- TBM 작업보고 섹션 -->
|
||||
<div id="tbmReportSection" class="step-section active">
|
||||
<!-- TBM 작업 목록 -->
|
||||
<div id="tbmWorkList">
|
||||
<!-- TBM 작업 항목들이 여기에 동적으로 추가됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작성 완료 보고서 섹션 -->
|
||||
<div id="completedReportSection" class="step-section" style="display: none;">
|
||||
<!-- 날짜 선택 필터 -->
|
||||
<div class="form-group" style="max-width: 300px; margin-bottom: var(--space-5);">
|
||||
<label for="completedReportDate" class="form-label">조회 날짜</label>
|
||||
<input type="date" id="completedReportDate" class="form-input" onchange="loadCompletedReports()">
|
||||
</div>
|
||||
|
||||
<!-- 완료된 보고서 목록 -->
|
||||
<div id="completedReportsList">
|
||||
<!-- 완료된 보고서들이 여기에 동적으로 추가됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 저장 결과 모달 -->
|
||||
<div id="saveResultModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container result-modal">
|
||||
<div class="modal-header">
|
||||
<h2 id="resultModalTitle">저장 결과</h2>
|
||||
<button class="modal-close-btn" onclick="closeSaveResultModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="resultModalContent" class="result-content">
|
||||
<!-- 결과 내용이 여기에 동적으로 추가됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary" onclick="closeSaveResultModal()">
|
||||
확인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업장소 선택 모달 (지도 기반) -->
|
||||
<div id="workplaceModal" class="modal-overlay" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); z-index: 1002; align-items: center; justify-content: center; overflow-y: auto; padding: 2rem 0;">
|
||||
<div class="modal-container" style="background: white; border-radius: 8px; max-width: 1000px; width: 90%; max-height: none; margin: auto; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); display: flex; flex-direction: column;">
|
||||
<div class="modal-header" style="padding: 1.5rem; border-bottom: 1px solid #e5e7eb; display: flex; justify-content: space-between; align-items: center;">
|
||||
<h2 style="font-size: 1.25rem; font-weight: 600; color: #111827; margin: 0;">작업장소 선택</h2>
|
||||
<button class="modal-close" onclick="closeWorkplaceModal()" style="background: none; border: none; font-size: 1.5rem; cursor: pointer; color: #6b7280; padding: 0; width: 2rem; height: 2rem; display: flex; align-items: center; justify-content: center; border-radius: 4px;">×</button>
|
||||
</div>
|
||||
<div class="modal-body" style="padding: 1.5rem; flex: 1; overflow-y: visible;">
|
||||
<!-- 1단계: 카테고리 선택 -->
|
||||
<div id="categorySelectionArea">
|
||||
<h3 style="font-size: 1rem; font-weight: 600; margin-bottom: 0.75rem; color: #374151;">공장 선택</h3>
|
||||
<div id="workplaceCategoryList" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.75rem;">
|
||||
<!-- 카테고리 버튼들 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2단계: 작업장 선택 (지도 + 리스트) -->
|
||||
<div id="workplaceSelectionArea" style="display: none; margin-top: 1.5rem;">
|
||||
<h3 style="font-size: 1rem; font-weight: 600; margin-bottom: 0.75rem; color: #374151;">
|
||||
<span id="selectedCategoryTitle">작업장 선택</span>
|
||||
</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;">
|
||||
지도에서 작업장을 클릭하여 선택하세요
|
||||
</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 style="margin-bottom: 1rem;">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem;">
|
||||
<span style="font-size: 0.875rem; color: #6b7280;">리스트에서 선택</span>
|
||||
</div>
|
||||
<div id="workplaceListArea" style="display: flex; flex-direction: column; gap: 0.5rem; max-height: 200px; overflow-y: auto; padding: 0.75rem; border: 1px solid #e5e7eb; border-radius: 0.5rem; background: white;">
|
||||
<!-- 작업장소 목록 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 0.75rem; justify-content: flex-end; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid #e5e7eb;">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeWorkplaceModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" id="confirmWorkplaceBtn" onclick="confirmWorkplaceSelection()" disabled>선택 완료</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 시간 선택 팝오버 -->
|
||||
<div id="timePickerOverlay" class="time-picker-overlay" style="display: none;" onclick="closeTimePicker()">
|
||||
<div class="time-picker-popup" onclick="event.stopPropagation()">
|
||||
<div class="time-picker-header">
|
||||
<h3 id="timePickerTitle">작업시간 선택</h3>
|
||||
<button class="time-picker-close" onclick="closeTimePicker()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="quick-time-grid">
|
||||
<button type="button" class="time-btn" onclick="setTimeValue(0.5)">
|
||||
<span class="time-value">30분</span>
|
||||
</button>
|
||||
<button type="button" class="time-btn" onclick="setTimeValue(1)">
|
||||
<span class="time-value">1시간</span>
|
||||
</button>
|
||||
<button type="button" class="time-btn" onclick="setTimeValue(2)">
|
||||
<span class="time-value">2시간</span>
|
||||
</button>
|
||||
<button type="button" class="time-btn" onclick="setTimeValue(4)">
|
||||
<span class="time-value">4시간</span>
|
||||
</button>
|
||||
<button type="button" class="time-btn" onclick="setTimeValue(8)">
|
||||
<span class="time-value">8시간</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="time-adjust-area">
|
||||
<span class="current-time-label">현재:</span>
|
||||
<strong id="currentTimeDisplay" class="current-time-value">0시간</strong>
|
||||
<div class="adjust-buttons">
|
||||
<button type="button" class="adjust-btn" onclick="adjustTime(-0.5)">-30분</button>
|
||||
<button type="button" class="adjust-btn" onclick="adjustTime(0.5)">+30분</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="confirm-btn" onclick="confirmTimeSelection()">확인</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 스크립트 -->
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
|
||||
<!-- 작업보고서 모듈 (리팩토링된 구조) -->
|
||||
<script src="/js/daily-work-report/state.js?v=1"></script>
|
||||
<script src="/js/daily-work-report/utils.js?v=1"></script>
|
||||
<script src="/js/daily-work-report/api.js?v=1"></script>
|
||||
|
||||
<!-- 기존 UI 로직 (점진적 마이그레이션) -->
|
||||
<script type="module" src="/js/daily-work-report.js?v=29"></script>
|
||||
</body>
|
||||
</html>
|
||||
672
deploy/tkfb-package/web-ui/pages/work/tbm.html
Normal file
672
deploy/tkfb-package/web-ui/pages/work/tbm.html
Normal file
@@ -0,0 +1,672 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>TBM 관리 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/common.css?v=2">
|
||||
<link rel="stylesheet" href="/css/tbm.css?v=1">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<!-- 최적화된 로딩: API 설정 → 앱 초기화 (병렬 컴포넌트 로딩) -->
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<!-- instant.page: 링크 호버 시 페이지 프리로딩 -->
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="work-report-container">
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="work-report-main">
|
||||
<div class="tbm-container">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="tbm-page-header">
|
||||
<div class="tbm-title-section">
|
||||
<h1 class="tbm-page-title">
|
||||
<span class="tbm-page-title-icon">🛠</span>
|
||||
TBM (Tool Box Meeting)
|
||||
</h1>
|
||||
<p class="tbm-page-description">아침 안전 회의 및 팀 구성 관리</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TBM 탭 메뉴 -->
|
||||
<div class="tbm-tab-menu">
|
||||
<button class="tbm-tab-btn active" data-tab="tbm-input" onclick="switchTbmTab('tbm-input')">
|
||||
<span class="tbm-tab-icon">📝</span>
|
||||
TBM 입력
|
||||
</button>
|
||||
<button class="tbm-tab-btn" data-tab="tbm-manage" onclick="switchTbmTab('tbm-manage')">
|
||||
<span class="tbm-tab-icon">📊</span>
|
||||
TBM 관리
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- TBM 입력 탭 -->
|
||||
<div id="tbm-input-tab" class="tbm-tab-content active">
|
||||
<div class="tbm-section">
|
||||
<div class="tbm-section-header">
|
||||
<h2 class="tbm-section-title">
|
||||
<span>📅</span>
|
||||
오늘의 TBM
|
||||
</h2>
|
||||
<div class="tbm-section-actions">
|
||||
<button class="tbm-btn tbm-btn-primary" onclick="openNewTbmModal()">
|
||||
<span class="tbm-btn-icon">+</span>
|
||||
새 TBM 시작
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tbm-stats-bar">
|
||||
<span class="tbm-stat-item">
|
||||
<span class="tbm-stat-label">오늘 등록</span>
|
||||
<span class="tbm-stat-value highlight" id="todayTotalSessions">0</span>
|
||||
<span class="tbm-stat-label">개</span>
|
||||
</span>
|
||||
<span class="tbm-stat-item">
|
||||
<span class="tbm-stat-label">완료</span>
|
||||
<span class="tbm-stat-value success" id="todayCompletedSessions">0</span>
|
||||
<span class="tbm-stat-label">개</span>
|
||||
</span>
|
||||
<span class="tbm-stat-item">
|
||||
<span class="tbm-stat-label">진행중</span>
|
||||
<span class="tbm-stat-value warning" id="todayActiveSessions">0</span>
|
||||
<span class="tbm-stat-label">개</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="tbm-card-grid" id="todayTbmGrid">
|
||||
<!-- 오늘의 TBM 세션 카드들이 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div class="tbm-empty-state" id="todayEmptyState" style="display: none;">
|
||||
<div class="tbm-empty-icon">📋</div>
|
||||
<h3 class="tbm-empty-title">오늘 등록된 TBM이 없습니다</h3>
|
||||
<p class="tbm-empty-description">"새 TBM 시작" 버튼을 눌러 오늘의 TBM을 시작해보세요.</p>
|
||||
<button class="tbm-btn tbm-btn-primary" onclick="openNewTbmModal()">
|
||||
<span class="tbm-btn-icon">+</span>
|
||||
첫 TBM 시작하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TBM 관리 탭 -->
|
||||
<div id="tbm-manage-tab" class="tbm-tab-content">
|
||||
<div class="tbm-section">
|
||||
<div class="tbm-section-header">
|
||||
<h2 class="tbm-section-title">
|
||||
<span>📚</span>
|
||||
TBM 기록
|
||||
</h2>
|
||||
<div class="tbm-section-actions">
|
||||
<button class="tbm-btn tbm-btn-secondary" onclick="loadMoreTbmDays()" id="loadMoreBtn">
|
||||
더 보기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tbm-stats-bar">
|
||||
<span class="tbm-stat-item">
|
||||
<span class="tbm-stat-label">총</span>
|
||||
<span class="tbm-stat-value" id="totalSessions">0</span>
|
||||
<span class="tbm-stat-label">개</span>
|
||||
</span>
|
||||
<span class="tbm-stat-item">
|
||||
<span class="tbm-stat-label">완료</span>
|
||||
<span class="tbm-stat-value success" id="completedSessions">0</span>
|
||||
<span class="tbm-stat-label">개</span>
|
||||
</span>
|
||||
<span class="tbm-stat-item" id="viewModeIndicator" style="display: none;">
|
||||
<span class="tbm-stat-value" id="viewModeText">내 TBM만</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 날짜별 그룹 컨테이너 -->
|
||||
<div class="tbm-section-body" id="tbmDateGroupsContainer">
|
||||
<!-- 날짜별 TBM 그룹이 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div class="tbm-empty-state" id="emptyState" style="display: none;">
|
||||
<div class="tbm-empty-icon">📚</div>
|
||||
<h3 class="tbm-empty-title">등록된 TBM 세션이 없습니다</h3>
|
||||
<p class="tbm-empty-description">TBM 입력 탭에서 새로운 TBM을 시작해보세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- TBM 생성/수정 모달 -->
|
||||
<div id="tbmModal" class="tbm-modal-overlay" style="display: none;">
|
||||
<div class="tbm-modal" style="max-width: 1000px;">
|
||||
<div class="tbm-modal-header">
|
||||
<h2 class="tbm-modal-title" id="modalTitle">
|
||||
<span>📝</span>
|
||||
새 TBM 시작
|
||||
</h2>
|
||||
<button class="tbm-modal-close" onclick="closeTbmModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="tbm-modal-body">
|
||||
<form id="tbmForm" onsubmit="event.preventDefault(); saveTbmSession();">
|
||||
<input type="hidden" id="sessionId">
|
||||
|
||||
<!-- 고정 정보 섹션 -->
|
||||
<div class="tbm-form-section">
|
||||
<h3 class="tbm-form-section-title">
|
||||
<span>📅</span>
|
||||
기본 정보
|
||||
</h3>
|
||||
<div class="tbm-form-row">
|
||||
<div class="tbm-form-group">
|
||||
<label class="tbm-form-label">TBM 날짜<span class="tbm-form-required">*</span></label>
|
||||
<div class="tbm-form-input-readonly" id="sessionDateDisplay">-</div>
|
||||
<input type="hidden" id="sessionDate">
|
||||
</div>
|
||||
<div class="tbm-form-group">
|
||||
<label class="tbm-form-label">입력자<span class="tbm-form-required">*</span></label>
|
||||
<div class="tbm-form-input-readonly" id="leaderName">-</div>
|
||||
<input type="hidden" id="leaderId">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업자 및 작업 정보 섹션 -->
|
||||
<div class="tbm-form-section">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<h3 class="tbm-form-section-title" style="margin: 0; border: 0; padding: 0;">
|
||||
<span>👥</span>
|
||||
작업자 및 작업 정보
|
||||
</h3>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<button type="button" class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="openBulkSettingModal()">
|
||||
일괄 설정
|
||||
</button>
|
||||
<button type="button" class="tbm-btn tbm-btn-primary tbm-btn-sm" onclick="openWorkerSelectionModal()">
|
||||
<span class="tbm-btn-icon">+</span>
|
||||
작업자 선택
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업자 카드 리스트 -->
|
||||
<div id="workerTaskList" class="tbm-worker-list">
|
||||
<!-- 작업자 카드들이 여기에 동적으로 추가됩니다 -->
|
||||
<div class="tbm-empty-state" id="workerListEmpty" style="padding: 2rem; border: 2px dashed #d1d5db; border-radius: 10px;">
|
||||
<div class="tbm-empty-icon">👥</div>
|
||||
<p class="tbm-empty-description" style="margin: 0;">작업자를 선택해주세요</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="tbm-modal-footer">
|
||||
<button type="button" class="tbm-btn tbm-btn-secondary" onclick="closeTbmModal()">취소</button>
|
||||
<button type="button" class="tbm-btn tbm-btn-primary" onclick="saveTbmSession()">
|
||||
<span class="tbm-btn-icon">✓</span>
|
||||
저장하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 일괄 설정 모달 -->
|
||||
<div id="bulkSettingModal" class="tbm-modal-overlay" style="display: none; z-index: 1001;">
|
||||
<div class="tbm-modal" style="max-width: 700px;">
|
||||
<div class="tbm-modal-header">
|
||||
<h2 class="tbm-modal-title">
|
||||
<span>⚙</span>
|
||||
일괄 설정
|
||||
</h2>
|
||||
<button class="tbm-modal-close" onclick="closeBulkSettingModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="tbm-modal-body">
|
||||
<div class="tbm-alert tbm-alert-info">
|
||||
<span class="tbm-alert-icon">💡</span>
|
||||
<div class="tbm-alert-content">
|
||||
<div class="tbm-alert-title">일괄 설정</div>
|
||||
<div class="tbm-alert-text">선택한 작업자들의 <strong>첫 번째 작업 라인</strong>에 동일한 정보가 적용됩니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업자 선택 -->
|
||||
<div class="tbm-form-section">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.75rem;">
|
||||
<label class="tbm-form-label">적용할 작업자 선택<span class="tbm-form-required">*</span></label>
|
||||
<div style="display: flex; gap: 0.25rem;">
|
||||
<button type="button" class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="selectAllForBulk()">전체</button>
|
||||
<button type="button" class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="deselectAllForBulk()">해제</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="bulkWorkerSelection" class="tbm-worker-select-grid" style="max-height: 180px;">
|
||||
<!-- 작업자 체크박스들이 여기에 생성됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tbm-form-section" style="border-top: 1px solid #e2e8f0; padding-top: 1.5rem;">
|
||||
<h3 class="tbm-form-section-title" style="border: 0; padding: 0; margin-bottom: 1rem;">
|
||||
<span>🛠</span>
|
||||
적용할 작업 정보
|
||||
</h3>
|
||||
|
||||
<div class="tbm-form-group">
|
||||
<label class="tbm-form-label">프로젝트</label>
|
||||
<button type="button" id="bulkProjectBtn" onclick="openBulkItemSelect('project')" class="tbm-select-btn">
|
||||
프로젝트 선택
|
||||
<span class="tbm-select-arrow">▼</span>
|
||||
</button>
|
||||
<input type="hidden" id="bulkProjectId">
|
||||
</div>
|
||||
|
||||
<div class="tbm-form-row">
|
||||
<div class="tbm-form-group">
|
||||
<label class="tbm-form-label">공정<span class="tbm-form-required">*</span></label>
|
||||
<button type="button" id="bulkWorkTypeBtn" onclick="openBulkItemSelect('workType')" class="tbm-select-btn">
|
||||
공정 선택
|
||||
<span class="tbm-select-arrow">▼</span>
|
||||
</button>
|
||||
<input type="hidden" id="bulkWorkTypeId">
|
||||
</div>
|
||||
<div class="tbm-form-group">
|
||||
<label class="tbm-form-label">작업<span class="tbm-form-required">*</span></label>
|
||||
<button type="button" id="bulkTaskBtn" onclick="openBulkItemSelect('task')" class="tbm-select-btn" disabled>
|
||||
작업 선택
|
||||
<span class="tbm-select-arrow">▼</span>
|
||||
</button>
|
||||
<input type="hidden" id="bulkTaskId">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tbm-form-group">
|
||||
<label class="tbm-form-label">작업장<span class="tbm-form-required">*</span></label>
|
||||
<button type="button" id="bulkWorkplaceBtn" onclick="openBulkWorkplaceSelect()" class="tbm-select-btn">
|
||||
작업장 선택
|
||||
<span class="tbm-select-arrow">▼</span>
|
||||
</button>
|
||||
<input type="hidden" id="bulkWorkplaceCategoryId">
|
||||
<input type="hidden" id="bulkWorkplaceId">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tbm-modal-footer">
|
||||
<button type="button" class="tbm-btn tbm-btn-secondary" onclick="closeBulkSettingModal()">취소</button>
|
||||
<button type="button" class="tbm-btn tbm-btn-primary" onclick="applyBulkSettings()">
|
||||
<span class="tbm-btn-icon">✓</span>
|
||||
선택한 작업자에 적용
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업자 선택 모달 -->
|
||||
<div id="workerSelectionModal" class="tbm-modal-overlay" style="display: none; z-index: 1001;">
|
||||
<div class="tbm-modal" style="max-width: 800px;">
|
||||
<div class="tbm-modal-header">
|
||||
<h2 class="tbm-modal-title">
|
||||
<span>👥</span>
|
||||
작업자 선택
|
||||
</h2>
|
||||
<button class="tbm-modal-close" onclick="closeWorkerSelectionModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="tbm-modal-body">
|
||||
<div style="margin-bottom: 1rem; display: flex; gap: 0.5rem;">
|
||||
<button type="button" class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="selectAllWorkersInModal()">전체 선택</button>
|
||||
<button type="button" class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="deselectAllWorkersInModal()">전체 해제</button>
|
||||
</div>
|
||||
|
||||
<div id="workerCardGrid" class="tbm-worker-select-grid">
|
||||
<!-- 작업자 카드들이 여기에 생성됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tbm-modal-footer">
|
||||
<button type="button" class="tbm-btn tbm-btn-secondary" onclick="closeWorkerSelectionModal()">취소</button>
|
||||
<button type="button" class="tbm-btn tbm-btn-primary" onclick="confirmWorkerSelection()">
|
||||
<span class="tbm-btn-icon">✓</span>
|
||||
선택 완료
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 항목 선택 모달 (프로젝트/공정/작업 선택용) -->
|
||||
<div id="itemSelectModal" class="tbm-modal-overlay" style="display: none; z-index: 1002;">
|
||||
<div class="tbm-modal" style="max-width: 600px;">
|
||||
<div class="tbm-modal-header">
|
||||
<h2 class="tbm-modal-title" id="itemSelectModalTitle">항목 선택</h2>
|
||||
<button class="tbm-modal-close" onclick="closeItemSelectModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="tbm-modal-body">
|
||||
<div id="itemSelectList" class="tbm-item-list">
|
||||
<!-- 선택 항목들이 여기에 생성됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tbm-modal-footer">
|
||||
<button type="button" class="tbm-btn tbm-btn-secondary" onclick="closeItemSelectModal()">취소</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업장 선택 모달 (2단계: 공장 → 작업장) -->
|
||||
<div id="workplaceSelectModal" class="tbm-modal-overlay" style="display: none; z-index: 1002;">
|
||||
<div class="tbm-modal" style="max-width: 1000px;">
|
||||
<div class="tbm-modal-header">
|
||||
<h2 class="tbm-modal-title">
|
||||
<span>🏭</span>
|
||||
작업장 선택
|
||||
</h2>
|
||||
<button class="tbm-modal-close" onclick="closeWorkplaceSelectModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="tbm-modal-body">
|
||||
<!-- 1단계: 공장 선택 -->
|
||||
<div class="tbm-form-section">
|
||||
<h3 class="tbm-form-section-title">
|
||||
<span style="background: #3b82f6; color: white; width: 24px; height: 24px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-size: 0.8rem;">1</span>
|
||||
공장 선택
|
||||
</h3>
|
||||
<div id="categoryList" style="display: flex; flex-wrap: wrap; gap: 0.5rem;">
|
||||
<!-- 공장 카테고리 버튼들이 여기에 생성됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2단계: 작업장 선택 (지도 + 리스트) -->
|
||||
<div id="workplaceSelectionArea" style="display: none;">
|
||||
<div class="tbm-form-section">
|
||||
<h3 class="tbm-form-section-title">
|
||||
<span style="background: #3b82f6; color: white; width: 24px; height: 24px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-size: 0.8rem;">2</span>
|
||||
작업장 선택
|
||||
</h3>
|
||||
|
||||
<!-- 지도 기반 선택 영역 -->
|
||||
<div id="layoutMapArea" style="display: none; margin-bottom: 1rem; padding: 1rem; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px;">
|
||||
<div style="font-size: 0.875rem; color: #64748b; margin-bottom: 0.75rem;">
|
||||
지도에서 작업장을 클릭하여 선택하세요
|
||||
</div>
|
||||
<div class="tbm-workplace-map-container">
|
||||
<canvas id="workplaceMapCanvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 리스트 기반 선택 (오류 대비용) -->
|
||||
<div>
|
||||
<div style="font-size: 0.875rem; color: #64748b; margin-bottom: 0.75rem; display: flex; align-items: center; justify-content: space-between;">
|
||||
<span>리스트에서 선택 (지도 오류 시)</span>
|
||||
<button type="button" class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="toggleWorkplaceList()" id="toggleListBtn">
|
||||
<span id="toggleListIcon">▼</span>
|
||||
리스트 보기
|
||||
</button>
|
||||
</div>
|
||||
<div id="workplaceList" class="tbm-item-list" style="display: none; max-height: 250px;">
|
||||
<div style="color: #94a3b8; text-align: center; padding: 2rem;">
|
||||
공장을 먼저 선택해주세요
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tbm-modal-footer">
|
||||
<button type="button" class="tbm-btn tbm-btn-secondary" onclick="closeWorkplaceSelectModal()">취소</button>
|
||||
<button type="button" class="tbm-btn tbm-btn-primary" onclick="confirmWorkplaceSelection()" id="confirmWorkplaceBtn" disabled>
|
||||
<span class="tbm-btn-icon">✓</span>
|
||||
선택 완료
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 팀 구성 모달 -->
|
||||
<div id="teamModal" class="tbm-modal-overlay" style="display: none;">
|
||||
<div class="tbm-modal" style="max-width: 900px;">
|
||||
<div class="tbm-modal-header">
|
||||
<h2 class="tbm-modal-title">
|
||||
<span>👥</span>
|
||||
팀 구성
|
||||
</h2>
|
||||
<button class="tbm-modal-close" onclick="closeTeamModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="tbm-modal-body">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<h3 class="tbm-form-section-title" style="margin: 0; border: 0; padding: 0;">작업자 선택</h3>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<button class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="selectAllWorkers()">전체 선택</button>
|
||||
<button class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="deselectAllWorkers()">전체 해제</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="workerSelectionGrid" class="tbm-worker-select-grid">
|
||||
<!-- 작업자 체크박스 목록이 여기에 생성됩니다 -->
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 1.5rem;">
|
||||
<h3 class="tbm-form-section-title">
|
||||
선택된 팀원 <span id="selectedCount" style="color: #3b82f6;">0</span>명
|
||||
</h3>
|
||||
<div id="selectedWorkersList" style="display: flex; flex-wrap: wrap; gap: 0.5rem; min-height: 50px; padding: 0.75rem; background: #f8fafc; border-radius: 10px; border: 1px solid #e2e8f0;">
|
||||
<p style="margin: 0; color: #94a3b8; font-size: 0.875rem;">작업자를 선택해주세요</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tbm-modal-footer">
|
||||
<button type="button" class="tbm-btn tbm-btn-secondary" onclick="closeTeamModal()">취소</button>
|
||||
<button type="button" class="tbm-btn tbm-btn-primary" onclick="saveTeamComposition()">
|
||||
<span class="tbm-btn-icon">✓</span>
|
||||
팀 구성 완료
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 안전 체크리스트 모달 -->
|
||||
<div id="safetyModal" class="tbm-modal-overlay" style="display: none;">
|
||||
<div class="tbm-modal" style="max-width: 700px;">
|
||||
<div class="tbm-modal-header">
|
||||
<h2 class="tbm-modal-title">
|
||||
<span>🛡</span>
|
||||
안전 체크리스트
|
||||
</h2>
|
||||
<button class="tbm-modal-close" onclick="closeSafetyModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="tbm-modal-body">
|
||||
<div id="safetyChecklistContainer" class="tbm-safety-list">
|
||||
<!-- 안전 체크리스트가 여기에 생성됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tbm-modal-footer">
|
||||
<button type="button" class="tbm-btn tbm-btn-secondary" onclick="closeSafetyModal()">취소</button>
|
||||
<button type="button" class="tbm-btn tbm-btn-success" onclick="saveSafetyChecklist()">
|
||||
<span class="tbm-btn-icon">✓</span>
|
||||
안전 체크 완료
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TBM 완료 모달 -->
|
||||
<div id="completeModal" class="tbm-modal-overlay" style="display: none;">
|
||||
<div class="tbm-modal" style="max-width: 500px;">
|
||||
<div class="tbm-modal-header">
|
||||
<h2 class="tbm-modal-title">
|
||||
<span>✓</span>
|
||||
TBM 완료
|
||||
</h2>
|
||||
<button class="tbm-modal-close" onclick="closeCompleteModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="tbm-modal-body">
|
||||
<div class="tbm-alert tbm-alert-warning">
|
||||
<span class="tbm-alert-icon">⚠</span>
|
||||
<div class="tbm-alert-content">
|
||||
<div class="tbm-alert-title">TBM 완료 확인</div>
|
||||
<div class="tbm-alert-text">완료 후에는 수정할 수 없습니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tbm-form-group" style="margin-top: 1.5rem;">
|
||||
<label class="tbm-form-label">종료 시간</label>
|
||||
<input type="time" id="endTime" class="tbm-form-input">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tbm-modal-footer">
|
||||
<button type="button" class="tbm-btn tbm-btn-secondary" onclick="closeCompleteModal()">취소</button>
|
||||
<button type="button" class="tbm-btn tbm-btn-success" onclick="completeTbmSession()">
|
||||
<span class="tbm-btn-icon">✓</span>
|
||||
완료
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업 인계 모달 -->
|
||||
<div id="handoverModal" class="tbm-modal-overlay" style="display: none;">
|
||||
<div class="tbm-modal" style="max-width: 600px;">
|
||||
<div class="tbm-modal-header">
|
||||
<h2 class="tbm-modal-title">
|
||||
<span>👉</span>
|
||||
작업 인계
|
||||
</h2>
|
||||
<button class="tbm-modal-close" onclick="closeHandoverModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="tbm-modal-body">
|
||||
<form id="handoverForm">
|
||||
<input type="hidden" id="handoverSessionId">
|
||||
|
||||
<div class="tbm-form-group">
|
||||
<label class="tbm-form-label">인계 사유<span class="tbm-form-required">*</span></label>
|
||||
<select id="handoverReason" class="tbm-form-input" required>
|
||||
<option value="">사유 선택...</option>
|
||||
<option value="half_day">반차</option>
|
||||
<option value="early_leave">조퇴</option>
|
||||
<option value="emergency">긴급 상황</option>
|
||||
<option value="other">기타</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="tbm-form-group">
|
||||
<label class="tbm-form-label">인수자 (다음 팀장)<span class="tbm-form-required">*</span></label>
|
||||
<select id="toLeaderId" class="tbm-form-input" required>
|
||||
<option value="">인수자 선택...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="tbm-form-row">
|
||||
<div class="tbm-form-group">
|
||||
<label class="tbm-form-label">인계 날짜<span class="tbm-form-required">*</span></label>
|
||||
<input type="date" id="handoverDate" class="tbm-form-input" required>
|
||||
</div>
|
||||
<div class="tbm-form-group">
|
||||
<label class="tbm-form-label">인계 시간</label>
|
||||
<input type="time" id="handoverTime" class="tbm-form-input">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tbm-form-group">
|
||||
<label class="tbm-form-label">인계 내용</label>
|
||||
<textarea id="handoverNotes" class="tbm-form-input" rows="4" placeholder="인수자에게 전달할 내용을 입력하세요" style="resize: vertical;"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="tbm-form-group">
|
||||
<label class="tbm-form-label" style="margin-bottom: 0.75rem;">인계할 팀원 선택</label>
|
||||
<div id="handoverTeamList" style="max-height: 200px; overflow-y: auto; border: 1px solid #e2e8f0; border-radius: 10px; padding: 0.75rem; background: #f8fafc;">
|
||||
<!-- 팀원 체크박스 목록 -->
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="tbm-modal-footer">
|
||||
<button type="button" class="tbm-btn tbm-btn-secondary" onclick="closeHandoverModal()">취소</button>
|
||||
<button type="button" class="tbm-btn tbm-btn-primary" onclick="saveHandover()">
|
||||
<span class="tbm-btn-icon">👉</span>
|
||||
인계 요청
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TBM 상세보기 모달 -->
|
||||
<div id="detailModal" class="tbm-modal-overlay" style="display: none;">
|
||||
<div class="tbm-modal" style="max-width: 900px;">
|
||||
<div class="tbm-modal-header">
|
||||
<h2 class="tbm-modal-title">
|
||||
<span>📋</span>
|
||||
TBM 상세 정보
|
||||
</h2>
|
||||
<button class="tbm-modal-close" onclick="closeDetailModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="tbm-modal-body">
|
||||
<!-- 세션 기본 정보 -->
|
||||
<div class="tbm-form-section">
|
||||
<h3 class="tbm-form-section-title">
|
||||
<span>📅</span>
|
||||
기본 정보
|
||||
</h3>
|
||||
<div id="detailBasicInfo" style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem;">
|
||||
<!-- 동적 생성 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 팀 구성 -->
|
||||
<div class="tbm-form-section">
|
||||
<h3 class="tbm-form-section-title">
|
||||
<span>👥</span>
|
||||
팀 구성
|
||||
</h3>
|
||||
<div id="detailTeamMembers" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.75rem;">
|
||||
<!-- 동적 생성 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 안전 체크 -->
|
||||
<div class="tbm-form-section">
|
||||
<h3 class="tbm-form-section-title">
|
||||
<span>🛡</span>
|
||||
안전 체크리스트
|
||||
</h3>
|
||||
<div id="detailSafetyChecks">
|
||||
<!-- 동적 생성 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tbm-modal-footer">
|
||||
<button type="button" class="tbm-btn tbm-btn-secondary" onclick="closeDetailModal()">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 토스트 알림 -->
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
</div>
|
||||
|
||||
<!-- TBM 모듈 (리팩토링된 구조) -->
|
||||
<script src="/js/tbm/state.js?v=1"></script>
|
||||
<script src="/js/tbm/utils.js?v=1"></script>
|
||||
<script src="/js/tbm/api.js?v=1"></script>
|
||||
|
||||
<!-- 기존 UI 로직 (점진적 마이그레이션) -->
|
||||
<script type="module" src="/js/tbm.js?v=4"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user