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>
|
||||
Reference in New Issue
Block a user