feat: 데이터베이스 및 웹 UI 대규모 리팩토링

- 삭제된 DB 테이블들과 관련 코드 정리:
  * 12개 사용하지 않는 테이블 삭제 (activity_logs, CuttingPlan, DailyIssueReports 등)
  * 관련 모델, 컨트롤러, 라우트 파일들 삭제
  * index.js에서 삭제된 라우트들 제거

- 웹 UI 페이지 정리:
  * 21개 사용하지 않는 페이지 삭제
  * issue-reports 폴더 전체 삭제
  * 모든 사용자 권한을 그룹장 대시보드로 통일

- 데이터베이스 스키마 정리:
  * v1 스키마로 통일 (daily_work_reports 테이블)
  * JSON 데이터 임포트 스크립트 구현
  * 외래키 관계 정리 및 데이터 일관성 확보

- 통합 Docker Compose 설정:
  * 모든 서비스를 단일 docker-compose.yml로 통합
  * 20000번대 포트 유지
  * JWT 시크릿 및 환경변수 설정

- 문서화:
  * DATABASE_SCHEMA.md: 현재 DB 스키마 문서화
  * DELETED_TABLES.md: 삭제된 테이블 목록
  * DELETED_PAGES.md: 삭제된 페이지 목록
This commit is contained in:
Hyungi Ahn
2025-11-03 09:26:50 +09:00
parent 2a3feca45b
commit 94ecc7333d
71 changed files with 15664 additions and 4385 deletions

View File

@@ -56,6 +56,12 @@
<button class="nav-btn dashboard-btn" title="대시보드">
🏠 대시보드
</button>
<button class="nav-btn admin-btn" title="관리자 페이지" id="adminBtn" style="display: none;">
⚙️ 관리자
</button>
<button class="nav-btn system-btn" title="시스템 관리자" id="systemBtn" style="display: none;">
🔧 시스템
</button>
</div>
</div>
</nav>
@@ -306,6 +312,32 @@
transform: translateY(-1px);
}
.admin-btn {
background: linear-gradient(135deg, #ff6b35 0%, #f7931e 100%);
color: white;
border: 1px solid rgba(255,255,255,0.3);
box-shadow: 0 2px 8px rgba(255,107,53,0.3);
}
.admin-btn:hover {
background: linear-gradient(135deg, #ff5722 0%, #ff9800 100%);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(255,107,53,0.4);
}
.system-btn {
background: linear-gradient(135deg, #9c27b0 0%, #673ab7 100%);
color: white;
border: 1px solid rgba(255,255,255,0.3);
box-shadow: 0 2px 8px rgba(156,39,176,0.3);
}
.system-btn:hover {
background: linear-gradient(135deg, #8e24aa 0%, #5e35b1 100%);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(156,39,176,0.4);
}
/* 반응형 */
@media (max-width: 1024px) {
.navbar {
@@ -336,7 +368,7 @@
}
@media (max-width: 480px) {
.dashboard-btn {
.dashboard-btn, .admin-btn, .system-btn {
display: none;
}

View File

@@ -44,5 +44,6 @@
<li><a href="/pages/issue-reports/daily-issue-report.html">일일 이슈 보고</a></li>
<li><a href="/pages/issue-reports/issue-summary.html">이슈 현황 요약</a></li>
<li><a href="/pages/analysis/daily_work_analysis.html">작업 정보 페이지</a></li>
<li><a href="/pages/analysis/work-report-analytics.html" class="admin-only-link">📊 작업보고서 종합분석 <span class="admin-badge">ADMIN</span></a></li>
</ul>
</section>

View File

@@ -22,4 +22,29 @@ h1, h2 {
a {
color: #1976d2;
text-decoration: none;
}
/* Admin 권한 배지 스타일 */
.admin-badge {
background: linear-gradient(135deg, #ff6b35 0%, #f7931e 100%);
color: white;
font-size: 0.7rem;
font-weight: bold;
padding: 2px 6px;
border-radius: 10px;
margin-left: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
box-shadow: 0 2px 4px rgba(255,107,53,0.3);
display: inline-block;
vertical-align: middle;
}
.admin-only-link {
position: relative;
}
.admin-only-link:hover .admin-badge {
background: linear-gradient(135deg, #ff5722 0%, #ff9800 100%);
box-shadow: 0 3px 6px rgba(255,107,53,0.4);
}

View File

@@ -4,10 +4,174 @@
.main-layout .content-wrapper {
background: #ffffff;
min-height: calc(100vh - 80px);
padding: 2rem;
padding: 0;
border-left: 1px solid #e0e0e0;
}
/* 시스템 관리자 배너 */
.system-banner {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 2rem;
margin-bottom: 2rem;
position: relative;
overflow: hidden;
}
.system-banner::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="grid" width="10" height="10" patternUnits="userSpaceOnUse"><path d="M 10 0 L 0 0 0 10" fill="none" stroke="rgba(255,255,255,0.1)" stroke-width="0.5"/></pattern></defs><rect width="100" height="100" fill="url(%23grid)"/></svg>');
opacity: 0.3;
}
.banner-content {
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
z-index: 1;
}
.banner-left {
display: flex;
align-items: center;
gap: 1.5rem;
}
.system-icon {
font-size: 3rem;
background: rgba(255,255,255,0.2);
padding: 1rem;
border-radius: 50%;
backdrop-filter: blur(10px);
border: 2px solid rgba(255,255,255,0.3);
}
.banner-text h1 {
margin: 0 0 0.5rem 0;
font-size: 2.2rem;
font-weight: 700;
text-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.banner-text p {
margin: 0;
font-size: 1.1rem;
opacity: 0.9;
font-weight: 300;
}
.banner-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 1rem;
}
.system-status {
display: flex;
align-items: center;
gap: 0.5rem;
background: rgba(255,255,255,0.15);
padding: 0.5rem 1rem;
border-radius: 20px;
backdrop-filter: blur(10px);
border: 1px solid rgba(255,255,255,0.2);
}
.quick-actions {
display: flex;
gap: 0.5rem;
}
.quick-btn {
background: rgba(255,255,255,0.2);
border: 1px solid rgba(255,255,255,0.3);
border-radius: 50%;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
font-size: 1.2rem;
}
.quick-btn:hover {
background: rgba(255,255,255,0.3);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
animation: pulse 2s infinite;
}
.status-dot.online {
background: #2ecc71;
box-shadow: 0 0 10px rgba(46,204,113,0.5);
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
/* 메인 컨텐츠 패딩 조정 */
.main-content {
padding: 0 2rem 2rem 2rem;
}
/* 반응형 배너 */
@media (max-width: 768px) {
.system-banner {
padding: 1.5rem;
}
.banner-content {
flex-direction: column;
gap: 1.5rem;
text-align: center;
}
.banner-left {
flex-direction: column;
gap: 1rem;
}
.system-icon {
font-size: 2.5rem;
padding: 0.8rem;
}
.banner-text h1 {
font-size: 1.8rem;
}
.banner-text p {
font-size: 1rem;
}
.banner-right {
align-items: center;
}
.main-content {
padding: 0 1rem 2rem 1rem;
}
}
.page-header {
display: flex;
align-items: center;

View File

@@ -55,6 +55,18 @@ function populateUserInfo(doc, user) {
// 드롭다운 메뉴 사용자 아이디
const dropdownIdEl = doc.getElementById('dropdown-user-id');
if (dropdownIdEl) dropdownIdEl.textContent = `@${user.username}`;
// Admin 버튼 표시 여부 결정 (admin 권한만)
const adminBtn = doc.getElementById('adminBtn');
if (adminBtn && user.role === 'admin') {
adminBtn.style.display = 'flex';
}
// System 버튼 표시 여부 결정 (system 권한만)
const systemBtn = doc.getElementById('systemBtn');
if (systemBtn && user.role === 'system') {
systemBtn.style.display = 'flex';
}
}
/**
@@ -83,6 +95,22 @@ function setupNavbarEvents() {
}
});
}
// Admin 버튼 클릭 이벤트
const adminButton = document.getElementById('adminBtn');
if (adminButton) {
adminButton.addEventListener('click', () => {
window.location.href = '/pages/dashboard/admin.html';
});
}
// System 버튼 클릭 이벤트
const systemButton = document.getElementById('systemBtn');
if (systemButton) {
systemButton.addEventListener('click', () => {
window.location.href = '/pages/dashboard/system.html';
});
}
// 외부 클릭 시 드롭다운 닫기
document.addEventListener('click', (e) => {

View File

@@ -1,181 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>근태 검증 관리 시스템 | (주)테크니컬코리아</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="/css/attendance-validation.css">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/auth-check.js" defer></script>
</head>
<body class="bg-gray-50 min-h-screen">
<!-- 네비게이션 바 -->
<div id="navbar-container"></div>
<div class="max-w-7xl mx-auto p-6">
<!-- 뒤로가기 버튼 -->
<a href="javascript:history.back()" class="back-btn">
← 뒤로가기
</a>
<!-- 헤더 -->
<div class="page-header">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<span class="text-4xl">👥</span>
<div>
<h1 class="text-3xl font-bold text-white">근태 검증 관리</h1>
<p class="text-lg text-white/90 mt-2">작업자별 근무시간을 검증하고 관리합니다</p>
</div>
</div>
<div class="text-sm text-white/80 bg-white/20 px-4 py-2 rounded-lg">
🔒 Admin 전용
</div>
</div>
</div>
<!-- 메시지 영역 -->
<div id="message-container"></div>
<!-- 로딩 화면 -->
<div id="loadingScreen" class="hidden">
<div class="loading-card">
<div class="flex items-center justify-center">
<div class="loading-spinner"></div>
<span class="ml-3 text-gray-600">데이터를 불러오는 중...</span>
</div>
</div>
</div>
<!-- 메인 콘텐츠 -->
<div id="mainContent">
<!-- 캘린더 섹션 -->
<div class="main-card">
<!-- 캘린더 헤더 -->
<div class="calendar-header">
<button id="prevMonth" class="nav-btn">
<span class="text-xl"></span>
</button>
<h2 id="currentMonthYear" class="text-2xl font-bold text-gray-800">
2025년 6월
</h2>
<button id="nextMonth" class="nav-btn">
<span class="text-xl"></span>
</button>
</div>
<!-- 월간 요약 정보 -->
<div class="summary-section">
<h3 class="summary-title">이번 달 요약</h3>
<div class="summary-grid">
<div class="summary-card normal">
<div id="normalCount" class="summary-number">0</div>
<div class="summary-label">✅ 정상</div>
</div>
<div class="summary-card warning">
<div id="reviewCount" class="summary-number">0</div>
<div class="summary-label">⚠️ 검토필요</div>
</div>
<div class="summary-card error">
<div id="missingCount" class="summary-number">0</div>
<div class="summary-label">❌ 미입력</div>
</div>
</div>
</div>
<!-- 요일 헤더 -->
<div class="weekday-header">
<div class="weekday sunday"></div>
<div class="weekday"></div>
<div class="weekday"></div>
<div class="weekday"></div>
<div class="weekday"></div>
<div class="weekday"></div>
<div class="weekday saturday"></div>
</div>
<!-- 캘린더 본체 -->
<div id="calendarGrid" class="calendar-grid">
<!-- 동적으로 생성됨 -->
</div>
<!-- 범례 -->
<div class="legend">
<div class="legend-item">
<div class="legend-dot normal"></div>
<span>정상</span>
</div>
<div class="legend-item">
<div class="legend-dot warning"></div>
<span>검토필요</span>
</div>
<div class="legend-item">
<div class="legend-dot error"></div>
<span>미입력</span>
</div>
</div>
<!-- 성능 상태 표시 (개발용) -->
<div id="performanceStatus" class="text-xs text-gray-500 text-center mt-4 hidden">
<div class="bg-gray-100 rounded-lg p-2">
🔄 순차 호출: <span id="activeReq">0</span>개 처리 중 |
📦 캐시: <span id="cacheCount">0</span>개 저장됨 |
⏳ 대기: <span id="queueCount">0</span>개 |
🚫 429 에러 방지 (2초 딜레이)
</div>
</div>
</div>
<!-- 작업자 리스트 섹션 -->
<div id="workersList" class="main-card">
<div class="empty-state">
<div class="empty-icon">🔄</div>
<h3 class="empty-title">날짜를 클릭해주세요</h3>
<p class="empty-description">
캘린더에서 날짜를 클릭하면 해당 날짜의 작업자 검증 내역을 확인할 수 있습니다.<br>
<strong>순차 호출 방식</strong>으로 안정적이지만 약 5초의 로딩 시간이 있습니다.
</p>
</div>
</div>
</div>
</div>
<!-- 수정 모달 -->
<div id="editModal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h3>✏️ 근무시간 수정</h3>
<button class="close-btn" onclick="closeEditModal()">×</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>👤 작업자</label>
<input type="text" id="editWorkerName" class="form-input" readonly>
</div>
<div class="form-group">
<label>📊 현재 상태</label>
<input type="text" id="editWorkerStatus" class="form-input" readonly>
</div>
<div class="form-group">
<label>⏰ 근무시간 (시간)</label>
<input type="number" id="editWorkHours" class="form-input"
min="0" max="24" step="0.5" placeholder="근무시간을 입력하세요">
</div>
<div class="form-group">
<label>📝 수정 사유</label>
<textarea id="editReason" class="form-textarea"
placeholder="수정 사유를 입력하세요 (선택사항)"></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" onclick="closeEditModal()">취소</button>
<button class="btn-primary" onclick="saveEditedWork()">💾 저장</button>
</div>
</div>
</div>
<script type="module" src="/js/load-navbar.js"></script>
<script src="/js/attendance-validation.js"></script>
</body>
</html>

View File

@@ -1,23 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>공장 지도 등록</title>
<link rel="stylesheet" href="/css/admin.css">
</head>
<body>
<div id="navbar-placeholder"></div>
<div class="container">
<h2>공장 지도 등록</h2>
<form id="uploadForm">
<input type="text" name="factory_name" placeholder="공장명" required>
<input type="text" name="address" placeholder="주소" required>
<textarea name="description" placeholder="설명" required></textarea>
<input type="file" name="map_image" accept="image/*" required>
<button type="submit">등록</button>
</form>
</div>
<script src="/js/load-navbar.js"></script>
<script src="/js/admin/factory-upload.js"></script>
</body>
</html>

View File

@@ -1,62 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>파이프 스펙 관리 | (주)테크니컬코리아</title>
<link rel="stylesheet" href="/css/main-layout.css">
<link rel="stylesheet" href="/css/admin.css" />
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/auth-check.js" defer></script>
</head>
<body>
<div class="main-layout">
<div id="navbar-container"></div>
<div class="content-wrapper">
<div id="sidebar-container"></div>
<div id="content-container">
<div class="page-header">
<h1>🧪 파이프 스펙 관리</h1>
<p class="subtitle">배관 사양 정보를 등록하고 관리합니다.</p>
</div>
<div class="card">
<h3>새 파이프 스펙 등록</h3>
<form id="specForm" class="form-horizontal">
<div class="form-row">
<input type="text" id="material" placeholder="재질 (예: STS304)" required />
<input type="text" id="diameter_in" placeholder="직경 (예: 1, 1/2)" required />
<input type="text" id="schedule" placeholder="스케줄 (예: SCH40)" required />
<button type="submit" class="btn btn-primary">등록</button>
</div>
</form>
</div>
<div class="card">
<h3>등록된 파이프 스펙</h3>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>스펙</th>
<th>작업</th>
</tr>
</thead>
<tbody id="specTableBody">
<tr><td colspan="3" class="text-center">불러오는 중...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<script type="module" src="/js/load-navbar.js"></script>
<script type="module" src="/js/load-sidebar.js"></script>
<script type="module" src="/js/manage-pipespec.js"></script>
</body>
</html>

View File

@@ -1,109 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>작업 검토 | (주)테크니컬코리아</title>
<link rel="stylesheet" href="/css/main-layout.css">
<link rel="stylesheet" href="/css/work-review.css">
<script src="/js/auth-check.js" defer></script>
</head>
<body>
<div class="main-layout-with-navbar">
<!-- 네비게이션 바 -->
<div id="navbar-container"></div>
<div class="content-wrapper">
<div class="review-container">
<!-- 뒤로가기 버튼 -->
<a href="javascript:history.back()" class="back-btn">
← 뒤로가기
</a>
<!-- 페이지 헤더 -->
<div class="page-header">
<h1>📋 작업 검토 대시보드</h1>
<p class="subtitle">캘린더에서 날짜를 클릭하여 해당 날짜의 작업 현황을 확인하고 검토하세요</p>
</div>
<!-- 메시지 영역 -->
<div id="message-container"></div>
<!-- 컨트롤 패널 -->
<div class="control-panel">
<div class="month-navigation">
<button class="nav-btn" id="prevMonth"> 이전</button>
<div class="current-month" id="currentMonth">2025년 6월</div>
<button class="nav-btn" id="nextMonth">다음 </button>
</div>
<div class="control-actions">
<button class="today-btn" id="goToday">📅 오늘</button>
</div>
</div>
<!-- 사용법 안내 -->
<div class="usage-guide">
<h3>📖 사용법</h3>
<div class="guide-grid">
<div class="guide-item">
<div class="guide-icon">👆</div>
<div class="guide-text">
<strong>날짜 클릭</strong><br>
캘린더에서 날짜를 클릭하면 해당 날짜의 작업 정보를 확인할 수 있습니다.
</div>
</div>
<div class="guide-item">
<div class="guide-icon">✏️</div>
<div class="guide-text">
<strong>작업 수정</strong><br>
각 작업 항목의 수정 버튼을 클릭하여 프로젝트, 작업 유형, 시간 등을 수정할 수 있습니다.
</div>
</div>
<div class="guide-item">
<div class="guide-icon">🗑️</div>
<div class="guide-text">
<strong>작업 삭제</strong><br>
개별 작업 또는 작업자의 모든 작업을 삭제할 수 있습니다.
</div>
</div>
<div class="guide-item">
<div class="guide-icon"></div>
<div class="guide-text">
<strong>검토 완료</strong><br>
작업 검토가 완료되면 검토 완료 버튼을 클릭하여 상태를 변경할 수 있습니다.
</div>
</div>
</div>
</div>
<!-- 캘린더 -->
<div class="calendar-container">
<h3 style="margin-bottom: 1rem;">📅 캘린더</h3>
<div class="calendar-grid" id="calendar">
<!-- 요일 헤더 -->
<div class="day-header"></div>
<div class="day-header"></div>
<div class="day-header"></div>
<div class="day-header"></div>
<div class="day-header"></div>
<div class="day-header"></div>
<div class="day-header"></div>
<!-- 날짜 셀들이 여기에 동적으로 추가됩니다 -->
</div>
</div>
<!-- 선택된 날짜 정보 -->
<div class="day-info-panel">
<div id="day-info-container">
<!-- 날짜별 정보가 여기에 동적으로 추가됩니다 -->
</div>
</div>
</div>
</div>
</div>
<!-- 스크립트 -->
<script type="module" src="/js/load-navbar.js"></script>
<script src="/js/work-review.js"></script>
</body>
</html>

View File

@@ -0,0 +1,672 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>프로젝트별 작업 시간 분석</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<style>
.project-card {
transition: all 0.3s ease;
border-left: 4px solid #3B82F6;
}
.project-card:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
}
.work-type-row {
transition: background-color 0.2s ease;
}
.work-type-row:hover {
background-color: #f8fafc;
}
.error-high { border-left-color: #EF4444; }
.error-medium { border-left-color: #F59E0B; }
.error-low { border-left-color: #10B981; }
.progress-bar {
transition: width 0.8s ease-in-out;
}
.loading-spinner {
border: 3px solid #f3f3f3;
border-top: 3px solid #3498db;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.stat-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.stat-card.error {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
.stat-card.regular {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
.stat-card.total {
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
}
</style>
</head>
<body class="bg-gray-50 min-h-screen">
<!-- 헤더 -->
<header class="bg-white shadow-lg border-b">
<div class="container mx-auto px-6 py-4">
<div class="flex justify-between items-center">
<div>
<h1 class="text-3xl font-bold text-gray-800">🏗️ 프로젝트별 작업 시간 분석</h1>
<p class="text-gray-600 mt-1">총시간 · 정규시간 · 에러시간 상세 분석</p>
<div class="mt-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
🔧 시스템 관리자 전용
</span>
</div>
</div>
<div class="flex items-center space-x-4">
<div class="text-sm text-gray-500">
<span id="last-updated">마지막 업데이트: -</span>
</div>
<button id="refresh-btn" class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-lg transition-colors">
🔄 새로고침
</button>
<button onclick="history.back()" class="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded-lg transition-colors">
← 뒤로가기
</button>
</div>
</div>
</div>
</header>
<!-- 날짜 선택 -->
<div class="container mx-auto px-6 py-6">
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
<div class="flex flex-wrap items-center gap-4">
<div class="flex items-center space-x-2">
<label for="start-date" class="text-sm font-medium text-gray-700">시작일:</label>
<input type="date" id="start-date" class="border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent">
</div>
<div class="flex items-center space-x-2">
<label for="end-date" class="text-sm font-medium text-gray-700">종료일:</label>
<input type="date" id="end-date" class="border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent">
</div>
<button id="analyze-btn" class="bg-green-500 hover:bg-green-600 text-white px-6 py-2 rounded-md text-sm font-medium transition-colors">
📊 분석 실행
</button>
<div class="flex items-center space-x-2">
<button id="preset-week" class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-3 py-1 rounded text-xs">최근 1주일</button>
<button id="preset-month" class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-3 py-1 rounded text-xs">최근 1개월</button>
<button id="preset-august" class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-3 py-1 rounded text-xs">8월 전체</button>
</div>
</div>
</div>
</div>
<!-- 로딩 화면 -->
<div id="loading" class="hidden fixed inset-0 bg-white bg-opacity-90 flex items-center justify-center z-50">
<div class="text-center">
<div class="loading-spinner mx-auto mb-4"></div>
<p class="text-gray-600">데이터를 분석하는 중...</p>
</div>
</div>
<!-- 메인 컨테이너 -->
<div class="container mx-auto px-6 pb-8" id="main-content">
<!-- 전체 요약 통계 -->
<div id="summary-stats" class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8 hidden">
<div class="stat-card total rounded-lg shadow-md p-6 text-center">
<div class="text-3xl font-bold" id="total-hours">-</div>
<div class="text-sm opacity-90">총 작업시간</div>
</div>
<div class="stat-card regular rounded-lg shadow-md p-6 text-center">
<div class="text-3xl font-bold" id="regular-hours">-</div>
<div class="text-sm opacity-90">정규 시간</div>
</div>
<div class="stat-card error rounded-lg shadow-md p-6 text-center">
<div class="text-3xl font-bold" id="error-hours">-</div>
<div class="text-sm opacity-90">에러 시간</div>
</div>
<div class="stat-card rounded-lg shadow-md p-6 text-center">
<div class="text-3xl font-bold" id="error-rate">-</div>
<div class="text-sm opacity-90">전체 에러율</div>
</div>
</div>
<!-- 차트 섹션 -->
<div id="charts-section" class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8 hidden">
<div class="bg-white rounded-lg shadow-md p-6">
<h3 class="text-lg font-semibold mb-4">프로젝트별 시간 분포</h3>
<canvas id="project-chart"></canvas>
</div>
<div class="bg-white rounded-lg shadow-md p-6">
<h3 class="text-lg font-semibold mb-4">에러율 분석</h3>
<canvas id="error-chart"></canvas>
</div>
</div>
<!-- 프로젝트별 상세 데이터 -->
<div id="projects-container" class="space-y-6">
<!-- 프로젝트 카드들이 여기에 동적으로 생성됩니다 -->
</div>
<!-- 데이터 없음 메시지 -->
<div id="no-data" class="hidden text-center py-12">
<div class="text-gray-400 text-6xl mb-4">📊</div>
<h3 class="text-xl font-semibold text-gray-600 mb-2">분석할 데이터가 없습니다</h3>
<p class="text-gray-500">날짜 범위를 선택하고 분석을 실행해주세요.</p>
</div>
</div>
<!-- 기존 인증 시스템 사용 -->
<script>
// 전역 변수
let analysisData = null;
let charts = {};
// API 호출 함수 (토큰 포함)
async function apiCall(endpoint, options = {}) {
const token = localStorage.getItem('token');
if (!token) {
throw new Error('인증 토큰이 없습니다.');
}
const defaultHeaders = {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
};
const response = await fetch(`http://localhost:20005/api${endpoint}`, {
...options,
headers: {
...defaultHeaders,
...options.headers
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response;
}
// DOM 요소들
const elements = {
startDate: document.getElementById('start-date'),
endDate: document.getElementById('end-date'),
analyzeBtn: document.getElementById('analyze-btn'),
refreshBtn: document.getElementById('refresh-btn'),
loading: document.getElementById('loading'),
mainContent: document.getElementById('main-content'),
summaryStats: document.getElementById('summary-stats'),
chartsSection: document.getElementById('charts-section'),
projectsContainer: document.getElementById('projects-container'),
noData: document.getElementById('no-data'),
lastUpdated: document.getElementById('last-updated'),
presetWeek: document.getElementById('preset-week'),
presetMonth: document.getElementById('preset-month'),
presetAugust: document.getElementById('preset-august')
};
// 초기화
document.addEventListener('DOMContentLoaded', function() {
// 로그인 확인
const token = localStorage.getItem('token');
if (!token) {
alert('로그인이 필요합니다.');
window.location.href = '/index.html';
return;
}
// 사용자 정보 및 권한 확인
const userStr = localStorage.getItem('user');
if (!userStr) {
alert('사용자 정보를 찾을 수 없습니다.');
window.location.href = '/index.html';
return;
}
const user = JSON.parse(userStr);
// 시스템 권한 확인 (system 역할만 접근 가능)
if (user.role !== 'system') {
alert('시스템 관리자 권한이 필요합니다.');
window.location.href = '/pages/dashboard/user.html'; // 일반 사용자 대시보드로 리디렉션
return;
}
console.log('시스템 관리자 인증 완료:', user.name || user.username);
initializeDateInputs();
bindEventListeners();
// 8월 전체를 기본값으로 설정
setDatePreset('august');
});
// 날짜 입력 초기화
function initializeDateInputs() {
const today = new Date();
const oneMonthAgo = new Date(today.getFullYear(), today.getMonth() - 1, today.getDate());
elements.endDate.value = today.toISOString().split('T')[0];
elements.startDate.value = oneMonthAgo.toISOString().split('T')[0];
}
// 이벤트 리스너 바인딩
function bindEventListeners() {
elements.analyzeBtn.addEventListener('click', performAnalysis);
elements.refreshBtn.addEventListener('click', performAnalysis);
// 날짜 프리셋 버튼들
elements.presetWeek.addEventListener('click', () => setDatePreset('week'));
elements.presetMonth.addEventListener('click', () => setDatePreset('month'));
elements.presetAugust.addEventListener('click', () => setDatePreset('august'));
// Enter 키로 분석 실행
elements.startDate.addEventListener('keypress', handleEnterKey);
elements.endDate.addEventListener('keypress', handleEnterKey);
}
// Enter 키 처리
function handleEnterKey(event) {
if (event.key === 'Enter') {
performAnalysis();
}
}
// 날짜 프리셋 설정
function setDatePreset(preset) {
const today = new Date();
let startDate, endDate;
switch (preset) {
case 'week':
startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 7);
endDate = today;
break;
case 'month':
startDate = new Date(today.getFullYear(), today.getMonth() - 1, today.getDate());
endDate = today;
break;
case 'august':
startDate = new Date(2025, 7, 1); // 2025년 8월 1일
endDate = new Date(2025, 7, 31); // 2025년 8월 31일
break;
}
elements.startDate.value = startDate.toISOString().split('T')[0];
elements.endDate.value = endDate.toISOString().split('T')[0];
}
// 분석 실행
async function performAnalysis() {
const startDate = elements.startDate.value;
const endDate = elements.endDate.value;
if (!startDate || !endDate) {
alert('시작일과 종료일을 모두 선택해주세요.');
return;
}
if (new Date(startDate) > new Date(endDate)) {
alert('시작일이 종료일보다 늦을 수 없습니다.');
return;
}
// 로그인 확인
const token = localStorage.getItem('token');
if (!token) {
alert('로그인이 필요합니다.');
window.location.href = '/index.html';
return;
}
showLoading(true);
try {
const response = await apiCall(`/work-analysis/project-worktype-analysis?start=${startDate}&end=${endDate}`);
const result = await response.json();
if (result.success) {
analysisData = result.data;
renderAnalysisResults();
updateLastUpdated();
} else {
throw new Error(result.error || '데이터 조회에 실패했습니다.');
}
} catch (error) {
console.error('분석 실패:', error);
showError(`분석 실패: ${error.message}`);
} finally {
showLoading(false);
}
}
// 로딩 표시/숨김
function showLoading(show) {
elements.loading.classList.toggle('hidden', !show);
}
// 에러 표시
function showError(message) {
alert(message);
}
// 분석 결과 렌더링
function renderAnalysisResults() {
if (!analysisData || !analysisData.projects || analysisData.projects.length === 0) {
showNoData();
return;
}
hideNoData();
renderSummaryStats();
renderCharts();
renderProjectCards();
}
// 데이터 없음 표시
function showNoData() {
elements.summaryStats.classList.add('hidden');
elements.chartsSection.classList.add('hidden');
elements.projectsContainer.innerHTML = '';
elements.noData.classList.remove('hidden');
}
// 데이터 없음 숨김
function hideNoData() {
elements.noData.classList.add('hidden');
elements.summaryStats.classList.remove('hidden');
elements.chartsSection.classList.remove('hidden');
}
// 요약 통계 렌더링
function renderSummaryStats() {
const summary = analysisData.summary;
document.getElementById('total-hours').textContent = `${(summary.grand_total_hours || 0).toFixed(1)}h`;
document.getElementById('regular-hours').textContent = `${(summary.grand_regular_hours || 0).toFixed(1)}h`;
document.getElementById('error-hours').textContent = `${(summary.grand_error_hours || 0).toFixed(1)}h`;
document.getElementById('error-rate').textContent = `${summary.grand_error_rate || 0}%`;
}
// 차트 렌더링
function renderCharts() {
renderProjectChart();
renderErrorChart();
}
// 프로젝트별 시간 분포 차트
function renderProjectChart() {
const ctx = document.getElementById('project-chart').getContext('2d');
// 기존 차트 삭제
if (charts.project) {
charts.project.destroy();
}
const projects = analysisData.projects;
const labels = projects.map(p => p.project_name || 'Unknown Project');
const totalHours = projects.map(p => p.total_project_hours || 0);
const regularHours = projects.map(p => p.total_regular_hours || 0);
const errorHours = projects.map(p => p.total_error_hours || 0);
charts.project = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [
{
label: '정규 시간',
data: regularHours,
backgroundColor: '#10B981',
borderColor: '#059669',
borderWidth: 1
},
{
label: '에러 시간',
data: errorHours,
backgroundColor: '#EF4444',
borderColor: '#DC2626',
borderWidth: 1
}
]
},
options: {
responsive: true,
scales: {
x: {
stacked: true,
ticks: {
maxRotation: 45
}
},
y: {
stacked: true,
beginAtZero: true,
title: {
display: true,
text: '시간 (h)'
}
}
},
plugins: {
legend: {
position: 'top'
},
tooltip: {
callbacks: {
footer: function(tooltipItems) {
const index = tooltipItems[0].dataIndex;
const total = totalHours[index];
return `총 시간: ${total.toFixed(1)}h`;
}
}
}
}
}
});
}
// 에러율 분석 차트
function renderErrorChart() {
const ctx = document.getElementById('error-chart').getContext('2d');
// 기존 차트 삭제
if (charts.error) {
charts.error.destroy();
}
const projects = analysisData.projects;
const labels = projects.map(p => p.project_name || 'Unknown Project');
const errorRates = projects.map(p => p.project_error_rate || 0);
// 에러율에 따른 색상 결정
const colors = errorRates.map(rate => {
if (rate >= 10) return '#EF4444'; // 높음 (빨강)
if (rate >= 5) return '#F59E0B'; // 중간 (주황)
return '#10B981'; // 낮음 (초록)
});
charts.error = new Chart(ctx, {
type: 'doughnut',
data: {
labels: labels,
datasets: [{
data: errorRates,
backgroundColor: colors,
borderColor: '#ffffff',
borderWidth: 2
}]
},
options: {
responsive: true,
plugins: {
legend: {
position: 'bottom'
},
tooltip: {
callbacks: {
label: function(context) {
return `${context.label}: ${context.parsed}%`;
}
}
}
}
}
});
}
// 프로젝트 카드 렌더링
function renderProjectCards() {
const container = elements.projectsContainer;
container.innerHTML = '';
analysisData.projects.forEach(project => {
const card = createProjectCard(project);
container.appendChild(card);
});
}
// 프로젝트 카드 생성
function createProjectCard(project) {
const card = document.createElement('div');
// 에러율에 따른 카드 스타일 결정
let errorClass = 'error-low';
const errorRate = project.project_error_rate || 0;
if (errorRate >= 10) errorClass = 'error-high';
else if (errorRate >= 5) errorClass = 'error-medium';
card.className = `project-card ${errorClass} bg-white rounded-lg shadow-md p-6`;
// 프로젝트 헤더
const header = `
<div class="flex justify-between items-start mb-4">
<div>
<h3 class="text-xl font-bold text-gray-800">${project.project_name || 'Unknown Project'}</h3>
<p class="text-sm text-gray-600">Job No: ${project.job_no || 'N/A'}</p>
</div>
<div class="text-right">
<div class="text-2xl font-bold text-blue-600">${(project.total_project_hours || 0).toFixed(1)}h</div>
<div class="text-sm text-gray-500">총 시간</div>
</div>
</div>
`;
// 프로젝트 요약 통계
const summary = `
<div class="grid grid-cols-3 gap-4 mb-6 p-4 bg-gray-50 rounded-lg">
<div class="text-center">
<div class="text-lg font-semibold text-green-600">${(project.total_regular_hours || 0).toFixed(1)}h</div>
<div class="text-xs text-gray-600">정규 시간</div>
</div>
<div class="text-center">
<div class="text-lg font-semibold text-red-600">${(project.total_error_hours || 0).toFixed(1)}h</div>
<div class="text-xs text-gray-600">에러 시간</div>
</div>
<div class="text-center">
<div class="text-lg font-semibold text-purple-600">${errorRate}%</div>
<div class="text-xs text-gray-600">에러율</div>
</div>
</div>
`;
// 작업 유형별 테이블
let workTypesTable = `
<div class="overflow-x-auto">
<table class="min-w-full">
<thead class="bg-gray-100">
<tr>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">작업 유형</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">총 시간</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">정규 시간</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">에러 시간</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">에러율</th>
<th class="px-4 py-2 text-center text-xs font-medium text-gray-500 uppercase">진행률</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
`;
if (project.work_types && project.work_types.length > 0) {
project.work_types.forEach(workType => {
const totalHours = workType.total_hours || 0;
const regularHours = workType.regular_hours || 0;
const errorHours = workType.error_hours || 0;
const errorRatePercent = workType.error_rate_percent || 0;
const regularPercent = totalHours > 0 ? (regularHours / totalHours) * 100 : 0;
const errorPercent = totalHours > 0 ? (errorHours / totalHours) * 100 : 0;
workTypesTable += `
<tr class="work-type-row">
<td class="px-4 py-3 text-sm font-medium text-gray-900">${workType.work_type_name || 'Unknown'}</td>
<td class="px-4 py-3 text-sm text-right font-semibold">${totalHours.toFixed(1)}h</td>
<td class="px-4 py-3 text-sm text-right text-green-600">${regularHours.toFixed(1)}h</td>
<td class="px-4 py-3 text-sm text-right text-red-600">${errorHours.toFixed(1)}h</td>
<td class="px-4 py-3 text-sm text-right font-medium ${errorRatePercent >= 10 ? 'text-red-600' : errorRatePercent >= 5 ? 'text-yellow-600' : 'text-green-600'}">${errorRatePercent}%</td>
<td class="px-4 py-3">
<div class="flex items-center space-x-2">
<div class="flex-1 bg-gray-200 rounded-full h-2">
<div class="bg-green-500 h-2 rounded-full progress-bar" style="width: ${regularPercent}%"></div>
</div>
<div class="flex-1 bg-gray-200 rounded-full h-2">
<div class="bg-red-500 h-2 rounded-full progress-bar" style="width: ${errorPercent}%"></div>
</div>
</div>
</td>
</tr>
`;
});
} else {
workTypesTable += `
<tr>
<td colspan="6" class="px-4 py-3 text-center text-gray-500">작업 유형 데이터가 없습니다.</td>
</tr>
`;
}
workTypesTable += `
</tbody>
</table>
</div>
`;
card.innerHTML = header + summary + workTypesTable;
return card;
}
// 마지막 업데이트 시간 갱신
function updateLastUpdated() {
const now = new Date();
const timeString = now.toLocaleString('ko-KR');
elements.lastUpdated.textContent = `마지막 업데이트: ${timeString}`;
}
// 유틸리티 함수들
function formatNumber(num) {
return new Intl.NumberFormat('ko-KR').format(num);
}
function formatHours(hours) {
return `${hours.toFixed(1)}시간`;
}
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,497 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rate Limit 관리</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #f5f5f5;
padding: 20px;
}
.container {
max-width: 1000px;
margin: 0 auto;
background: white;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
display: flex;
align-items: center;
justify-content: space-between;
}
.header h1 {
font-size: 24px;
display: flex;
align-items: center;
gap: 10px;
}
.shield-icon {
width: 24px;
height: 24px;
fill: white;
}
.user-level {
background: rgba(255, 255, 255, 0.2);
padding: 8px 16px;
border-radius: 20px;
font-size: 14px;
}
.content {
padding: 30px;
}
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.status-card {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 20px;
text-align: center;
}
.status-card .label {
font-size: 14px;
color: #6c757d;
margin-bottom: 8px;
}
.status-card .value {
font-size: 18px;
font-weight: bold;
font-family: 'Courier New', monospace;
color: #495057;
}
.buttons-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-bottom: 30px;
}
.btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 20px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-blue {
background: #007bff;
color: white;
}
.btn-blue:hover:not(:disabled) {
background: #0056b3;
}
.btn-green {
background: #28a745;
color: white;
}
.btn-green:hover:not(:disabled) {
background: #1e7e34;
}
.btn-orange {
background: #fd7e14;
color: white;
}
.btn-orange:hover:not(:disabled) {
background: #e55a00;
}
.btn-gray {
background: #6c757d;
color: white;
}
.btn-gray:hover:not(:disabled) {
background: #545b62;
}
.message {
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
font-weight: 500;
}
.message.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.message.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.message.warning {
background: #fff3cd;
color: #856404;
border: 1px solid #ffeaa7;
}
.help-section {
background: #e3f2fd;
border: 1px solid #bbdefb;
border-radius: 8px;
padding: 20px;
margin-top: 20px;
}
.help-section h3 {
color: #1565c0;
margin-bottom: 10px;
}
.help-section ul {
color: #1976d2;
line-height: 1.6;
}
.help-section li {
margin-bottom: 5px;
}
.loading {
opacity: 0.7;
pointer-events: none;
}
.no-permission {
background: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
text-align: center;
}
.icon {
width: 16px;
height: 16px;
fill: currentColor;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>
<svg class="shield-icon" viewBox="0 0 24 24">
<path d="M12,1L3,5V11C3,16.55 6.84,21.74 12,23C17.16,21.74 21,16.55 21,11V5L12,1M12,7C13.4,7 14.8,8.6 14.8,10V11H16V21H8V11H9.2V10C9.2,8.6 10.6,7 12,7M12,8.2C11.2,8.2 10.4,8.7 10.4,10V11H13.6V10C13.6,8.7 12.8,8.2 12,8.2Z"/>
</svg>
Rate Limit 관리
</h1>
<div class="user-level" id="userLevel">권한 레벨: -</div>
</div>
<div class="content">
<!-- 권한 없음 메시지 -->
<div class="no-permission" id="noPermission" style="display: none;">
<svg class="icon" viewBox="0 0 24 24" style="width: 24px; height: 24px; margin-bottom: 10px;">
<path d="M12,2C13.1,2 14,2.9 14,4C14,5.1 13.1,6 12,6C10.9,6 10,5.1 10,4C10,2.9 10.9,2 12,2M21,9V7L15,1H5C3.89,1 3,1.89 3,3V21A2,2 0 0,0 5,23H19A2,2 0 0,0 21,21V9M19,9H14V4H5V21H19V9Z"/>
</svg>
<h3>접근 권한 부족</h3>
<p>Rate Limit 관리 기능은 권한 레벨 4 이상의 사용자만 사용할 수 있습니다.</p>
</div>
<!-- 현재 상태 -->
<div id="statusSection">
<h2 style="margin-bottom: 20px; display: flex; align-items: center; gap: 8px;">
<svg class="icon" viewBox="0 0 24 24">
<path d="M12,2C13.1,2 14,2.9 14,4C14,5.1 13.1,6 12,6C10.9,6 10,5.1 10,4C10,2.9 10.9,2 12,2M21,9V7L15,1H5C3.89,1 3,1.89 3,3V21A2,2 0 0,0 5,23H19A2,2 0 0,0 21,21V9M19,9H14V4H5V21H19V9Z"/>
</svg>
현재 상태
</h2>
<div class="status-grid">
<div class="status-card">
<div class="label">클라이언트 IP</div>
<div class="value" id="clientIP">로딩 중...</div>
</div>
<div class="status-card">
<div class="label">API 제한</div>
<div class="value" id="apiLimit">로딩 중...</div>
</div>
<div class="status-card">
<div class="label">로그인 제한</div>
<div class="value" id="loginLimit">로딩 중...</div>
</div>
<div class="status-card">
<div class="label">시간 윈도우</div>
<div class="value">15분</div>
</div>
</div>
</div>
<!-- 컨트롤 버튼들 -->
<div id="controlSection">
<div class="buttons-grid">
<button class="btn btn-blue" onclick="resetRateLimit()">
<svg class="icon" viewBox="0 0 24 24">
<path d="M12,2C13.1,2 14,2.9 14,4C14,5.1 13.1,6 12,6C10.9,6 10,5.1 10,4C10,2.9 10.9,2 12,2M21,9V7L15,1H5C3.89,1 3,1.89 3,3V21A2,2 0 0,0 5,23H19A2,2 0 0,0 21,21V9M19,9H14V4H5V21H19V9Z"/>
</svg>
내 IP 제한 초기화
</button>
<button class="btn btn-green" onclick="bypassRateLimit(3600000)">
<svg class="icon" viewBox="0 0 24 24">
<path d="M12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22C6.47,22 2,17.5 2,12A10,10 0 0,1 12,2Z"/>
</svg>
1시간 제한 해제
</button>
<button class="btn btn-orange" onclick="bypassRateLimit(86400000)">
<svg class="icon" viewBox="0 0 24 24">
<path d="M12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22C6.47,22 2,17.5 2,12A10,10 0 0,1 12,2Z"/>
</svg>
24시간 제한 해제
</button>
<button class="btn btn-gray" onclick="checkStatus()">
<svg class="icon" viewBox="0 0 24 24">
<path d="M17.65,6.35C16.2,4.9 14.21,4 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20C15.73,20 18.84,17.45 19.73,14H17.65C16.83,16.33 14.61,18 12,18A6,6 0 0,1 6,12A6,6 0 0,1 12,6C13.66,6 15.14,6.69 16.22,7.78L13,11H20V4L17.65,6.35Z"/>
</svg>
상태 새로고침
</button>
</div>
</div>
<!-- 메시지 표시 영역 -->
<div id="messageArea"></div>
<!-- 도움말 -->
<div class="help-section">
<h3>💡 사용 가이드</h3>
<ul>
<li><strong>초기화</strong>: 현재 IP의 요청 카운터를 0으로 리셋</li>
<li><strong>제한 해제</strong>: 지정된 시간 동안 Rate Limit 완전 비활성화</li>
<li><strong>권한 요구사항</strong>: 레벨 4-5 사용자만 접근 가능</li>
<li><strong>자동 해제</strong>: 임시 해제는 설정된 시간 후 자동으로 복구됨</li>
</ul>
</div>
</div>
</div>
<script>
let userLevel = 0;
let loading = false;
// 토큰 가져오기
function getToken() {
return localStorage.getItem('token') || sessionStorage.getItem('token');
}
// 로딩 상태 설정
function setLoading(isLoading) {
loading = isLoading;
const container = document.querySelector('.container');
if (isLoading) {
container.classList.add('loading');
} else {
container.classList.remove('loading');
}
}
// 메시지 표시
function showMessage(message, type = 'info') {
const messageArea = document.getElementById('messageArea');
const messageDiv = document.createElement('div');
messageDiv.className = `message ${type}`;
messageDiv.textContent = message;
messageArea.innerHTML = '';
messageArea.appendChild(messageDiv);
// 5초 후 자동 제거
setTimeout(() => {
if (messageDiv.parentNode) {
messageDiv.parentNode.removeChild(messageDiv);
}
}, 5000);
}
// 사용자 권한 확인
async function checkUserPermission() {
try {
const token = getToken();
if (!token) {
showMessage('로그인이 필요합니다.', 'error');
return false;
}
const response = await fetch('/api/auth/me', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
const userData = await response.json();
userLevel = userData.access_level || 0;
document.getElementById('userLevel').textContent = `권한 레벨: ${userLevel}`;
if (userLevel < 4) {
document.getElementById('noPermission').style.display = 'block';
document.getElementById('statusSection').style.display = 'none';
document.getElementById('controlSection').style.display = 'none';
return false;
}
return true;
} else {
showMessage('사용자 정보 확인 실패', 'error');
return false;
}
} catch (error) {
showMessage('네트워크 오류: ' + error.message, 'error');
return false;
}
}
// 현재 상태 조회
async function checkStatus() {
if (loading || userLevel < 4) return;
setLoading(true);
try {
const token = getToken();
const response = await fetch('/api/admin/rate-limit/status', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
const data = await response.json();
document.getElementById('clientIP').textContent = data.clientIP;
document.getElementById('apiLimit').textContent = `${data.rateLimitInfo.apiLimit}회/15분`;
document.getElementById('loginLimit').textContent = `${data.rateLimitInfo.loginLimit}회/15분`;
} else {
const errorData = await response.json();
showMessage('상태 조회 실패: ' + (errorData.error || response.statusText), 'error');
}
} catch (error) {
showMessage('네트워크 오류: ' + error.message, 'error');
}
setLoading(false);
}
// Rate Limit 초기화
async function resetRateLimit(targetIP = null) {
if (loading || userLevel < 4) return;
setLoading(true);
try {
const token = getToken();
const response = await fetch('/api/admin/rate-limit/reset', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ targetIP })
});
const data = await response.json();
if (response.ok) {
showMessage('✅ ' + data.message, 'success');
checkStatus(); // 상태 새로고침
} else {
showMessage('❌ ' + data.error, 'error');
}
} catch (error) {
showMessage('❌ 초기화 실패: ' + error.message, 'error');
}
setLoading(false);
}
// Rate Limit 임시 비활성화
async function bypassRateLimit(duration = 3600000) {
if (loading || userLevel < 4) return;
setLoading(true);
try {
const token = getToken();
const response = await fetch('/api/admin/rate-limit/bypass', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ duration })
});
const data = await response.json();
if (response.ok) {
const hours = duration / 3600000;
showMessage(`🔓 ${hours}시간 동안 Rate Limit가 해제되었습니다.`, 'success');
checkStatus();
} else {
showMessage('❌ ' + data.error, 'error');
}
} catch (error) {
showMessage('❌ Bypass 설정 실패: ' + error.message, 'error');
}
setLoading(false);
}
// 페이지 로드 시 초기화
async function init() {
const hasPermission = await checkUserPermission();
if (hasPermission) {
await checkStatus();
}
}
// 페이지 로드 시 실행
document.addEventListener('DOMContentLoaded', init);
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,87 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>출근부 조회 | (주)테크니컬코리아</title>
<link rel="stylesheet" href="/css/main-layout.css">
<link rel="stylesheet" href="/css/admin.css">
<link rel="stylesheet" href="/css/attendance.css" />
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/auth-check.js" defer></script>
</head>
<body>
<div class="main-layout">
<div id="navbar-container"></div>
<div class="content-wrapper">
<div id="sidebar-container"></div>
<div id="content-container">
<div class="page-header">
<h1>📊 출근부 조회</h1>
<p class="subtitle">월별 출근 현황 및 잔업 시간을 확인할 수 있습니다.</p>
</div>
<div id="pdfContent">
<!-- 조회 컨트롤 -->
<div class="control-panel card">
<div class="control-group">
<label for="year">연도</label>
<select id="year" class="form-control"></select>
</div>
<div class="control-group">
<label for="month"></label>
<select id="month" class="form-control"></select>
</div>
<div class="control-actions">
<button id="loadAttendance" class="btn btn-primary">
<span class="icon">📊</span> 조회하기
</button>
<button id="downloadPdf" class="btn btn-secondary">
<span class="icon">📄</span> PDF 저장
</button>
</div>
</div>
<!-- 출근부 테이블 -->
<div class="table-container card">
<div id="attendanceTableContainer">
<div class="empty-state">
<p>조회할 연도와 월을 선택하세요.</p>
</div>
</div>
</div>
</div>
<!-- 범례 -->
<div class="legend card">
<h3>범례</h3>
<div class="legend-items">
<span class="legend-item">
<span class="color-box overtime-cell"></span> 잔업
</span>
<span class="legend-item">
<span class="color-box leave"></span> 연차/반차
</span>
<span class="legend-item">
<span class="color-box paid-leave"></span> 유급
</span>
<span class="legend-item">
<span class="color-box holiday"></span> 휴무
</span>
</div>
</div>
</div>
</div>
</div>
<script type="module" src="/js/load-navbar.js"></script>
<script type="module" src="/js/load-sidebar.js"></script>
<script type="module" src="/js/attendance.js"></script>
<!-- PDF 생성 라이브러리 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
</body>
</html>

View File

@@ -1,47 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>공장 정보 등록 | (주)테크니컬코리아</title>
<link rel="stylesheet" href="/css/main-layout.css">
<link rel="stylesheet" href="/css/factory.css" />
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/auth-check.js" defer></script>
</head>
<body>
<div class="main-layout">
<div id="navbar-container"></div>
<div class="content-wrapper">
<div id="sidebar-container"></div>
<div id="content-container">
<div class="container">
<h2>공장 정보 등록</h2>
<form id="uploadForm" enctype="multipart/form-data">
<label>공장명</label>
<input type="text" name="factory_name" placeholder="예: 울산 제1공장" required>
<label>주소</label>
<input type="text" name="address" placeholder="예: 울산광역시 남구 산업로 123" required>
<label>설명</label>
<textarea name="description" placeholder="공장에 대한 간단한 설명을 입력하세요." required></textarea>
<label>공장 지도 이미지</label>
<input type="file" name="map_image" accept="image/*" required>
<div id="file-preview" style="margin: 10px 0; text-align: center;"></div>
<button type="submit">등록하기</button>
</form>
</div>
</div>
</div>
</div>
<script type="module" src="/js/load-navbar.js"></script>
<script type="module" src="/js/load-sidebar.js"></script>
<script type="module" src="/js/factory-upload.js"></script>
</body>
</html>

View File

@@ -1,59 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>공장 정보 | (주)테크니컬코리아</title>
<link rel="stylesheet" href="/css/main-layout.css">
<link rel="stylesheet" href="/css/factory.css" />
<script src="/js/auth-check.js" defer></script>
</head>
<body>
<div class="main-layout">
<div id="navbar-container"></div>
<div class="content-wrapper">
<div id="sidebar-container"></div>
<div id="content-container">
<div class="container">
<!-- 로딩 상태 -->
<div id="loading" class="loading">
공장 정보를 불러오는 중...
</div>
<!-- 실제 컨텐츠 (초기에는 숨김) -->
<div id="content" style="display: none;">
<h2 id="factoryName">공장 이름</h2>
<div class="info-section">
<div class="info-label">📍 주소</div>
<p id="factoryAddress" style="margin: 0;">주소</p>
</div>
<img id="factoryImage" alt="공장 지도" style="max-width: 100%; margin: 20px 0;">
<div class="info-section">
<div class="info-label">📝 설명</div>
<p id="factoryDescription" style="margin: 0;">설명</p>
</div>
<div style="margin-top: 30px; text-align: center;">
<button onclick="history.back()" style="padding: 10px 20px;">뒤로가기</button>
</div>
</div>
<!-- 에러 메시지 (필요시 표시) -->
<div id="error" class="error-state" style="display: none;">
공장 정보를 불러올 수 없습니다.
</div>
</div>
</div>
</div>
</div>
<script type="module" src="/js/load-navbar.js"></script>
<script type="module" src="/js/load-sidebar.js"></script>
<script type="module" src="/js/factory-view.js"></script>
</body>
</html>

View File

@@ -7,7 +7,7 @@
<link rel="stylesheet" href="/css/main-layout.css">
<link rel="stylesheet" href="/css/management-dashboard.css">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/auth-check.js" defer></script>
<script type="module" src="/js/auth-check.js" defer></script>
</head>
<body>
<div class="main-layout-with-navbar">

View File

@@ -17,10 +17,38 @@
<div id="navbar-container"></div>
<div class="content-wrapper">
<!-- 시스템 관리자 페이지 헤더 -->
<div class="page-header">
<h1><i class="fas fa-cogs"></i> 시스템 관리자</h1>
<span class="system-badge">SYSTEM</span>
<!-- 시스템 관리자 배너 -->
<div class="system-banner">
<div class="banner-content">
<div class="banner-left">
<div class="system-icon">🔧</div>
<div class="banner-text">
<h1>시스템 관리자</h1>
<p>시스템 전반의 설정, 모니터링 및 관리를 담당합니다</p>
</div>
</div>
<div class="banner-right">
<span class="system-badge">SYSTEM</span>
<div class="system-status">
<span class="status-dot online"></span>
<span>시스템 정상</span>
</div>
<div class="quick-actions">
<button class="quick-btn" onclick="window.location.href='/pages/admin/manage-user.html'" title="사용자 관리">
👤
</button>
<button class="quick-btn" onclick="window.location.href='/pages/analysis/work-report-analytics.html'" title="분석 대시보드">
📊
</button>
<button class="quick-btn" onclick="window.location.href='/pages/analysis/project-worktype-analysis.html'" title="프로젝트별 작업 시간 분석">
🏗️
</button>
<button class="quick-btn" onclick="refreshSystemStatus()" title="시스템 새로고침">
🔄
</button>
</div>
</div>
</div>
</div>
<!-- 메인 컨텐츠 -->
@@ -144,6 +172,22 @@
</div>
</div>
<!-- 프로젝트별 작업 시간 분석 -->
<div class="management-card primary">
<div class="card-header">
<i class="fas fa-project-diagram"></i>
<h3>프로젝트 작업 분석</h3>
</div>
<div class="card-content">
<p>프로젝트별-작업별 시간 분석 및 에러율 모니터링</p>
<div class="card-actions">
<button class="btn btn-primary" onclick="window.location.href='/pages/analysis/project-worktype-analysis.html'">
<i class="fas fa-chart-bar"></i> 분석 보기
</button>
</div>
</div>
</div>
<!-- 모니터링 -->
<div class="management-card">
<div class="card-header">
@@ -189,9 +233,39 @@
</div>
</div>
<!-- 테스트용 인라인 스크립트 -->
<!-- 시스템 관리자 스크립트 -->
<script>
console.log('🧪 인라인 스크립트 실행됨');
console.log('🔧 시스템 관리자 대시보드 로드됨');
// 시스템 상태 새로고침 함수
function refreshSystemStatus() {
console.log('🔄 시스템 상태 새로고침 중...');
// 시각적 피드백
const statusDot = document.querySelector('.status-dot');
const refreshBtn = document.querySelector('.quick-btn[title="시스템 새로고침"]');
if (refreshBtn) {
refreshBtn.style.transform = 'rotate(360deg)';
setTimeout(() => {
refreshBtn.style.transform = '';
}, 1000);
}
// 실제 상태 업데이트 (시뮬레이션)
setTimeout(() => {
updateSystemTime();
console.log('✅ 시스템 상태 업데이트 완료');
}, 1000);
}
// 시스템 시간 업데이트
function updateSystemTime() {
const timeElement = document.getElementById('server-check-time');
if (timeElement) {
timeElement.textContent = new Date().toLocaleTimeString('ko-KR');
}
}
// 간단한 테스트 함수
function testClick() {
@@ -199,17 +273,24 @@
alert('버튼이 정상적으로 작동합니다!');
}
// DOM 로드 후 이벤트 리스너 설정
// DOM 로드 후 초기화
document.addEventListener('DOMContentLoaded', function() {
console.log('📄 DOM 로드 완료');
console.log('📄 시스템 대시보드 DOM 로드 완료');
// 초기 시간 설정
updateSystemTime();
// 주기적 시간 업데이트 (30초마다)
setInterval(updateSystemTime, 30000);
// 계정 관리 버튼 이벤트
const accountBtn = document.querySelector('[data-action="account-management"]');
if (accountBtn) {
accountBtn.addEventListener('click', testClick);
console.log('✅ 계정 관리 버튼에 테스트 이벤트 리스너 추가됨');
} else {
console.log('❌ 계정 관리 버튼을 찾을 수 없음');
console.log('✅ 계정 관리 버튼 이벤트 설정 완료');
}
console.log('🚀 시스템 관리자 대시보드 초기화 완료');
});
</script>

View File

@@ -1,76 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>일일 이슈 보고 | (주)테크니컬코리아</title>
<link rel="stylesheet" href="/css/main-layout.css">
<link rel="stylesheet" href="/css/daily-issue.css">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/auth-check.js" defer></script>
</head>
<body>
<div class="main-layout">
<div id="navbar-container"></div>
<div class="content-wrapper">
<div id="sidebar-container"></div>
<div id="content-container">
<div class="page-header">
<h1>📋 일일 이슈 보고</h1>
<p class="subtitle">금일 발생한 이슈 상황을 입력합니다.</p>
</div>
<div class="card">
<form id="issueForm">
<div class="form-group">
<label for="dateSelect">날짜</label>
<input type="date" id="dateSelect" class="form-control" required />
</div>
<div class="form-group">
<label for="projectSelect">프로젝트</label>
<select id="projectSelect" class="form-control" required>
<option value="">-- 프로젝트 선택 --</option>
</select>
</div>
<div class="form-group">
<label>작업자 선택</label>
<div id="workerList" class="multi-select-box">
날짜를 먼저 선택하세요.
</div>
</div>
<div class="form-group">
<label for="issueTypeSelect">이슈 유형</label>
<select id="issueTypeSelect" class="form-control" required>
<option value="">-- 이슈 유형 선택 --</option>
</select>
</div>
<div class="form-group">
<label>시간 범위</label>
<div class="time-range">
<select id="timeStart" class="form-control" required></select>
<span style="margin: 0 10px;">~</span>
<select id="timeEnd" class="form-control" required></select>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="history.back()">취소</button>
<button type="submit" id="submitBtn" class="btn btn-primary">등록</button>
</div>
</form>
</div>
</div>
</div>
</div>
<script type="module" src="/js/load-navbar.js"></script>
<script type="module" src="/js/load-sidebar.js"></script>
<script type="module" src="/js/daily-issue.js"></script>
</body>
</html>