fix: 캘린더 모달 중복 카드 문제 및 삭제 권한 개선
- monthly_worker_status 조회 시 GROUP BY로 중복 데이터 합산 - 작업보고서 삭제 권한을 그룹장 이상으로 제한 (admin, system, group_leader) - 중복 데이터 정리를 위한 마이그레이션 SQL 추가 (009_fix_duplicate_monthly_status.sql) - synology_deployment 버전에도 동일 수정 적용
This commit is contained in:
11
synology_deployment/web-ui/Dockerfile
Normal file
11
synology_deployment/web-ui/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
||||
FROM nginx:alpine
|
||||
|
||||
# 정적 파일 복사
|
||||
COPY . /usr/share/nginx/html/
|
||||
|
||||
# Nginx 설정 파일 복사 (선택사항)
|
||||
# COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
1635
synology_deployment/web-ui/Minutes/safety.html
Normal file
1635
synology_deployment/web-ui/Minutes/safety.html
Normal file
File diff suppressed because it is too large
Load Diff
105
synology_deployment/web-ui/Minutes/안전 회의록 백업/2025.06.27.json
Normal file
105
synology_deployment/web-ui/Minutes/안전 회의록 백업/2025.06.27.json
Normal file
@@ -0,0 +1,105 @@
|
||||
[
|
||||
{
|
||||
"category": "안전회의",
|
||||
"items": [
|
||||
{
|
||||
"task": "일정 공유",
|
||||
"method": "메일",
|
||||
"frequency": "1회/매달",
|
||||
"assignee": "하주현"
|
||||
},
|
||||
{
|
||||
"task": "안전회의록 작성 및 지난달 안전회의록 인쇄 및 서명",
|
||||
"method": "-",
|
||||
"frequency": "1회/매달",
|
||||
"assignee": "하주현"
|
||||
},
|
||||
{
|
||||
"task": "내용 공유",
|
||||
"method": "메일",
|
||||
"frequency": "1회/매달",
|
||||
"assignee": "하주현"
|
||||
},
|
||||
{
|
||||
"task": "자료 저장 (서버: [공융 드라이브] - [테크니컬코리아] - [07.안전회의])",
|
||||
"method": "서버",
|
||||
"frequency": "1회/매달",
|
||||
"assignee": "하주현"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "동절기 전열기 관리",
|
||||
"items": [
|
||||
{
|
||||
"task": "담당자 지정 및 전열기 전원 확인",
|
||||
"method": "-",
|
||||
"frequency": "매주/동절기",
|
||||
"assignee": "하주현"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "소화기 점검",
|
||||
"items": [
|
||||
{
|
||||
"task": "점검일지 작성",
|
||||
"method": "-",
|
||||
"frequency": "1회/매달",
|
||||
"assignee": "신민기 (=신상균)"
|
||||
},
|
||||
{
|
||||
"task": "점검일지 자료 저장 (서버: [공융 드라이브] - [테크니컬코리아] - [02.구매물류팀] - [소방안전 박창원,하주현] - [소화기 점검일지])",
|
||||
"method": "서버",
|
||||
"frequency": "1회/매달",
|
||||
"assignee": "하주현"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "건강검진",
|
||||
"items": [
|
||||
{
|
||||
"task": "제휴 병원 (화성디에스) 건강검진 버스 일정 확인",
|
||||
"method": "",
|
||||
"frequency": "1회/매년",
|
||||
"assignee": "이예린"
|
||||
},
|
||||
{
|
||||
"task": "일정 공유",
|
||||
"method": "메일",
|
||||
"frequency": "1회/매년",
|
||||
"assignee": "하주현"
|
||||
},
|
||||
{
|
||||
"task": "건강검진 실시확인서 (직장제출용) 수집",
|
||||
"method": "메일",
|
||||
"frequency": "1회/매년",
|
||||
"assignee": "하주현"
|
||||
},
|
||||
{
|
||||
"task": "건강검진 실시확인서 (직장제출용) 보관",
|
||||
"method": "",
|
||||
"frequency": "1회/매년",
|
||||
"assignee": "안현기"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "법정의무교육",
|
||||
"items": [
|
||||
{
|
||||
"task": "일정 공유",
|
||||
"method": "메일",
|
||||
"frequency": "1회 / 상,하반기",
|
||||
"assignee": "이예린"
|
||||
},
|
||||
{
|
||||
"task": "교육 미이수자 Follow-up",
|
||||
"method": "메일",
|
||||
"frequency": "1회 / 상,하반기",
|
||||
"assignee": "하주현"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
BIN
synology_deployment/web-ui/Minutes/인수인계관련/안전회의록 2025년 5월.xlsx
Normal file
BIN
synology_deployment/web-ui/Minutes/인수인계관련/안전회의록 2025년 5월.xlsx
Normal file
Binary file not shown.
Binary file not shown.
368
synology_deployment/web-ui/components/navbar.html
Normal file
368
synology_deployment/web-ui/components/navbar.html
Normal file
@@ -0,0 +1,368 @@
|
||||
<!-- components/navbar.html -->
|
||||
<!-- 프로필 드롭다운이 추가된 개선된 네비게이션바 -->
|
||||
<nav class="navbar">
|
||||
<div class="navbar-brand">
|
||||
<img src="/img/logo.png" alt="로고" class="logo-small">
|
||||
<div class="brand-content">
|
||||
<span class="brand-text">테크니컬코리아</span>
|
||||
<span class="brand-subtitle">생산팀 포털</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="navbar-center">
|
||||
<div class="current-time" id="current-time"></div>
|
||||
</div>
|
||||
|
||||
<div class="navbar-menu">
|
||||
<!-- 프로필 드롭다운 추가 -->
|
||||
<div class="profile-dropdown">
|
||||
<div class="user-info" id="user-info-dropdown">
|
||||
<div class="user-avatar">👤</div>
|
||||
<div class="user-details">
|
||||
<span class="user-name" id="user-name">사용자</span>
|
||||
<span class="user-role" id="user-role">작업자</span>
|
||||
</div>
|
||||
<span class="dropdown-arrow">▼</span>
|
||||
</div>
|
||||
|
||||
<!-- 드롭다운 메뉴 -->
|
||||
<div class="dropdown-menu" id="profile-dropdown-menu">
|
||||
<div class="dropdown-header">
|
||||
<div class="dropdown-user-name" id="dropdown-user-fullname">사용자</div>
|
||||
<div class="dropdown-user-id" id="dropdown-user-id">@username</div>
|
||||
</div>
|
||||
<div class="dropdown-divider"></div>
|
||||
<a href="/pages/profile/my-profile.html" class="dropdown-item">
|
||||
<span class="dropdown-icon">👤</span>
|
||||
내 프로필
|
||||
</a>
|
||||
<a href="/pages/profile/change-password.html" class="dropdown-item">
|
||||
<span class="dropdown-icon">🔐</span>
|
||||
비밀번호 변경
|
||||
</a>
|
||||
<a href="/pages/profile/admin-settings.html" class="dropdown-item admin-only">
|
||||
<span class="dropdown-icon">⚙️</span>
|
||||
관리자 설정
|
||||
</a>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item logout-item" id="dropdown-logout">
|
||||
<span class="dropdown-icon">🚪</span>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="navbar-buttons">
|
||||
<button class="nav-btn dashboard-btn" title="대시보드">
|
||||
🏠 대시보드
|
||||
</button>
|
||||
<button class="nav-btn system-btn" title="시스템 관리자" id="systemBtn" style="display: none;">
|
||||
🔧 시스템
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.navbar {
|
||||
background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%);
|
||||
padding: 12px 24px;
|
||||
color: white;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
font-family: 'Malgun Gothic', sans-serif;
|
||||
position: relative;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.logo-small {
|
||||
height: 40px;
|
||||
width: auto;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.brand-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.9;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.navbar-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.current-time {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
padding: 8px 16px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
border-radius: 20px;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
.navbar-menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* 프로필 드롭다운 스타일 */
|
||||
.profile-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
border-radius: 25px;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.user-info:hover {
|
||||
background: rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
.user-info.active {
|
||||
background: rgba(255,255,255,0.25);
|
||||
box-shadow: 0 0 0 3px rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
font-size: 0.7rem;
|
||||
margin-left: 4px;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.user-info.active .dropdown-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: rgba(255,255,255,0.2);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.user-role {
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.8;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* 드롭다운 메뉴 */
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.15);
|
||||
min-width: 240px;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(-10px);
|
||||
transition: all 0.3s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dropdown-menu.show {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.dropdown-header {
|
||||
padding: 16px 20px;
|
||||
background: #f5f5f5;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.dropdown-user-name {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.dropdown-user-id {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.dropdown-divider {
|
||||
height: 1px;
|
||||
background: #e0e0e0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
color: #333;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s ease;
|
||||
border: none;
|
||||
background: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.dropdown-item:active {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
font-size: 1.1rem;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logout-item {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.logout-item:hover {
|
||||
background: #ffebee;
|
||||
}
|
||||
|
||||
/* 기존 버튼 스타일 */
|
||||
.navbar-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.dashboard-btn {
|
||||
background: rgba(255,255,255,0.15);
|
||||
color: white;
|
||||
border: 1px solid rgba(255,255,255,0.3);
|
||||
}
|
||||
|
||||
.dashboard-btn:hover {
|
||||
background: rgba(255,255,255,0.25);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
|
||||
.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 {
|
||||
padding: 12px 20px;
|
||||
}
|
||||
|
||||
.navbar-center {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.navbar {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.brand-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar-menu {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
right: -16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.dashboard-btn, .admin-btn, .system-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,49 @@
|
||||
<!-- ✅ /components/sections/admin-sections.html -->
|
||||
<meta charset="UTF-8">
|
||||
|
||||
<section>
|
||||
<h2>📄 작업 보고서</h2>
|
||||
<ul>
|
||||
<li><a href="/pages/work-reports/work-report-create.html">작업보고서 입력</a></li>
|
||||
<li><a href="/pages/work-reports/work-report-manage.html">작업보고서 관리</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>📊 출근/공수 관리</h2>
|
||||
<ul>
|
||||
<li><a href="/pages/common/attendance.html">출근부</a></li>
|
||||
<li><a href="/pages/work-reports/project-labor-summary.html">프로젝트별 공수 계산</a></li>
|
||||
<li><a href="/pages/work-reports/monthly-labor-report.html">월간 공수 보고서</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>🔧 시스템 관리</h2>
|
||||
<ul>
|
||||
<li><a href="/pages/admin/manage-user.html">👤 사용자 관리</a></li>
|
||||
<li><a href="/pages/admin/manage-project.html">📁 프로젝트 관리</a></li>
|
||||
<li><a href="/pages/admin/manage-worker.html">👷 작업자 관리</a></li>
|
||||
<li><a href="/pages/admin/manage-task.html">📋 작업 유형 관리</a></li>
|
||||
<li><a href="/pages/admin/manage-issue.html">🚨 이슈 유형 관리</a></li>
|
||||
<li><a href="/pages/admin/manage-pipespec.html">🔧 배관 스펙 관리</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>🏭 공장 정보</h2>
|
||||
<ul>
|
||||
<li><a href="/pages/common/factory-upload.html">공장 정보 등록</a></li>
|
||||
<li><a href="/pages/common/factory-view.html">공장 목록 보기</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>📊 이슈 리포트</h2>
|
||||
<ul>
|
||||
<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>
|
||||
136
synology_deployment/web-ui/components/sidebar.html
Normal file
136
synology_deployment/web-ui/components/sidebar.html
Normal file
@@ -0,0 +1,136 @@
|
||||
<!-- ✅ /components/sidebar.html -->
|
||||
<aside class="sidebar">
|
||||
<nav class="sidebar-nav">
|
||||
<!-- 일반 작업자 메뉴 -->
|
||||
<div class="menu-section worker-only">
|
||||
<h3 class="menu-title">👷 작업 메뉴</h3>
|
||||
<ul class="menu-list">
|
||||
<li><a href="/pages/work-reports/create.html">📝 작업 일보 작성</a></li>
|
||||
<li><a href="/pages/common/attendance.html">📋 출근부 확인</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 그룹장 메뉴 -->
|
||||
<div class="menu-section group-leader-only">
|
||||
<h3 class="menu-title">👨🏫 그룹장 메뉴</h3>
|
||||
<ul class="menu-list">
|
||||
<li><a href="/pages/issue-reports/daily-issue.html">📋 일일 이슈 보고</a></li>
|
||||
<li><a href="/pages/work-reports/team-reports.html">👥 팀 작업 관리</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 지원팀 메뉴 -->
|
||||
<div class="menu-section support-only">
|
||||
<h3 class="menu-title">🧑💼 지원팀 메뉴</h3>
|
||||
<ul class="menu-list">
|
||||
<li><a href="/pages/work-reports/create.html">📥 작업보고서 입력</a></li>
|
||||
<li><a href="/pages/work-reports/manage.html">🛠 작업보고서 관리</a></li>
|
||||
<li><a href="/pages/common/attendance.html">📊 전체 출근부</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 관리자 메뉴 -->
|
||||
<div class="menu-section admin-only">
|
||||
<h3 class="menu-title">🏢 관리자 메뉴</h3>
|
||||
<ul class="menu-list">
|
||||
<li><a href="/pages/admin/reports-dashboard.html">📈 리포트 대시보드</a></li>
|
||||
<li><a href="/pages/admin/system-logs.html">📋 시스템 로그</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 시스템 관리자 메뉴 -->
|
||||
<div class="menu-section system-only">
|
||||
<h3 class="menu-title">⚙️ 시스템 관리</h3>
|
||||
<ul class="menu-list">
|
||||
<li><a href="/pages/admin/manage-user.html">👤 사용자 관리</a></li>
|
||||
<li><a href="/pages/admin/manage-worker.html">👷 작업자 관리</a></li>
|
||||
<li><a href="/pages/admin/manage-project.html">📁 프로젝트 관리</a></li>
|
||||
<li><a href="/pages/admin/manage-task.html">📋 작업 유형 관리</a></li>
|
||||
<li><a href="/pages/admin/manage-issue.html">🚨 이슈 유형 관리</a></li>
|
||||
<li><a href="/pages/admin/manage-pipespec.html">🔧 배관 스펙 관리</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 공통 메뉴 (모든 사용자) -->
|
||||
<div class="menu-section">
|
||||
<h3 class="menu-title">📌 공통 메뉴</h3>
|
||||
<ul class="menu-list">
|
||||
<li><a href="/pages/common/factory-list.html">🏭 공장 정보</a></li>
|
||||
<li><a href="/pages/common/emergency-contacts.html">📞 비상 연락망</a></li>
|
||||
<li><a href="/pages/common/help.html">❓ 도움말</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
background: #1a237e;
|
||||
color: white;
|
||||
min-height: calc(100vh - 60px);
|
||||
overflow-y: auto;
|
||||
box-shadow: 2px 0 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.menu-section {
|
||||
margin-bottom: 24px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.menu-section:not(:last-child) {
|
||||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.menu-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin: 0 0 12px 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.menu-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.menu-list li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.menu-list a {
|
||||
display: block;
|
||||
color: rgba(255,255,255,0.9);
|
||||
text-decoration: none;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.menu-list a:hover {
|
||||
background: rgba(255,255,255,0.1);
|
||||
color: white;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.menu-list a:active {
|
||||
background: rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
/* 모바일 대응 */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
min-height: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
677
synology_deployment/web-ui/css/admin-settings.css
Normal file
677
synology_deployment/web-ui/css/admin-settings.css
Normal file
@@ -0,0 +1,677 @@
|
||||
/* admin-settings.css */
|
||||
|
||||
/* 기본 레이아웃 */
|
||||
.work-report-container {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
.work-report-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
text-align: center;
|
||||
padding: 3rem 2rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.work-report-header h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 1rem 0;
|
||||
text-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.work-report-header .subtitle {
|
||||
font-size: 1.1rem;
|
||||
opacity: 0.9;
|
||||
margin: 0;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.work-report-main {
|
||||
background: #f8f9fa;
|
||||
min-height: calc(100vh - 200px);
|
||||
padding-top: 2rem;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: #495057;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
margin: 0 2rem 2rem 2rem;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: white;
|
||||
color: #007bff;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.dashboard-main {
|
||||
padding: 0 2rem 2rem 2rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-title-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.title-icon {
|
||||
font-size: 2.25rem;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
font-size: 1rem;
|
||||
color: #666;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 설정 섹션 */
|
||||
.settings-section {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem 2rem;
|
||||
background: #f8f9fa;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
/* 사용자 컨테이너 */
|
||||
.users-container {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.users-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* 검색 박스 */
|
||||
.search-box {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem 0.75rem 2.5rem;
|
||||
border: 2px solid #e9ecef;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: #007bff;
|
||||
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 0.75rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 1rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* 필터 버튼 */
|
||||
.filter-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 2px solid #e9ecef;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
border-color: #007bff;
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: #007bff;
|
||||
border-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 사용자 테이블 */
|
||||
.users-table-container {
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.users-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.users-table th {
|
||||
background: #f8f9fa;
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
border-bottom: 2px solid #e9ecef;
|
||||
}
|
||||
|
||||
.users-table td {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.users-table tbody tr:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
/* 사용자 정보 */
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.user-avatar-small {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.user-details h4 {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.user-details p {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* 역할 배지 */
|
||||
.role-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.role-badge.admin {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.role-badge.leader {
|
||||
background: #fd7e14;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.role-badge.user {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 상태 배지 */
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-badge.active {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.status-badge.inactive {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
/* 액션 버튼 */
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.4rem 0.8rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.action-btn.edit {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-btn.edit:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
|
||||
.action-btn.delete {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-btn.delete:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.action-btn.toggle {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-btn.toggle:hover {
|
||||
background: #545b62;
|
||||
}
|
||||
|
||||
/* 빈 상태 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 2rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 모달 스타일 */
|
||||
.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: 12px;
|
||||
box-shadow: 0 20px 25px rgba(0, 0, 0, 0.1);
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-container.small {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem 2rem;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.modal-close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-close-btn:hover {
|
||||
background: #f8f9fa;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem 2rem;
|
||||
border-top: 1px solid #e9ecef;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
/* 폼 스타일 */
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 2px solid #e9ecef;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: #007bff;
|
||||
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
|
||||
}
|
||||
|
||||
.form-help {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* 삭제 경고 */
|
||||
.delete-warning {
|
||||
text-align: center;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.warning-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.delete-warning p {
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1rem;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
font-size: 0.9rem;
|
||||
color: #dc3545;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 버튼 스타일 */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #0056b3;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #545b62;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* 토스트 알림 */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
.toast {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
padding: 1rem 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-width: 300px;
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
.toast.success {
|
||||
border-left: 4px solid #28a745;
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
border-left: 4px solid #dc3545;
|
||||
}
|
||||
|
||||
.toast.warning {
|
||||
border-left: 4px solid #ffc107;
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
flex: 1;
|
||||
font-size: 0.9rem;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.toast-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.25rem;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 1024px) {
|
||||
.dashboard-main {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.users-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-main {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.users-table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.users-table {
|
||||
min-width: 600px;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
width: 95%;
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.filter-buttons {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
50
synology_deployment/web-ui/css/admin.css
Normal file
50
synology_deployment/web-ui/css/admin.css
Normal file
@@ -0,0 +1,50 @@
|
||||
body {
|
||||
font-family: 'Malgun Gothic', sans-serif;
|
||||
margin: 0;
|
||||
background: #f0f2f5;
|
||||
}
|
||||
.main-layout {
|
||||
display: flex;
|
||||
}
|
||||
#sidebar-container {
|
||||
width: 250px;
|
||||
background: #1a237e;
|
||||
color: white;
|
||||
min-height: 100vh;
|
||||
}
|
||||
#content-container {
|
||||
flex: 1;
|
||||
padding: 30px;
|
||||
}
|
||||
h1, h2 {
|
||||
color: #1976d2;
|
||||
}
|
||||
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);
|
||||
}
|
||||
883
synology_deployment/web-ui/css/attendance-validation.css
Normal file
883
synology_deployment/web-ui/css/attendance-validation.css
Normal file
@@ -0,0 +1,883 @@
|
||||
/* 근태 검증 관리 시스템 - 개선된 스타일 */
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* 뒤로가기 버튼 */
|
||||
.back-btn {
|
||||
background: rgba(255,255,255,0.95);
|
||||
color: #667eea;
|
||||
border: 3px solid #667eea;
|
||||
padding: 12px 24px;
|
||||
border-radius: 12px;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 3px 12px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
/* 페이지 헤더 */
|
||||
.page-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 16px;
|
||||
padding: 2.5rem;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 8px 32px rgba(102, 126, 234, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* 메인 카드 */
|
||||
.main-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.8);
|
||||
backdrop-filter: blur(10px);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.main-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.loading-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 3rem;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 캘린더 헤더 */
|
||||
.calendar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: between;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.nav-btn:hover {
|
||||
transform: translateY(-2px) scale(1.05);
|
||||
box-shadow: 0 6px 24px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
/* 월간 요약 */
|
||||
.summary-section {
|
||||
margin-bottom: 2rem;
|
||||
padding: 1.5rem;
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
.summary-title {
|
||||
text-align: center;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.summary-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.summary-card.normal {
|
||||
border-color: #10b981;
|
||||
}
|
||||
|
||||
.summary-card.warning {
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
.summary-card.error {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.summary-number {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.summary-card.normal .summary-number {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.summary-card.warning .summary-number {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.summary-card.error .summary-number {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* 요일 헤더 */
|
||||
.weekday-header {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.weekday {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.weekday.sunday {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.weekday.saturday {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
/* 캘린더 그리드 */
|
||||
.calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.calendar-day {
|
||||
position: relative;
|
||||
min-height: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
background: white;
|
||||
border: 2px solid #e2e8f0;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.calendar-day.hover-enabled:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.15);
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.calendar-day.selected {
|
||||
transform: scale(1.05);
|
||||
z-index: 10;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 8px 32px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.calendar-day.loading-state {
|
||||
background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%);
|
||||
border-color: #3b82f6;
|
||||
animation: loading-pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes loading-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
.calendar-day.error-state {
|
||||
background: linear-gradient(135deg, #fecaca 0%, #fca5a5 100%);
|
||||
border-color: #ef4444;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.calendar-day.normal {
|
||||
background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
|
||||
border-color: #10b981;
|
||||
color: #064e3b;
|
||||
}
|
||||
|
||||
.calendar-day.needs-review {
|
||||
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
|
||||
border-color: #f59e0b;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.calendar-day.missing {
|
||||
background: linear-gradient(135deg, #fecaca 0%, #fca5a5 100%);
|
||||
border-color: #ef4444;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.calendar-day.no-data {
|
||||
background: #f9fafb;
|
||||
border-color: #e5e7eb;
|
||||
color: #9ca3af;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.calendar-day.no-data::after {
|
||||
content: "클릭하여 확인";
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
font-size: 10px;
|
||||
color: #6b7280;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.calendar-day.no-data.hover-enabled:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.status-dot.pulse {
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.2); }
|
||||
}
|
||||
|
||||
.status-dot.normal {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.status-dot.warning {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.status-dot.error {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
/* 범례 */
|
||||
.legend {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.legend-dot.normal {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.legend-dot.warning {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.legend-dot.error {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
/* 빈 상태 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #374151;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-description {
|
||||
color: #6b7280;
|
||||
font-size: 1rem;
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 작업자 카드 */
|
||||
.worker-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 2px solid transparent;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.worker-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.worker-card.normal {
|
||||
background: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%);
|
||||
border-color: #10b981;
|
||||
}
|
||||
|
||||
.worker-card.needs-review {
|
||||
background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%);
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
.worker-card.missing {
|
||||
background: linear-gradient(135deg, #fef2f2 0%, #fecaca 100%);
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.worker-header {
|
||||
display: flex;
|
||||
justify-content: between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 2px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.worker-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.worker-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 1.25rem;
|
||||
color: #374151;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.worker-name {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.worker-id {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-size: 1.5rem;
|
||||
filter: drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
|
||||
/* 데이터 행 */
|
||||
.data-section {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.data-row {
|
||||
display: flex;
|
||||
justify-content: between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.data-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.data-label {
|
||||
font-weight: 600;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.data-value {
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.difference-positive {
|
||||
color: #dc2626;
|
||||
background: rgba(220, 38, 38, 0.1);
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.difference-negative {
|
||||
color: #2563eb;
|
||||
background: rgba(37, 99, 235, 0.1);
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* 버튼 */
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.3);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 24px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6b7280;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 16px rgba(107, 114, 128, 0.3);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #4b5563;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 0.875rem;
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.edit-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 0.75rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 16px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
/* 필터 */
|
||||
.filter-container {
|
||||
display: flex;
|
||||
justify-content: between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
background: white;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.filter-select:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
/* 모달 */
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.modal.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-50px) scale(0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 20px 20px 0 0;
|
||||
display: flex;
|
||||
justify-content: between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
cursor: pointer;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem;
|
||||
border-top: 2px solid #f0f0f0;
|
||||
background: #f8fafc;
|
||||
border-radius: 0 0 20px 20px;
|
||||
}
|
||||
|
||||
/* 폼 요소 */
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 700;
|
||||
color: #374151;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.form-textarea:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
/* 메시지 */
|
||||
.message {
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 1.5rem;
|
||||
font-weight: 600;
|
||||
border: 2px solid transparent;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.message.success {
|
||||
background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
|
||||
color: #065f46;
|
||||
border-color: #10b981;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background: linear-gradient(135deg, #fecaca 0%, #fca5a5 100%);
|
||||
color: #991b1b;
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.message.warning {
|
||||
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
|
||||
color: #92400e;
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
.message.loading {
|
||||
background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%);
|
||||
color: #1e40af;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
/* 로딩 스피너 */
|
||||
.loading-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid #e5e7eb;
|
||||
border-top: 3px solid #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 애니메이션 */
|
||||
.fade-in {
|
||||
animation: fadeInUp 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 반응형 디자인 */
|
||||
@media (max-width: 768px) {
|
||||
.summary-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.calendar-day {
|
||||
min-height: 60px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.worker-header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.legend {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 95%;
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
.main-card {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
padding: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.calendar-day {
|
||||
min-height: 50px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.summary-number {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.worker-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-body, .modal-footer {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
72
synology_deployment/web-ui/css/attendance.css
Normal file
72
synology_deployment/web-ui/css/attendance.css
Normal file
@@ -0,0 +1,72 @@
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 20px;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
h2 {
|
||||
text-align: center;
|
||||
color: #343a40;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.controls label {
|
||||
font-weight: bold;
|
||||
}
|
||||
.controls select,
|
||||
.controls button {
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ccc;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
}
|
||||
button#loadAttendance {
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
button#downloadPdf {
|
||||
background-color: #007BFF;
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
#attendanceTableContainer {
|
||||
max-height: 600px;
|
||||
overflow: auto;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 6px;
|
||||
text-align: center;
|
||||
background: white;
|
||||
}
|
||||
th {
|
||||
background: #f2f2f2;
|
||||
}
|
||||
.divider {
|
||||
border-left: 3px solid #333 !important;
|
||||
}
|
||||
tr.separator td {
|
||||
border-bottom: 2px solid #999;
|
||||
padding: 0;
|
||||
height: 4px;
|
||||
background: transparent;
|
||||
}
|
||||
.overtime-cell { background: #e9e5ff !important; }
|
||||
.leave { background: #f5e0d6 !important; }
|
||||
.holiday { background: #ffd6d6 !important; }
|
||||
.paid-leave { background: #d6f0ff !important; }
|
||||
.no-data { background: #ddd !important; }
|
||||
.overtime-sum { background: #e9e5ff !important; }
|
||||
259
synology_deployment/web-ui/css/common.css
Normal file
259
synology_deployment/web-ui/css/common.css
Normal file
@@ -0,0 +1,259 @@
|
||||
/* Common CSS - 공통 스타일 */
|
||||
|
||||
/* ========== 통일된 헤더 스타일 ========== */
|
||||
.work-report-container {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
.work-report-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
text-align: center;
|
||||
padding: 3rem 2rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.work-report-header h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 1rem 0;
|
||||
text-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.work-report-header .subtitle {
|
||||
font-size: 1.1rem;
|
||||
opacity: 0.9;
|
||||
margin: 0;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.work-report-main {
|
||||
background: #f8f9fa;
|
||||
min-height: calc(100vh - 200px);
|
||||
padding-top: 2rem;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: #495057;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
margin: 0 2rem 2rem 2rem;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: white;
|
||||
color: #007bff;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
/* Reset and Base Styles */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
h1 { font-size: 2rem; }
|
||||
h2 { font-size: 1.5rem; }
|
||||
h3 { font-size: 1.25rem; }
|
||||
h4 { font-size: 1.125rem; }
|
||||
h5 { font-size: 1rem; }
|
||||
h6 { font-size: 0.875rem; }
|
||||
|
||||
/* ========== 헤더 액션 버튼 ========== */
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.dashboard-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 1.2rem;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.dashboard-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.dashboard-btn .btn-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #2563eb;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #6b7280;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #4b5563;
|
||||
}
|
||||
|
||||
/* Utilities */
|
||||
.text-center { text-align: center; }
|
||||
.text-left { text-align: left; }
|
||||
.text-right { text-align: right; }
|
||||
|
||||
.mb-1 { margin-bottom: 0.25rem; }
|
||||
.mb-2 { margin-bottom: 0.5rem; }
|
||||
.mb-3 { margin-bottom: 0.75rem; }
|
||||
.mb-4 { margin-bottom: 1rem; }
|
||||
.mb-5 { margin-bottom: 1.25rem; }
|
||||
.mb-6 { margin-bottom: 1.5rem; }
|
||||
|
||||
.mt-1 { margin-top: 0.25rem; }
|
||||
.mt-2 { margin-top: 0.5rem; }
|
||||
.mt-3 { margin-top: 0.75rem; }
|
||||
.mt-4 { margin-top: 1rem; }
|
||||
.mt-5 { margin-top: 1.25rem; }
|
||||
.mt-6 { margin-top: 1.5rem; }
|
||||
|
||||
.p-1 { padding: 0.25rem; }
|
||||
.p-2 { padding: 0.5rem; }
|
||||
.p-3 { padding: 0.75rem; }
|
||||
.p-4 { padding: 1rem; }
|
||||
.p-5 { padding: 1.25rem; }
|
||||
.p-6 { padding: 1.5rem; }
|
||||
|
||||
/* Container */
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.25rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 3px solid #f3f3f3;
|
||||
border-top: 3px solid #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
h1 { font-size: 1.5rem; }
|
||||
h2 { font-size: 1.25rem; }
|
||||
h3 { font-size: 1.125rem; }
|
||||
}
|
||||
90
synology_deployment/web-ui/css/daily-issue.css
Normal file
90
synology_deployment/web-ui/css/daily-issue.css
Normal file
@@ -0,0 +1,90 @@
|
||||
/* /css/daily-issue.css */
|
||||
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
background: #f5f7fa;
|
||||
margin: 0;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 500px;
|
||||
margin: auto;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.05);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
text-align: center;
|
||||
color: #333;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-top: 20px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
select, input[type="date"], button {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
margin-top: 6px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
button#submitBtn {
|
||||
margin-top: 30px;
|
||||
background: #1976d2;
|
||||
color: white;
|
||||
border: none;
|
||||
font-size: 1rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
button#submitBtn:hover {
|
||||
background: #125cb1;
|
||||
}
|
||||
|
||||
.multi-select-box {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.multi-select-box .btn {
|
||||
flex: 1 0 30%;
|
||||
padding: 8px;
|
||||
border: 1px solid #1976d2;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
color: #1976d2;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.multi-select-box .btn.selected {
|
||||
background: #1976d2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.time-range {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.time-range select {
|
||||
flex: 1;
|
||||
}
|
||||
548
synology_deployment/web-ui/css/daily-report-viewer.css
Normal file
548
synology_deployment/web-ui/css/daily-report-viewer.css
Normal file
@@ -0,0 +1,548 @@
|
||||
/* daily-report-viewer.css */
|
||||
|
||||
/* 전체 레이아웃 */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* 헤더 */
|
||||
.page-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #666;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* 날짜 선택기 */
|
||||
.date-selector {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 15px;
|
||||
padding: 25px;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.date-input-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.date-input-group label {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.date-input {
|
||||
padding: 12px 16px;
|
||||
border: 2px solid #e1e5e9;
|
||||
border-radius: 10px;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
background: white;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.date-input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.search-btn, .today-btn {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.search-btn {
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.search-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.today-btn {
|
||||
background: #f8f9fa;
|
||||
color: #667eea;
|
||||
border: 2px solid #667eea;
|
||||
}
|
||||
|
||||
.today-btn:hover {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 로딩 스피너 */
|
||||
.loading-spinner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 50px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 15px;
|
||||
margin: 30px 0;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #667eea;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.loading-spinner p {
|
||||
color: #666;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* 에러 메시지 */
|
||||
.error-message {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 15px;
|
||||
padding: 25px;
|
||||
margin: 30px 0;
|
||||
border-left: 5px solid #e74c3c;
|
||||
}
|
||||
|
||||
.error-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #e74c3c;
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* 데이터 없음 메시지 */
|
||||
.no-data-message {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 15px;
|
||||
padding: 50px;
|
||||
text-align: center;
|
||||
margin: 30px 0;
|
||||
}
|
||||
|
||||
.no-data-content {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.no-data-icon {
|
||||
font-size: 3rem;
|
||||
display: block;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.no-data-content h3 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 10px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* 요약 카드 */
|
||||
.report-summary {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.summary-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 15px;
|
||||
padding: 25px;
|
||||
text-align: center;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.summary-card:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.summary-card.error-card {
|
||||
border-left: 5px solid #e74c3c;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.card-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.error-card .card-value {
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
/* 작업자 리포트 */
|
||||
.workers-report {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 15px;
|
||||
padding: 30px;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 25px;
|
||||
color: #333;
|
||||
border-bottom: 3px solid #667eea;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.workers-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.worker-card {
|
||||
background: #f8f9fa;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
border-left: 5px solid #667eea;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.worker-card:hover {
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
.worker-header {
|
||||
display: flex;
|
||||
justify-content: between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.worker-name {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.worker-total-hours {
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.work-entries {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.work-entry {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
border: 1px solid #e1e5e9;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.work-entry:hover {
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 3px 10px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.work-entry.error-entry {
|
||||
border-left: 4px solid #e74c3c;
|
||||
background: #fff5f5;
|
||||
}
|
||||
|
||||
.entry-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.project-name {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.work-hours {
|
||||
background: #e8f4f8;
|
||||
color: #2c5aa0;
|
||||
padding: 4px 12px;
|
||||
border-radius: 15px;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.work-entry.error-entry .work-hours {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.entry-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.entry-detail {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.error-type {
|
||||
color: #e74c3c;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 내보내기 섹션 */
|
||||
.export-section {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 15px;
|
||||
padding: 25px;
|
||||
text-align: center;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.export-section h3 {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.export-buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 15px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.export-btn {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.excel-btn {
|
||||
background: #217346;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.excel-btn:hover {
|
||||
background: #1a5a37;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.print-btn {
|
||||
background: #495057;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.print-btn:hover {
|
||||
background: #343a40;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* 반응형 디자인 */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.date-input-group {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.date-input-group > * {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.summary-cards {
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.work-entries {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.worker-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.export-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* 인쇄 스타일 */
|
||||
@media print {
|
||||
body {
|
||||
background: white;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.date-selector,
|
||||
.export-section {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.summary-card,
|
||||
.workers-report,
|
||||
.worker-card,
|
||||
.work-entry {
|
||||
background: white !important;
|
||||
box-shadow: none !important;
|
||||
border: 1px solid #ddd !important;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
background: white !important;
|
||||
box-shadow: none !important;
|
||||
border-bottom: 2px solid #333;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
color: #333 !important;
|
||||
-webkit-text-fill-color: #333 !important;
|
||||
}
|
||||
}
|
||||
1376
synology_deployment/web-ui/css/daily-work-report.css
Normal file
1376
synology_deployment/web-ui/css/daily-work-report.css
Normal file
File diff suppressed because it is too large
Load Diff
452
synology_deployment/web-ui/css/design-system.css
Normal file
452
synology_deployment/web-ui/css/design-system.css
Normal file
@@ -0,0 +1,452 @@
|
||||
/* ✅ design-system.css - 한글 기반 모던 디자인 시스템 */
|
||||
|
||||
/* ========== 색상 시스템 ========== */
|
||||
:root {
|
||||
/* 주요 브랜드 색상 */
|
||||
--primary-50: #e3f2fd;
|
||||
--primary-100: #bbdefb;
|
||||
--primary-200: #90caf9;
|
||||
--primary-300: #64b5f6;
|
||||
--primary-400: #42a5f5;
|
||||
--primary-500: #2196f3;
|
||||
--primary-600: #1e88e5;
|
||||
--primary-700: #1976d2;
|
||||
--primary-800: #1565c0;
|
||||
--primary-900: #0d47a1;
|
||||
|
||||
/* 보조 색상 */
|
||||
--secondary-50: #f3e5f5;
|
||||
--secondary-100: #e1bee7;
|
||||
--secondary-200: #ce93d8;
|
||||
--secondary-300: #ba68c8;
|
||||
--secondary-400: #ab47bc;
|
||||
--secondary-500: #9c27b0;
|
||||
--secondary-600: #8e24aa;
|
||||
--secondary-700: #7b1fa2;
|
||||
--secondary-800: #6a1b9a;
|
||||
--secondary-900: #4a148c;
|
||||
|
||||
/* 그레이 스케일 */
|
||||
--gray-50: #fafafa;
|
||||
--gray-100: #f5f5f5;
|
||||
--gray-200: #eeeeee;
|
||||
--gray-300: #e0e0e0;
|
||||
--gray-400: #bdbdbd;
|
||||
--gray-500: #9e9e9e;
|
||||
--gray-600: #757575;
|
||||
--gray-700: #616161;
|
||||
--gray-800: #424242;
|
||||
--gray-900: #212121;
|
||||
|
||||
/* 상태 색상 */
|
||||
--success-50: #e8f5e8;
|
||||
--success-500: #4caf50;
|
||||
--success-700: #388e3c;
|
||||
|
||||
--warning-50: #fff8e1;
|
||||
--warning-500: #ff9800;
|
||||
--warning-700: #f57c00;
|
||||
|
||||
--error-50: #ffebee;
|
||||
--error-500: #f44336;
|
||||
--error-700: #d32f2f;
|
||||
|
||||
--info-50: #e1f5fe;
|
||||
--info-500: #03a9f4;
|
||||
--info-700: #0288d1;
|
||||
|
||||
/* 배경 색상 */
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f8fafc;
|
||||
--bg-tertiary: #f1f5f9;
|
||||
--bg-overlay: rgba(0, 0, 0, 0.5);
|
||||
|
||||
/* 텍스트 색상 */
|
||||
--text-primary: #1a202c;
|
||||
--text-secondary: #4a5568;
|
||||
--text-tertiary: #718096;
|
||||
--text-inverse: #ffffff;
|
||||
|
||||
/* 경계선 */
|
||||
--border-light: #e2e8f0;
|
||||
--border-medium: #cbd5e0;
|
||||
--border-dark: #a0aec0;
|
||||
|
||||
/* 그림자 */
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
|
||||
/* 반경 */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* 간격 */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
--space-10: 40px;
|
||||
--space-12: 48px;
|
||||
--space-16: 64px;
|
||||
--space-20: 80px;
|
||||
--space-24: 96px;
|
||||
|
||||
/* 폰트 크기 */
|
||||
--text-xs: 12px;
|
||||
--text-sm: 14px;
|
||||
--text-base: 16px;
|
||||
--text-lg: 18px;
|
||||
--text-xl: 20px;
|
||||
--text-2xl: 24px;
|
||||
--text-3xl: 30px;
|
||||
--text-4xl: 36px;
|
||||
--text-5xl: 48px;
|
||||
|
||||
/* 폰트 두께 */
|
||||
--font-light: 300;
|
||||
--font-normal: 400;
|
||||
--font-medium: 500;
|
||||
--font-semibold: 600;
|
||||
--font-bold: 700;
|
||||
--font-extrabold: 800;
|
||||
|
||||
/* 애니메이션 */
|
||||
--transition-fast: 150ms ease-in-out;
|
||||
--transition-normal: 250ms ease-in-out;
|
||||
--transition-slow: 350ms ease-in-out;
|
||||
}
|
||||
|
||||
/* ========== 기본 리셋 ========== */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: 'Pretendard', 'Malgun Gothic', 'Apple SD Gothic Neo', system-ui, sans-serif;
|
||||
font-size: var(--text-base);
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-secondary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* ========== 타이포그래피 ========== */
|
||||
.text-xs { font-size: var(--text-xs); }
|
||||
.text-sm { font-size: var(--text-sm); }
|
||||
.text-base { font-size: var(--text-base); }
|
||||
.text-lg { font-size: var(--text-lg); }
|
||||
.text-xl { font-size: var(--text-xl); }
|
||||
.text-2xl { font-size: var(--text-2xl); }
|
||||
.text-3xl { font-size: var(--text-3xl); }
|
||||
.text-4xl { font-size: var(--text-4xl); }
|
||||
.text-5xl { font-size: var(--text-5xl); }
|
||||
|
||||
.font-light { font-weight: var(--font-light); }
|
||||
.font-normal { font-weight: var(--font-normal); }
|
||||
.font-medium { font-weight: var(--font-medium); }
|
||||
.font-semibold { font-weight: var(--font-semibold); }
|
||||
.font-bold { font-weight: var(--font-bold); }
|
||||
.font-extrabold { font-weight: var(--font-extrabold); }
|
||||
|
||||
.text-primary { color: var(--text-primary); }
|
||||
.text-secondary { color: var(--text-secondary); }
|
||||
.text-tertiary { color: var(--text-tertiary); }
|
||||
.text-inverse { color: var(--text-inverse); }
|
||||
|
||||
/* ========== 카드 컴포넌트 ========== */
|
||||
.card {
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--border-light);
|
||||
transition: var(--transition-normal);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: var(--space-6);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
padding: var(--space-6);
|
||||
border-top: 1px solid var(--border-light);
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
|
||||
}
|
||||
|
||||
/* ========== 버튼 컴포넌트 ========== */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
border-radius: var(--radius-md);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary-500);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--primary-600);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--gray-100);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-medium);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--gray-200);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: var(--success-500);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.btn-success:hover:not(:disabled) {
|
||||
background: var(--success-700);
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: var(--warning-500);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.btn-warning:hover:not(:disabled) {
|
||||
background: var(--warning-700);
|
||||
}
|
||||
|
||||
.btn-error {
|
||||
background: var(--error-500);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.btn-error:hover:not(:disabled) {
|
||||
background: var(--error-700);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: var(--space-4) var(--space-6);
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
/* ========== 배지 컴포넌트 ========== */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-medium);
|
||||
border-radius: var(--radius-full);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-primary {
|
||||
background: var(--primary-100);
|
||||
color: var(--primary-800);
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: var(--success-50);
|
||||
color: var(--success-700);
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: var(--warning-50);
|
||||
color: var(--warning-700);
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
background: var(--error-50);
|
||||
color: var(--error-700);
|
||||
}
|
||||
|
||||
.badge-gray {
|
||||
background: var(--gray-100);
|
||||
color: var(--gray-700);
|
||||
}
|
||||
|
||||
/* ========== 상태 표시기 ========== */
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: var(--radius-full);
|
||||
margin-right: var(--space-2);
|
||||
}
|
||||
|
||||
.status-dot.active {
|
||||
background: var(--success-500);
|
||||
box-shadow: 0 0 0 2px var(--success-100);
|
||||
}
|
||||
|
||||
.status-dot.inactive {
|
||||
background: var(--gray-400);
|
||||
}
|
||||
|
||||
.status-dot.warning {
|
||||
background: var(--warning-500);
|
||||
box-shadow: 0 0 0 2px var(--warning-100);
|
||||
}
|
||||
|
||||
.status-dot.error {
|
||||
background: var(--error-500);
|
||||
box-shadow: 0 0 0 2px var(--error-100);
|
||||
}
|
||||
|
||||
/* ========== 그리드 시스템 ========== */
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.grid-cols-1 { grid-template-columns: repeat(1, 1fr); }
|
||||
.grid-cols-2 { grid-template-columns: repeat(2, 1fr); }
|
||||
.grid-cols-3 { grid-template-columns: repeat(3, 1fr); }
|
||||
.grid-cols-4 { grid-template-columns: repeat(4, 1fr); }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.grid-cols-2,
|
||||
.grid-cols-3,
|
||||
.grid-cols-4 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 플렉스 유틸리티 ========== */
|
||||
.flex { display: flex; }
|
||||
.flex-col { flex-direction: column; }
|
||||
.items-center { align-items: center; }
|
||||
.items-start { align-items: flex-start; }
|
||||
.items-end { align-items: flex-end; }
|
||||
.justify-center { justify-content: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.justify-start { justify-content: flex-start; }
|
||||
.justify-end { justify-content: flex-end; }
|
||||
.gap-1 { gap: var(--space-1); }
|
||||
.gap-2 { gap: var(--space-2); }
|
||||
.gap-3 { gap: var(--space-3); }
|
||||
.gap-4 { gap: var(--space-4); }
|
||||
.gap-6 { gap: var(--space-6); }
|
||||
|
||||
/* ========== 간격 유틸리티 ========== */
|
||||
.p-1 { padding: var(--space-1); }
|
||||
.p-2 { padding: var(--space-2); }
|
||||
.p-3 { padding: var(--space-3); }
|
||||
.p-4 { padding: var(--space-4); }
|
||||
.p-6 { padding: var(--space-6); }
|
||||
.p-8 { padding: var(--space-8); }
|
||||
|
||||
.m-1 { margin: var(--space-1); }
|
||||
.m-2 { margin: var(--space-2); }
|
||||
.m-3 { margin: var(--space-3); }
|
||||
.m-4 { margin: var(--space-4); }
|
||||
.m-6 { margin: var(--space-6); }
|
||||
.m-8 { margin: var(--space-8); }
|
||||
|
||||
.mb-2 { margin-bottom: var(--space-2); }
|
||||
.mb-4 { margin-bottom: var(--space-4); }
|
||||
.mb-6 { margin-bottom: var(--space-6); }
|
||||
.mt-4 { margin-top: var(--space-4); }
|
||||
.mt-6 { margin-top: var(--space-6); }
|
||||
|
||||
/* ========== 반응형 유틸리티 ========== */
|
||||
@media (max-width: 640px) {
|
||||
.sm\:hidden { display: none; }
|
||||
.sm\:text-sm { font-size: var(--text-sm); }
|
||||
.sm\:p-4 { padding: var(--space-4); }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.md\:hidden { display: none; }
|
||||
.md\:flex-col { flex-direction: column; }
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.lg\:hidden { display: none; }
|
||||
}
|
||||
|
||||
/* ========== 애니메이션 ========== */
|
||||
.fade-in {
|
||||
animation: fadeIn var(--transition-normal) ease-in-out;
|
||||
}
|
||||
|
||||
.slide-up {
|
||||
animation: slideUp var(--transition-normal) ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 로딩 스피너 ========== */
|
||||
.spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--gray-200);
|
||||
border-top: 2px solid var(--primary-500);
|
||||
border-radius: var(--radius-full);
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
61
synology_deployment/web-ui/css/factory.css
Normal file
61
synology_deployment/web-ui/css/factory.css
Normal file
@@ -0,0 +1,61 @@
|
||||
body {
|
||||
font-family: 'Segoe UI', sans-serif;
|
||||
background-color: #f9f9f9;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 50px auto;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
form label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: bold;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
form input[type="text"],
|
||||
form input[type="file"],
|
||||
form textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
form textarea {
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px;
|
||||
font-size: 16px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
54
synology_deployment/web-ui/css/login.css
Normal file
54
synology_deployment/web-ui/css/login.css
Normal file
@@ -0,0 +1,54 @@
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: url('/img/login-bg.jpeg') no-repeat center center fixed;
|
||||
background-size: cover;
|
||||
font-family: 'Malgun Gothic', sans-serif;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
width: 400px;
|
||||
padding: 40px;
|
||||
margin: 100px auto;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
color: white;
|
||||
box-shadow: 0 0 20px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 200px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 15px 0;
|
||||
padding: 12px;
|
||||
font-size: 1rem;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 12px 20px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background-color: #1976d2;
|
||||
color: white;
|
||||
border-radius: 6px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: #1565c0;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-top: 10px;
|
||||
color: #ff6b6b;
|
||||
font-weight: bold;
|
||||
}
|
||||
160
synology_deployment/web-ui/css/main-layout.css
Normal file
160
synology_deployment/web-ui/css/main-layout.css
Normal file
@@ -0,0 +1,160 @@
|
||||
/* ✅ /css/main-layout.css - 공통 레이아웃 스타일 */
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
/* 메인 레이아웃 구조 */
|
||||
.main-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#navbar-container {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#sidebar-container {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#content-container,
|
||||
#sections-container,
|
||||
#admin-sections,
|
||||
#user-sections {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 카드 스타일 */
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
/* 섹션 스타일 */
|
||||
section {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
section h2 {
|
||||
font-size: 18px;
|
||||
margin: 0 0 16px 0;
|
||||
color: #333;
|
||||
border-bottom: 2px solid #1976d2;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
section ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
section li {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
section li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
section a {
|
||||
color: #1976d2;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
padding: 4px 0;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
section a:hover {
|
||||
color: #0d47a1;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
/* 로딩 상태 */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.loading::after {
|
||||
content: '.';
|
||||
animation: dots 1.5s steps(3, end) infinite;
|
||||
}
|
||||
|
||||
@keyframes dots {
|
||||
0%, 20% { content: '.'; }
|
||||
40% { content: '..'; }
|
||||
60%, 100% { content: '...'; }
|
||||
}
|
||||
|
||||
/* 에러 상태 */
|
||||
.error-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #d32f2f;
|
||||
}
|
||||
|
||||
.error-state button {
|
||||
margin-top: 16px;
|
||||
padding: 8px 24px;
|
||||
background: #1976d2;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.error-state button:hover {
|
||||
background: #1565c0;
|
||||
}
|
||||
|
||||
/* 반응형 디자인 */
|
||||
@media (max-width: 1024px) {
|
||||
#content-container,
|
||||
#sections-container {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content-wrapper {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#sidebar-container {
|
||||
order: -1;
|
||||
}
|
||||
|
||||
section h2 {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
1005
synology_deployment/web-ui/css/management-dashboard.css
Normal file
1005
synology_deployment/web-ui/css/management-dashboard.css
Normal file
File diff suppressed because it is too large
Load Diff
1898
synology_deployment/web-ui/css/modern-dashboard.css
Normal file
1898
synology_deployment/web-ui/css/modern-dashboard.css
Normal file
File diff suppressed because it is too large
Load Diff
1589
synology_deployment/web-ui/css/project-management.css
Normal file
1589
synology_deployment/web-ui/css/project-management.css
Normal file
File diff suppressed because it is too large
Load Diff
953
synology_deployment/web-ui/css/system-dashboard.css
Normal file
953
synology_deployment/web-ui/css/system-dashboard.css
Normal file
@@ -0,0 +1,953 @@
|
||||
/* 시스템 대시보드 전용 스타일 */
|
||||
|
||||
/* 시스템 대시보드 배경 - 깔끔한 흰색 */
|
||||
.main-layout .content-wrapper {
|
||||
background: #ffffff;
|
||||
min-height: calc(100vh - 80px);
|
||||
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;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
color: #333;
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.8rem;
|
||||
font-weight: 500;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
/* 시스템 배지 */
|
||||
|
||||
.system-badge {
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border: 1px solid #c0392b;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
background: #c0392b;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 15px rgba(231, 76, 60, 0.3);
|
||||
}
|
||||
|
||||
/* 메인 컨텐츠 */
|
||||
.main-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 시스템 상태 개요 */
|
||||
.system-overview {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.system-overview h2 {
|
||||
color: #2c3e50;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border-bottom: 1px solid #ecf0f1;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
transition: box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.status-card:hover {
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
background: linear-gradient(45deg, #3498db, #2980b9);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-info h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #2c3e50;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-value {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.status-value.online {
|
||||
color: #27ae60;
|
||||
}
|
||||
|
||||
.status-value.warning {
|
||||
color: #f39c12;
|
||||
}
|
||||
|
||||
.status-value.error {
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.status-info small {
|
||||
color: #7f8c8d;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* 관리 섹션 */
|
||||
.management-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.management-section h2 {
|
||||
color: #2c3e50;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border-bottom: 1px solid #ecf0f1;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.management-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.management-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
transition: box-shadow 0.2s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.management-card:hover {
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.management-card.primary {
|
||||
border-left: 4px solid #3498db;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.card-header i {
|
||||
font-size: 1.2rem;
|
||||
color: #3498db;
|
||||
}
|
||||
|
||||
.card-header h3 {
|
||||
margin: 0;
|
||||
color: #2c3e50;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.card-content p {
|
||||
color: #7f8c8d;
|
||||
margin-bottom: 1.5rem;
|
||||
line-height: 1.5;
|
||||
font-size: 0.9rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 0.8rem;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.6rem 1rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: #ffffff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3498db;
|
||||
color: white;
|
||||
border-color: #2980b9;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2980b9;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #95a5a6;
|
||||
color: white;
|
||||
border-color: #7f8c8d;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #7f8c8d;
|
||||
}
|
||||
|
||||
/* 최근 활동 */
|
||||
.recent-activity {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.recent-activity h2 {
|
||||
color: white;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 1.8rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.activity-container {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.activity-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.activity-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.activity-item:hover {
|
||||
background: rgba(52, 152, 219, 0.05);
|
||||
}
|
||||
|
||||
.activity-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.activity-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(45deg, #3498db, #2980b9);
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.activity-info h4 {
|
||||
margin: 0 0 0.3rem 0;
|
||||
color: #2c3e50;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.activity-info p {
|
||||
margin: 0;
|
||||
color: #7f8c8d;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.activity-time {
|
||||
margin-left: auto;
|
||||
color: #95a5a6;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* 모달 */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 2000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
margin: 5% auto;
|
||||
padding: 0;
|
||||
border-radius: 16px;
|
||||
width: 90%;
|
||||
max-width: 800px;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
background: linear-gradient(45deg, #3498db, #2980b9);
|
||||
color: white;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 2rem;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 반응형 디자인 */
|
||||
@media (max-width: 1200px) {
|
||||
.status-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
}
|
||||
|
||||
.management-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.main-layout .content-wrapper {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.status-grid,
|
||||
.management-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.status-card,
|
||||
.management-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 95%;
|
||||
margin: 5% auto;
|
||||
}
|
||||
|
||||
.system-overview h2,
|
||||
.management-section h2,
|
||||
.recent-activity h2 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.main-layout .content-wrapper {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.status-card,
|
||||
.management-card {
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 0.8rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* 계정 관리 스타일 */
|
||||
.account-management {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.account-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 2px solid #ecf0f1;
|
||||
}
|
||||
|
||||
.account-header h4 {
|
||||
margin: 0;
|
||||
color: #2c3e50;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.account-filters {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.account-filters input,
|
||||
.account-filters select {
|
||||
padding: 0.7rem;
|
||||
border: 2px solid #ecf0f1;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
transition: border-color 0.3s ease;
|
||||
}
|
||||
|
||||
.account-filters input:focus,
|
||||
.account-filters select:focus {
|
||||
outline: none;
|
||||
border-color: #3498db;
|
||||
}
|
||||
|
||||
.users-table {
|
||||
overflow-x: auto;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.users-table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.users-table th,
|
||||
.users-table td {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #ecf0f1;
|
||||
}
|
||||
|
||||
.users-table th {
|
||||
background: linear-gradient(45deg, #3498db, #2980b9);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.users-table tr:hover {
|
||||
background: rgba(52, 152, 219, 0.05);
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
padding: 0.3rem 0.8rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.role-badge.role-system {
|
||||
background: linear-gradient(45deg, #e74c3c, #c0392b);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.role-badge.role-admin {
|
||||
background: linear-gradient(45deg, #f39c12, #e67e22);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.role-badge.role-leader {
|
||||
background: linear-gradient(45deg, #9b59b6, #8e44ad);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.role-badge.role-user {
|
||||
background: linear-gradient(45deg, #95a5a6, #7f8c8d);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 0.3rem 0.8rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-badge.active {
|
||||
background: linear-gradient(45deg, #27ae60, #2ecc71);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge.inactive {
|
||||
background: linear-gradient(45deg, #e74c3c, #c0392b);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
background: linear-gradient(45deg, #3498db, #2980b9);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-edit:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.3);
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: linear-gradient(45deg, #e74c3c, #c0392b);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(231, 76, 60, 0.3);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #7f8c8d;
|
||||
}
|
||||
|
||||
.loading-spinner i {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.error-message i {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-message p {
|
||||
margin: 1rem 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* 알림 스타일 */
|
||||
.notification {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
z-index: 3000;
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
.notification-info {
|
||||
background: linear-gradient(45deg, #3498db, #2980b9);
|
||||
}
|
||||
|
||||
.notification-success {
|
||||
background: linear-gradient(45deg, #27ae60, #2ecc71);
|
||||
}
|
||||
|
||||
.notification-warning {
|
||||
background: linear-gradient(45deg, #f39c12, #e67e22);
|
||||
}
|
||||
|
||||
.notification-error {
|
||||
background: linear-gradient(45deg, #e74c3c, #c0392b);
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 사용자 폼 스타일 */
|
||||
.user-edit-form,
|
||||
.user-create-form {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.user-edit-form h4,
|
||||
.user-create-form h4 {
|
||||
margin: 0 0 2rem 0;
|
||||
color: #2c3e50;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #2c3e50;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 0.8rem;
|
||||
border: 2px solid #ecf0f1;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
transition: border-color 0.3s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #3498db;
|
||||
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1);
|
||||
}
|
||||
|
||||
.form-group input:disabled {
|
||||
background: #f8f9fa;
|
||||
color: #6c757d;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 2rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 2px solid #ecf0f1;
|
||||
}
|
||||
|
||||
/* 스크롤바 스타일링 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(45deg, #3498db, #2980b9);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(45deg, #2980b9, #3498db);
|
||||
}
|
||||
57
synology_deployment/web-ui/css/user.css
Normal file
57
synology_deployment/web-ui/css/user.css
Normal file
@@ -0,0 +1,57 @@
|
||||
body {
|
||||
font-family: 'Malgun Gothic', sans-serif;
|
||||
background-color: #f5f5f5;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
header {
|
||||
background: linear-gradient(135deg, #4caf50 0%, #45a049 100%);
|
||||
color: white;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 30px;
|
||||
max-width: 1200px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.quick-menu {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: #333;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background: #e8f5e9;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
1687
synology_deployment/web-ui/css/work-analysis.css
Normal file
1687
synology_deployment/web-ui/css/work-analysis.css
Normal file
File diff suppressed because it is too large
Load Diff
612
synology_deployment/web-ui/css/work-management.css
Normal file
612
synology_deployment/web-ui/css/work-management.css
Normal file
@@ -0,0 +1,612 @@
|
||||
/* 작업 관리 페이지 스타일 */
|
||||
|
||||
/* 기본 레이아웃 */
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* 헤더 스타일 */
|
||||
.dashboard-header {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
padding: 1rem 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 2px 20px rgba(0, 0, 0, 0.1);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 40px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.company-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.company-name {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.company-subtitle {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.current-time {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.time-label {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
.time-value {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.dashboard-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: #374151;
|
||||
text-decoration: none;
|
||||
border-radius: 1.25rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.dashboard-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
transform: translateY(-1px);
|
||||
text-decoration: none;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.user-profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 2rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.user-profile:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
background: linear-gradient(135deg, #3b82f6, #1d4ed8);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.user-role {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* 메인 콘텐츠 */
|
||||
.dashboard-main {
|
||||
flex: 1;
|
||||
padding: 2rem;
|
||||
min-height: calc(100vh - 80px);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-title-section {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
margin: 0 0 0.5rem 0;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.title-icon {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
font-size: 1.125rem;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin: 0;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* 관리 메뉴 그리드 */
|
||||
.management-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.management-card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.management-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4px;
|
||||
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
|
||||
transform: scaleX(0);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.management-card:hover::before {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
.management-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
font-size: 2rem;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #f3f4f6, #e5e7eb);
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.card-description {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.card-stats {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.card-action {
|
||||
font-size: 0.875rem;
|
||||
color: #3b82f6;
|
||||
font-weight: 500;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.management-card:hover .card-action {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
/* 최근 활동 섹션 */
|
||||
.recent-activity-section {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.refresh-btn:hover {
|
||||
background: #2563eb;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.activity-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.activity-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: #f8fafc;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.activity-item:hover {
|
||||
background: #f1f5f9;
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
|
||||
.activity-icon {
|
||||
font-size: 1.25rem;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.activity-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.activity-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #1f2937;
|
||||
margin-bottom: 0.25rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.activity-meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.activity-user {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 반응형 디자인 */
|
||||
@media (max-width: 1024px) {
|
||||
.dashboard-header {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.dashboard-main {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.management-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-center {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.company-info {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dashboard-btn .btn-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.management-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.dashboard-header {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.dashboard-main {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.management-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.recent-activity-section {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 빠른 액세스 섹션 ========== */
|
||||
.quick-access-section {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.quick-actions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 1rem;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.quick-action-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1.5rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 2px solid rgba(59, 130, 246, 0.1);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
min-height: 120px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.quick-action-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(59, 130, 246, 0.15);
|
||||
border-color: rgba(59, 130, 246, 0.3);
|
||||
background: rgba(255, 255, 255, 1);
|
||||
}
|
||||
|
||||
.quick-icon {
|
||||
font-size: 2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.quick-text {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ========== 관리 섹션 ========== */
|
||||
.management-section {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
/* ========== 시스템 상태 섹션 ========== */
|
||||
.system-status-section {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
font-size: 2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.status-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
1830
synology_deployment/web-ui/css/work-report-calendar.css
Normal file
1830
synology_deployment/web-ui/css/work-report-calendar.css
Normal file
File diff suppressed because it is too large
Load Diff
108
synology_deployment/web-ui/css/work-report.css
Normal file
108
synology_deployment/web-ui/css/work-report.css
Normal file
@@ -0,0 +1,108 @@
|
||||
/* ✅ /css/workreport.css */
|
||||
|
||||
body {
|
||||
font-family: 'Malgun Gothic', sans-serif;
|
||||
background-color: #f8f9fa;
|
||||
margin: 0;
|
||||
padding: 30px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
h2 {
|
||||
text-align: center;
|
||||
color: #1976d2;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
#calendar {
|
||||
max-width: 500px;
|
||||
margin: 0 auto 30px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#calendar .nav {
|
||||
grid-column: span 7;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
#calendar button {
|
||||
padding: 8px;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #ccc;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#calendar button:hover {
|
||||
background-color: #e3f2fd;
|
||||
}
|
||||
|
||||
.selected-date {
|
||||
background-color: #4caf50 !important;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: auto;
|
||||
border-collapse: collapse;
|
||||
background-color: white;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
th, td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: #f1f3f4;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
select,
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 6px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
background-color: #d9534f;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.remove-btn:hover {
|
||||
background-color: #c9302c;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
display: block;
|
||||
margin: 30px auto;
|
||||
padding: 12px 30px;
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
background-color: #1976d2;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
background-color: #1565c0;
|
||||
}
|
||||
839
synology_deployment/web-ui/css/work-review.css
Normal file
839
synology_deployment/web-ui/css/work-review.css
Normal file
@@ -0,0 +1,839 @@
|
||||
/* work-review.css - 작업 검토 페이지 전용 스타일 (개선된 버전) */
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: #f5f7fa;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.main-layout-with-navbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.review-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 페이지 헤더 */
|
||||
.page-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
padding: 2rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 6px 24px rgba(0,0,0,0.12);
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 2.2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* 뒤로가기 버튼 */
|
||||
.back-btn {
|
||||
background: rgba(255,255,255,0.95);
|
||||
color: #667eea;
|
||||
border: 3px solid #667eea;
|
||||
padding: 12px 24px;
|
||||
border-radius: 12px;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* 컨트롤 패널 */
|
||||
.control-panel {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.month-navigation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nav-btn, .today-btn {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.nav-btn:hover, .today-btn:hover {
|
||||
background: #0056b3;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.today-btn {
|
||||
background: #28a745;
|
||||
}
|
||||
|
||||
.today-btn:hover {
|
||||
background: #1e7e34;
|
||||
}
|
||||
|
||||
.current-month {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
min-width: 200px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.control-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 사용법 안내 */
|
||||
.usage-guide {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.usage-guide h3 {
|
||||
margin-bottom: 1.5rem;
|
||||
color: #333;
|
||||
font-size: 1.3rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.guide-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.guide-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||
border: 2px solid #dee2e6;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.guide-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.guide-icon {
|
||||
font-size: 2rem;
|
||||
width: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.guide-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.guide-text strong {
|
||||
color: #007bff;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* 캘린더 */
|
||||
.calendar-container {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.calendar-container h3 {
|
||||
color: #333;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 2px;
|
||||
border: 3px solid #dee2e6;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.day-header {
|
||||
background: #343a40;
|
||||
color: white;
|
||||
padding: 15px;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.day-cell {
|
||||
background: white;
|
||||
border: 1px solid #dee2e6;
|
||||
min-height: 80px;
|
||||
padding: 8px;
|
||||
position: relative;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.day-cell:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.day-cell.other-month {
|
||||
background: #f8f9fa;
|
||||
color: #6c757d;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.day-cell.today {
|
||||
border: 3px solid #007bff;
|
||||
background: #e7f3ff;
|
||||
}
|
||||
|
||||
.day-cell.selected {
|
||||
background: #d4edda;
|
||||
border: 3px solid #28a745;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.day-cell.selected .day-number {
|
||||
color: #155724;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.day-number {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
/* 선택된 날짜 정보 패널 */
|
||||
.day-info-panel {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.day-info-placeholder {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.day-info-placeholder h3 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.day-info-content {
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.day-info-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 3px solid #dee2e6;
|
||||
}
|
||||
|
||||
.day-info-header h3 {
|
||||
color: #333;
|
||||
font-size: 1.5rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.day-info-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.review-toggle, .refresh-day-btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.review-toggle {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.review-toggle.reviewed {
|
||||
background: #28a745;
|
||||
}
|
||||
|
||||
.review-toggle:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.refresh-day-btn {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.refresh-day-btn:hover {
|
||||
background: #545b62;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* 일별 요약 정보 */
|
||||
.day-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
padding: 1.5rem;
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||
border-radius: 12px;
|
||||
border: 2px solid #dee2e6;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-weight: 600;
|
||||
color: #6c757d;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.summary-value.normal-work {
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.summary-value.overtime {
|
||||
color: #6f42c1;
|
||||
}
|
||||
|
||||
.summary-value.vacation {
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
.summary-value.reviewed {
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.summary-value.unreviewed {
|
||||
color: #fd7e14;
|
||||
}
|
||||
|
||||
/* 작업자별 상세 섹션 */
|
||||
.workers-detail-container h4 {
|
||||
margin-bottom: 1rem;
|
||||
color: #333;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.worker-detail-section {
|
||||
border: 2px solid #dee2e6;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 1rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.worker-header-detail {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.delete-worker-btn {
|
||||
background: rgba(220, 53, 69, 0.9);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.delete-worker-btn:hover {
|
||||
background: #c82333;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.worker-work-items {
|
||||
padding: 1rem;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.work-item-detail {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #007bff;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.work-item-detail:hover {
|
||||
background: #e9ecef;
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
.work-item-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.work-item-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.edit-work-btn, .delete-work-btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.edit-work-btn {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.edit-work-btn:hover {
|
||||
background: #0056b3;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.delete-work-btn {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.delete-work-btn:hover {
|
||||
background: #c82333;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* 메시지 */
|
||||
.message {
|
||||
padding: 15px 20px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
font-weight: 600;
|
||||
animation: slideInDown 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideInDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.message.loading {
|
||||
background: #cce5ff;
|
||||
color: #0066cc;
|
||||
border: 2px solid #99d6ff;
|
||||
}
|
||||
|
||||
.message.success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border: 2px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 2px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.message.warning {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
border: 2px solid #ffeaa7;
|
||||
}
|
||||
|
||||
/* 수정 모달 스타일 */
|
||||
.edit-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0,0,0,0.7);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1001;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.edit-modal-content {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.3);
|
||||
animation: slideInUp 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(50px) scale(0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.edit-modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24px;
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border-radius: 16px 16px 0 0;
|
||||
}
|
||||
|
||||
.edit-modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.close-modal-btn {
|
||||
background: rgba(255,255,255,0.2);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.close-modal-btn:hover {
|
||||
background: rgba(255,255,255,0.3);
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.edit-modal-body {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.edit-form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.edit-form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 700;
|
||||
color: #555;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.edit-select, .edit-input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 2px solid #e1e5e9;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
background: white;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.edit-select:focus, .edit-input:focus {
|
||||
outline: none;
|
||||
border-color: #007bff;
|
||||
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
|
||||
}
|
||||
|
||||
.edit-modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 24px;
|
||||
border-top: 2px solid #f0f0f0;
|
||||
background: #f8f9fa;
|
||||
border-radius: 0 0 16px 16px;
|
||||
}
|
||||
|
||||
/* 확인 모달 */
|
||||
.confirm-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0,0,0,0.8);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1002;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.confirm-modal-content {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
width: 90%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.3);
|
||||
animation: slideInUp 0.3s ease;
|
||||
}
|
||||
|
||||
.confirm-modal-header {
|
||||
padding: 24px 24px 16px;
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.confirm-modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.confirm-modal-body {
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.confirm-modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 16px 24px 24px;
|
||||
border-top: 2px solid #f0f0f0;
|
||||
background: #f8f9fa;
|
||||
border-radius: 0 0 16px 16px;
|
||||
}
|
||||
|
||||
/* 버튼 스타일 */
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #545b62;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: #1e7e34;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c82333;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 768px) {
|
||||
.content-wrapper {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.month-navigation {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.guide-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.guide-item {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.day-cell {
|
||||
min-height: 60px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.day-number {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.day-summary {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.day-info-header {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.day-info-actions {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.review-toggle, .refresh-day-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.work-item-detail {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.work-item-actions {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.worker-header-detail {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.edit-modal-content {
|
||||
width: 95%;
|
||||
margin: 20px;
|
||||
}
|
||||
|
||||
.edit-modal-footer, .confirm-modal-footer {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.edit-work-btn, .delete-work-btn {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
19
synology_deployment/web-ui/docker-compose.yml
Normal file
19
synology_deployment/web-ui/docker-compose.yml
Normal file
@@ -0,0 +1,19 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: web_hyungi_dev
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "20000:80"
|
||||
volumes:
|
||||
- .:/usr/share/nginx/html:ro
|
||||
networks:
|
||||
- hyungi_network
|
||||
|
||||
networks:
|
||||
hyungi_network:
|
||||
external: true
|
||||
394
synology_deployment/web-ui/docs/assets/css/style.css
Normal file
394
synology_deployment/web-ui/docs/assets/css/style.css
Normal file
@@ -0,0 +1,394 @@
|
||||
/* 테크니컬코리아 문서 시스템 CSS */
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 헤더 스타일 */
|
||||
.header {
|
||||
text-align: center;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 40px 30px;
|
||||
border-radius: 20px;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 10px;
|
||||
background: linear-gradient(45deg, #667eea, #764ba2);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.header p {
|
||||
font-size: 1.2rem;
|
||||
color: #666;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* 메인 콘텐츠 */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
/* 카드 그리드 */
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 25px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
/* 카드 스타일 */
|
||||
.card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 20px;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-10px);
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 20px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 15px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.card p {
|
||||
color: #666;
|
||||
margin-bottom: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.card-link {
|
||||
display: inline-block;
|
||||
background: linear-gradient(45deg, #667eea, #764ba2);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
padding: 12px 30px;
|
||||
border-radius: 25px;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.card-link:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
/* 검색 카드 특별 스타일 */
|
||||
.search-card {
|
||||
grid-column: 1 / -1;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
flex: 1;
|
||||
padding: 15px 20px;
|
||||
border: 2px solid #e1e5e9;
|
||||
border-radius: 25px;
|
||||
font-size: 1rem;
|
||||
outline: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-box input:focus {
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.search-box button {
|
||||
background: linear-gradient(45deg, #667eea, #764ba2);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 15px 30px;
|
||||
border-radius: 25px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.search-box button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
/* 문서 목록 스타일 */
|
||||
.document-list {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.document-item {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 15px;
|
||||
padding: 25px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.document-item:hover {
|
||||
transform: translateX(10px);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.document-icon {
|
||||
font-size: 2rem;
|
||||
width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.document-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.document-content h3 {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 5px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.document-content p {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.document-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.document-date {
|
||||
font-size: 0.8rem;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.download-btn {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.download-btn:hover {
|
||||
background: #218838;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* 네비게이션 브레드크럼 */
|
||||
.breadcrumb {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
padding: 15px 25px;
|
||||
border-radius: 15px;
|
||||
margin-bottom: 20px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.breadcrumb a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.breadcrumb a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.breadcrumb span {
|
||||
color: #999;
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
/* 페이지 헤더 */
|
||||
.page-header {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 30px;
|
||||
border-radius: 20px;
|
||||
margin-bottom: 30px;
|
||||
text-align: center;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 10px;
|
||||
background: linear-gradient(45deg, #667eea, #764ba2);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* 푸터 */
|
||||
.footer {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
padding: 25px;
|
||||
border-radius: 15px;
|
||||
text-align: center;
|
||||
backdrop-filter: blur(10px);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.footer p {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.footer p:last-child {
|
||||
margin-bottom: 0;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* 반응형 디자인 */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 25px 20px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 25px 20px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.search-box button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.document-item {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.document-meta {
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.header h1 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.header p {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 20px 15px;
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* 로딩 애니메이션 */
|
||||
.loading {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: #fff;
|
||||
animation: spin 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 알림 메시지 */
|
||||
.alert {
|
||||
padding: 15px 20px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 20px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-left: 5px solid #667eea;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
border-left-color: #28a745;
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
border-left-color: #ffc107;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
border-left-color: #dc3545;
|
||||
}
|
||||
60
synology_deployment/web-ui/docs/assets/js/main.js
Normal file
60
synology_deployment/web-ui/docs/assets/js/main.js
Normal file
@@ -0,0 +1,60 @@
|
||||
// 문서 검색 기능
|
||||
function searchDocuments() {
|
||||
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
|
||||
if (searchTerm.trim() === '') {
|
||||
alert('검색어를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 간단한 검색 구현 (실제로는 서버 검색 또는 더 복잡한 로직 필요)
|
||||
window.location.href = `search.html?q=${encodeURIComponent(searchTerm)}`;
|
||||
}
|
||||
|
||||
// 문서 필터링 기능 (문서 목록 페이지용)
|
||||
function filterDocuments(searchTerm) {
|
||||
const documents = document.querySelectorAll('.document-item');
|
||||
const term = searchTerm.toLowerCase();
|
||||
|
||||
documents.forEach(doc => {
|
||||
const keywords = doc.getAttribute('data-keywords').toLowerCase();
|
||||
const title = doc.querySelector('h3').textContent.toLowerCase();
|
||||
const description = doc.querySelector('p').textContent.toLowerCase();
|
||||
|
||||
if (keywords.includes(term) || title.includes(term) || description.includes(term)) {
|
||||
doc.style.display = 'flex';
|
||||
} else {
|
||||
doc.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// QR 코드 생성 (선택사항)
|
||||
function generateQR() {
|
||||
const url = 'http://192.168.0.3:10080';
|
||||
const qrAPI = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(url)}`;
|
||||
|
||||
const qrModal = document.createElement('div');
|
||||
qrModal.innerHTML = `
|
||||
<div style="position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); display: flex; justify-content: center; align-items: center; z-index: 1000;">
|
||||
<div style="background: white; padding: 30px; border-radius: 15px; text-align: center;">
|
||||
<h3>QR 코드로 접속</h3>
|
||||
<img src="${qrAPI}" alt="QR Code" style="margin: 20px 0;">
|
||||
<p>${url}</p>
|
||||
<button onclick="this.parentElement.parentElement.remove()">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(qrModal);
|
||||
}
|
||||
|
||||
// Enter 키 검색
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
searchDocuments();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
357
synology_deployment/web-ui/docs/hr.html
Normal file
357
synology_deployment/web-ui/docs/hr.html
Normal file
@@ -0,0 +1,357 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>인사규정 - 테크니컬코리아</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: #f8f9fa;
|
||||
min-height: 100vh;
|
||||
color: #333;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border: 2px solid #dc2626;
|
||||
box-shadow: 0 10px 30px rgba(220, 38, 38, 0.1);
|
||||
min-height: calc(100vh - 40px);
|
||||
}
|
||||
|
||||
/* 브레드크럼 */
|
||||
.breadcrumb {
|
||||
background: #f8f9fa;
|
||||
padding: 15px 30px;
|
||||
border-bottom: 1px solid #dc2626;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.breadcrumb a {
|
||||
color: #dc2626;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.breadcrumb a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.breadcrumb span {
|
||||
color: #6b7280;
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
/* 헤더 */
|
||||
.page-header {
|
||||
text-align: center;
|
||||
padding: 40px 30px;
|
||||
border-bottom: 1px solid #dc2626;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
color: #dc2626;
|
||||
font-size: 2rem;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
color: #6b7280;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* 메인 컨텐츠 */
|
||||
.main-content {
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
/* 문서 목록 */
|
||||
.document-list {
|
||||
display: grid;
|
||||
gap: 15px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.document-item {
|
||||
background: white;
|
||||
border: 1px solid #dc2626;
|
||||
padding: 25px;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 1px 3px rgba(220, 38, 38, 0.1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.document-item:hover {
|
||||
border-color: #991b1b;
|
||||
box-shadow: 0 4px 12px rgba(220, 38, 38, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.document-content h3 {
|
||||
color: #111827;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.document-content p {
|
||||
color: #6b7280;
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.document-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 검색 섹션 */
|
||||
.search-section {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dc2626;
|
||||
padding: 25px;
|
||||
text-align: center;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.search-section h3 {
|
||||
color: #111827;
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
flex: 1;
|
||||
padding: 12px 15px;
|
||||
background: white;
|
||||
border: 1px solid #dc2626;
|
||||
color: #111827;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-box input::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.search-box input:focus {
|
||||
border-color: #991b1b;
|
||||
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.1);
|
||||
}
|
||||
|
||||
.search-box button {
|
||||
background: white;
|
||||
color: #dc2626;
|
||||
border: 1px solid #dc2626;
|
||||
padding: 12px 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.search-box button:hover {
|
||||
background: #dc2626;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 푸터 */
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
border-top: 1px solid #dc2626;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.footer p {
|
||||
color: #6b7280;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
padding: 25px 20px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- 브레드크럼 네비게이션 -->
|
||||
<nav class="breadcrumb">
|
||||
<a href="index.html">홈</a>
|
||||
<span>></span>
|
||||
<strong>인사규정</strong>
|
||||
</nav>
|
||||
|
||||
<!-- 페이지 헤더 -->
|
||||
<header class="page-header">
|
||||
<h2>인사규정</h2>
|
||||
<p>Human Resources - 인사관리 규정 및 지침</p>
|
||||
</header>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="main-content">
|
||||
<div class="document-list">
|
||||
<!-- 인사규정 -->
|
||||
<div class="document-item" onclick="location.href='hr/HR-001.html'">
|
||||
<div class="document-content">
|
||||
<h3>HR-001 인사관리 규정</h3>
|
||||
<p>채용, 승진, 전보, 퇴직 등 인사관리 전반</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 급여규정 -->
|
||||
<div class="document-item" onclick="location.href='hr/HR-002.html'">
|
||||
<div class="document-content">
|
||||
<h3>HR-002 급여관리 규정</h3>
|
||||
<p>급여체계, 수당, 상여금 지급 기준</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 근무규정 -->
|
||||
<div class="document-item" onclick="location.href='hr/HR-003.html'">
|
||||
<div class="document-content">
|
||||
<h3>HR-003 근무시간 관리규정</h3>
|
||||
<p>근무시간, 휴게시간, 연장근무 규정</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 휴가규정 -->
|
||||
<div class="document-item" onclick="location.href='hr/HR-004.html'">
|
||||
<div class="document-content">
|
||||
<h3>HR-004 휴가 및 휴직규정</h3>
|
||||
<p>연차, 병가, 특별휴가, 휴직 관련 규정</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 복리후생 -->
|
||||
<div class="document-item" onclick="location.href='hr/HR-005.html'">
|
||||
<div class="document-content">
|
||||
<h3>HR-005 복리후생 규정</h3>
|
||||
<p>건강보험, 퇴직금, 각종 지원금 규정</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 성과평가 -->
|
||||
<div class="document-item" onclick="location.href='hr/HR-006.html'">
|
||||
<div class="document-content">
|
||||
<h3>HR-006 성과평가 관리규정</h3>
|
||||
<p>성과평가 기준, 절차, 결과 활용 방안</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 교육훈련 -->
|
||||
<div class="document-item" onclick="location.href='hr/HR-007.html'">
|
||||
<div class="document-content">
|
||||
<h3>HR-007 교육훈련 관리규정</h3>
|
||||
<p>신입사원 교육, 직무교육, 외부교육 지원</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 징계규정 -->
|
||||
<div class="document-item" onclick="location.href='hr/HR-008.html'">
|
||||
<div class="document-content">
|
||||
<h3>HR-008 징계 관리규정</h3>
|
||||
<p>징계사유, 절차, 징계양정 기준</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 보안규정 -->
|
||||
<div class="document-item" onclick="location.href='hr/HR-009.html'">
|
||||
<div class="document-content">
|
||||
<h3>HR-009 정보보안 및 기밀유지</h3>
|
||||
<p>회사 정보보안 및 기밀유지 의무</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 복무규정 -->
|
||||
<div class="document-item" onclick="location.href='hr/HR-010.html'">
|
||||
<div class="document-content">
|
||||
<h3>HR-010 복무 및 행동강령</h3>
|
||||
<p>직원 복무 기준 및 윤리 행동강령</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 검색 섹션 -->
|
||||
<div class="search-section">
|
||||
<h3>인사규정 검색</h3>
|
||||
<div class="search-box">
|
||||
<input type="text" id="hrSearchInput" placeholder="인사규정 검색 (예: 급여, 휴가, 교육)">
|
||||
<button onclick="filterDocuments(document.getElementById('hrSearchInput').value)">검색</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<p>테크니컬코리아 내부 전용 문서시스템</p>
|
||||
<p>인사 문의: hr@technicalkorea.co.kr | 내선: 3456</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 문서 필터링 기능
|
||||
function filterDocuments(searchTerm) {
|
||||
const documents = document.querySelectorAll('.document-item');
|
||||
const term = searchTerm.toLowerCase();
|
||||
|
||||
documents.forEach(doc => {
|
||||
const title = doc.querySelector('h3').textContent.toLowerCase();
|
||||
const description = doc.querySelector('p').textContent.toLowerCase();
|
||||
|
||||
if (title.includes(term) || description.includes(term)) {
|
||||
doc.style.display = 'block';
|
||||
} else {
|
||||
doc.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Enter 키로 검색
|
||||
document.getElementById('hrSearchInput').addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
filterDocuments(this.value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
447
synology_deployment/web-ui/docs/hse.html
Normal file
447
synology_deployment/web-ui/docs/hse.html
Normal file
@@ -0,0 +1,447 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>HSE 관리시스템 - 테크니컬코리아</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: #f8f9fa;
|
||||
min-height: 100vh;
|
||||
color: #333;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border: 2px solid #dc2626;
|
||||
box-shadow: 0 10px 30px rgba(220, 38, 38, 0.1);
|
||||
min-height: calc(100vh - 40px);
|
||||
}
|
||||
|
||||
/* 브레드크럼 */
|
||||
.breadcrumb {
|
||||
background: #f8f9fa;
|
||||
padding: 15px 30px;
|
||||
border-bottom: 1px solid #dc2626;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.breadcrumb a {
|
||||
color: #dc2626;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.breadcrumb a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.breadcrumb span {
|
||||
color: #6b7280;
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
/* 헤더 */
|
||||
.page-header {
|
||||
text-align: center;
|
||||
padding: 40px 30px;
|
||||
border-bottom: 1px solid #dc2626;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
color: #dc2626;
|
||||
font-size: 2rem;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
color: #6b7280;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* 메인 컨텐츠 */
|
||||
.main-content {
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
/* 문서 목록 */
|
||||
.document-list {
|
||||
display: grid;
|
||||
gap: 15px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.document-item {
|
||||
background: white;
|
||||
border: 1px solid #dc2626;
|
||||
padding: 25px;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 1px 3px rgba(220, 38, 38, 0.1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.document-item:hover {
|
||||
border-color: #991b1b;
|
||||
box-shadow: 0 4px 12px rgba(220, 38, 38, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.document-content h3 {
|
||||
color: #111827;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.document-content p {
|
||||
color: #6b7280;
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.document-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 검색 섹션 */
|
||||
.search-section {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dc2626;
|
||||
padding: 25px;
|
||||
text-align: center;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.search-section h3 {
|
||||
color: #111827;
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
flex: 1;
|
||||
padding: 12px 15px;
|
||||
background: white;
|
||||
border: 1px solid #dc2626;
|
||||
color: #111827;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-box input::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.search-box input:focus {
|
||||
border-color: #991b1b;
|
||||
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.1);
|
||||
}
|
||||
|
||||
.search-box button {
|
||||
background: white;
|
||||
color: #dc2626;
|
||||
border: 1px solid #dc2626;
|
||||
padding: 12px 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.search-box button:hover {
|
||||
background: #dc2626;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 푸터 */
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
border-top: 1px solid #dc2626;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.footer p {
|
||||
color: #6b7280;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
padding: 25px 20px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- 브레드크럼 네비게이션 -->
|
||||
<nav class="breadcrumb">
|
||||
<a href="index.html">홈</a>
|
||||
<span>></span>
|
||||
<strong>HSE 관리시스템</strong>
|
||||
</nav>
|
||||
|
||||
<!-- 페이지 헤더 -->
|
||||
<header class="page-header">
|
||||
<h2>HSE 관리시스템</h2>
|
||||
<p>Health, Safety & Environment - 안전보건환경 관련 절차서</p>
|
||||
</header>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="main-content">
|
||||
<div class="document-list">
|
||||
<!-- HSE 관리시스템 매뉴얼 (최상위 문서) -->
|
||||
<div class="document-item" onclick="location.href='iso45001_bilingual_manual.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-001 ISO 45001:2018 HSE 관리시스템 매뉴얼</h3>
|
||||
<p>ISO 45001:2018 기반 보건, 안전 및 환경 관리시스템 최상위 문서 (한/영 이중언어)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. 조직의 상황 관련 문서 -->
|
||||
<div class="document-item" onclick="location.href='hse/TK-HSE-P-410.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-P-410 조직 상황 이해 및 HSE 관리시스템 운영 절차</h3>
|
||||
<p>조직의 내외부 상황 파악 및 HSE 관리시스템 전반 운영</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 5. 리더십과 근로자 참여 관련 문서 -->
|
||||
<div class="document-item" onclick="location.href='hse/TK-HSE-P-510.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-P-510 리더십 및 정책 수립 절차</h3>
|
||||
<p>최고경영자 리더십 및 HSE 정책 수립·운영 절차</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="document-item" onclick="location.href='hse/TK-HSE-P-520.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-P-520 조직 편성 및 직무 배정 절차</h3>
|
||||
<p>HSE 관련 조직 구성 및 역할·책임·권한 배정</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 6. 기획 관련 문서 -->
|
||||
<div class="document-item" onclick="location.href='hse/TK-HSE-P-610.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-P-610 기획 및 위험 관리 절차</h3>
|
||||
<p>HSE 관리시스템 기획 및 위험과 기회 관리</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="document-item" onclick="location.href='hse/TK-HSE-P-620.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-P-620 위험 평가 절차</h3>
|
||||
<p>유해요인 식별 및 위험성 평가 실시 절차</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="document-item" onclick="location.href='hse/TK-HSE-P-630.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-P-630 HSE 법적 요구사항 관리 절차</h3>
|
||||
<p>HSE 관련 법령 및 기타 요구사항 관리</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="document-item" onclick="location.href='hse/TK-HSE-P-640.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-P-640 HSE 목표 관리 절차</h3>
|
||||
<p>HSE 목표 설정, 달성 계획 수립 및 관리</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 7. 지원 관련 문서 -->
|
||||
<div class="document-item" onclick="location.href='hse/TK-HSE-P-710.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-P-710 자원 관리 절차</h3>
|
||||
<p>HSE 관리시스템 운영에 필요한 자원 관리</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="document-item" onclick="location.href='hse/TK-HSE-P-720.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-P-720 교육 및 훈련 관리 절차</h3>
|
||||
<p>HSE 관련 교육·훈련 계획 수립 및 실시</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="document-item" onclick="location.href='hse/TK-HSE-P-730.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-P-730 인식 및 의사소통 절차</h3>
|
||||
<p>HSE 인식 제고 및 내외부 의사소통 관리</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="document-item" onclick="location.href='hse/TK-HSE-P-740.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-P-740 문서화된 정보 관리 절차</h3>
|
||||
<p>HSE 문서 및 기록의 작성, 관리, 보관</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 8. 운영 관련 문서 -->
|
||||
<div class="document-item" onclick="location.href='hse/TK-HSE-P-810.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-P-810 운영 기획 및 관리 절차</h3>
|
||||
<p>HSE 운영 기획, 작업허가, 변경관리, 조달관리</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="document-item" onclick="location.href='hse/TK-HSE-P-820.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-P-820 비상 대비 및 대응 절차</h3>
|
||||
<p>비상상황 대비, 대응 계획 및 훈련</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 9. 성과 평가 관련 문서 -->
|
||||
<div class="document-item" onclick="location.href='hse/TK-HSE-P-910.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-P-910 프로세스 성과 관리 절차</h3>
|
||||
<p>HSE 관리시스템 프로세스 성과 관리</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="document-item" onclick="location.href='hse/TK-HSE-P-920.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-P-920 HSE 모니터링 및 측정 관리 절차</h3>
|
||||
<p>HSE 성과 모니터링, 측정 및 분석</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="document-item" onclick="location.href='hse/TK-HSE-P-930.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-P-930 내부 심사 절차</h3>
|
||||
<p>HSE 관리시스템 내부 심사 계획 및 실시</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="document-item" onclick="location.href='hse/TK-HSE-P-940.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-P-940 경영 검토 절차</h3>
|
||||
<p>HSE 관리시스템 경영진 검토</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 10. 개선 관련 문서 -->
|
||||
<div class="document-item" onclick="location.href='hse/TK-HSE-P-1010.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-P-1010 사건, 부적합 및 시정조치 절차</h3>
|
||||
<p>사건·사고 조사, 부적합 처리 및 시정조치</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="document-item" onclick="location.href='hse/TK-HSE-P-1020.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-P-1020 지속적 개선 절차</h3>
|
||||
<p>HSE 관리시스템 지속적 개선 활동</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 실무 지침 문서들 -->
|
||||
<div class="document-item" onclick="location.href='hse/TK-HSE-W-001.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-W-001 개인보호구 관리 지침</h3>
|
||||
<p>개인보호구 지급, 관리, 점검 실무 지침</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="document-item" onclick="location.href='hse/TK-HSE-W-002.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-W-002 화학물질 관리 지침</h3>
|
||||
<p>화학물질 보관, 사용, 폐기 실무 지침</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="document-item" onclick="location.href='hse/TK-HSE-W-003.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-W-003 응급처치 및 의료관리 지침</h3>
|
||||
<p>응급상황 대응 및 응급처치 실무 지침</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 검색 섹션 -->
|
||||
<div class="search-section">
|
||||
<h3>HSE 문서 검색</h3>
|
||||
<div class="search-box">
|
||||
<input type="text" id="hseSearchInput" placeholder="HSE 문서 검색 (예: 안전, 화재, 교육)">
|
||||
<button onclick="filterDocuments(document.getElementById('hseSearchInput').value)">검색</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<p>테크니컬코리아 내부 전용 문서시스템</p>
|
||||
<p>HSE 문의: safety@technicalkorea.co.kr | 내선: 1234</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 문서 필터링 기능
|
||||
function filterDocuments(searchTerm) {
|
||||
const documents = document.querySelectorAll('.document-item');
|
||||
const term = searchTerm.toLowerCase();
|
||||
|
||||
documents.forEach(doc => {
|
||||
const title = doc.querySelector('h3').textContent.toLowerCase();
|
||||
const description = doc.querySelector('p').textContent.toLowerCase();
|
||||
|
||||
if (title.includes(term) || description.includes(term)) {
|
||||
doc.style.display = 'flex';
|
||||
} else {
|
||||
doc.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Enter 키로 검색
|
||||
document.getElementById('hseSearchInput').addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
filterDocuments(this.value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1208
synology_deployment/web-ui/docs/hse/TK-HSE-P-410.html
Normal file
1208
synology_deployment/web-ui/docs/hse/TK-HSE-P-410.html
Normal file
File diff suppressed because it is too large
Load Diff
1529
synology_deployment/web-ui/docs/hse/TK-HSE-P-510.html
Normal file
1529
synology_deployment/web-ui/docs/hse/TK-HSE-P-510.html
Normal file
File diff suppressed because it is too large
Load Diff
1826
synology_deployment/web-ui/docs/hse/TK-HSE-P-520.html
Normal file
1826
synology_deployment/web-ui/docs/hse/TK-HSE-P-520.html
Normal file
File diff suppressed because it is too large
Load Diff
1671
synology_deployment/web-ui/docs/hse/TK-HSE-P-610.html
Normal file
1671
synology_deployment/web-ui/docs/hse/TK-HSE-P-610.html
Normal file
File diff suppressed because it is too large
Load Diff
1784
synology_deployment/web-ui/docs/hse/TK-HSE-P-620.html
Normal file
1784
synology_deployment/web-ui/docs/hse/TK-HSE-P-620.html
Normal file
File diff suppressed because it is too large
Load Diff
1894
synology_deployment/web-ui/docs/hse/TK-HSE-P-630.html
Normal file
1894
synology_deployment/web-ui/docs/hse/TK-HSE-P-630.html
Normal file
File diff suppressed because it is too large
Load Diff
295
synology_deployment/web-ui/docs/index.html
Normal file
295
synology_deployment/web-ui/docs/index.html
Normal file
@@ -0,0 +1,295 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>테크니컬코리아 문서 시스템</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: #f8f9fa;
|
||||
min-height: 100vh;
|
||||
color: #333;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 40px;
|
||||
background: white;
|
||||
border: 2px solid #dc2626;
|
||||
box-shadow: 0 10px 30px rgba(220, 38, 38, 0.1);
|
||||
min-height: calc(100vh - 40px);
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
padding-bottom: 30px;
|
||||
border-bottom: 1px solid #dc2626;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
color: #dc2626;
|
||||
font-size: 2rem;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header p {
|
||||
color: #6b7280;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border: 1px solid #dc2626;
|
||||
padding: 25px;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 10px rgba(220, 38, 38, 0.1);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: #991b1b;
|
||||
box-shadow: 0 5px 20px rgba(220, 38, 38, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 15px;
|
||||
opacity: 0.8;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
color: #111827;
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card p {
|
||||
color: #6b7280;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 20px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.card-link {
|
||||
display: inline-block;
|
||||
background: white;
|
||||
color: #dc2626;
|
||||
text-decoration: none;
|
||||
padding: 10px 20px;
|
||||
border: 1px solid #dc2626;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.3s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.card-link:hover {
|
||||
background: #dc2626;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.search-card {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
max-width: 400px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
flex: 1;
|
||||
padding: 12px 15px;
|
||||
background: white;
|
||||
border: 1px solid #dc2626;
|
||||
color: #111827;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-box input::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.search-box input:focus {
|
||||
border-color: #991b1b;
|
||||
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.1);
|
||||
}
|
||||
|
||||
.search-box button {
|
||||
background: white;
|
||||
color: #dc2626;
|
||||
border: 1px solid #dc2626;
|
||||
padding: 12px 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.search-box button:hover {
|
||||
background: #dc2626;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding-top: 30px;
|
||||
border-top: 1px solid #dc2626;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.footer p {
|
||||
color: #6b7280;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 20px;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.search-box button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header class="header">
|
||||
<h1>테크니컬코리아 문서 시스템</h1>
|
||||
<p>회사 규정 및 절차서 열람 시스템</p>
|
||||
</header>
|
||||
|
||||
<main class="main-content">
|
||||
<div class="card-grid">
|
||||
<!-- HSE 문서 -->
|
||||
<div class="card">
|
||||
<h3>HSE 관리시스템</h3>
|
||||
<p>ISO 45001:2018 기반 안전보건환경 관련 절차서</p>
|
||||
<a href="hse.html" class="card-link">바로가기</a>
|
||||
</div>
|
||||
|
||||
<!-- 품질 문서 -->
|
||||
<div class="card">
|
||||
<h3>품질 관리시스템</h3>
|
||||
<p>ISO 9001 기반 품질관리 절차 및 매뉴얼</p>
|
||||
<a href="quality.html" class="card-link">바로가기</a>
|
||||
</div>
|
||||
|
||||
<!-- 인사 규정 -->
|
||||
<div class="card">
|
||||
<h3>인사 규정</h3>
|
||||
<p>인사관리 규정 및 지침서</p>
|
||||
<a href="hr.html" class="card-link">바로가기</a>
|
||||
</div>
|
||||
|
||||
<!-- 기술 문서 -->
|
||||
<div class="card">
|
||||
<h3>기술 문서</h3>
|
||||
<p>설계 표준, 용접절차, BOM 시스템 가이드라인</p>
|
||||
<a href="technical.html" class="card-link">바로가기</a>
|
||||
</div>
|
||||
|
||||
<!-- 경영 방침 -->
|
||||
<div class="card">
|
||||
<h3>경영 방침</h3>
|
||||
<p>회사 방침, 윤리강령 및 정책 문서</p>
|
||||
<a href="policy.html" class="card-link">바로가기</a>
|
||||
</div>
|
||||
|
||||
<!-- 검색 -->
|
||||
<div class="card search-card">
|
||||
<h3>문서 검색</h3>
|
||||
<div class="search-box">
|
||||
<input type="text" id="searchInput" placeholder="문서명 또는 키워드 입력">
|
||||
<button onclick="searchDocuments()">검색</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<p>테크니컬코리아 내부 전용 문서시스템</p>
|
||||
<p>http://192.168.0.3:10080/docs</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 문서 검색 기능
|
||||
function searchDocuments() {
|
||||
const searchTerm = document.getElementById('searchInput').value.toLowerCase().trim();
|
||||
if (searchTerm === '') {
|
||||
alert('검색어를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 키워드에 따른 페이지 추천
|
||||
if (searchTerm.includes('iso') || searchTerm.includes('45001') || searchTerm.includes('hse') || searchTerm.includes('안전')) {
|
||||
window.location.href = 'hse.html';
|
||||
} else if (searchTerm.includes('품질') || searchTerm.includes('quality') || searchTerm.includes('9001')) {
|
||||
window.location.href = 'quality.html';
|
||||
} else if (searchTerm.includes('인사') || searchTerm.includes('hr') || searchTerm.includes('급여')) {
|
||||
window.location.href = 'hr.html';
|
||||
} else if (searchTerm.includes('기술') || searchTerm.includes('설계') || searchTerm.includes('용접') || searchTerm.includes('bom')) {
|
||||
window.location.href = 'technical.html';
|
||||
} else if (searchTerm.includes('경영') || searchTerm.includes('정책') || searchTerm.includes('윤리')) {
|
||||
window.location.href = 'policy.html';
|
||||
} else {
|
||||
alert('관련 문서를 찾을 수 없습니다. 다른 키워드로 시도해보세요.');
|
||||
}
|
||||
}
|
||||
|
||||
// Enter 키 검색
|
||||
document.getElementById('searchInput').addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
searchDocuments();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1469
synology_deployment/web-ui/docs/iso45001_bilingual_manual.html
Normal file
1469
synology_deployment/web-ui/docs/iso45001_bilingual_manual.html
Normal file
File diff suppressed because it is too large
Load Diff
317
synology_deployment/web-ui/docs/policy.html
Normal file
317
synology_deployment/web-ui/docs/policy.html
Normal file
@@ -0,0 +1,317 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>경영방침 - 테크니컬코리아</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: #f8f9fa;
|
||||
min-height: 100vh;
|
||||
color: #333;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border: 2px solid #dc2626;
|
||||
box-shadow: 0 10px 30px rgba(220, 38, 38, 0.1);
|
||||
min-height: calc(100vh - 40px);
|
||||
}
|
||||
|
||||
/* 브레드크럼 */
|
||||
.breadcrumb {
|
||||
background: #f8f9fa;
|
||||
padding: 15px 30px;
|
||||
border-bottom: 1px solid #dc2626;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.breadcrumb a {
|
||||
color: #dc2626;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.breadcrumb a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.breadcrumb span {
|
||||
color: #6b7280;
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
/* 헤더 */
|
||||
.page-header {
|
||||
text-align: center;
|
||||
padding: 40px 30px;
|
||||
border-bottom: 1px solid #dc2626;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
color: #dc2626;
|
||||
font-size: 2rem;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
color: #6b7280;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* 메인 컨텐츠 */
|
||||
.main-content {
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
/* 문서 목록 */
|
||||
.document-list {
|
||||
display: grid;
|
||||
gap: 15px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.document-item {
|
||||
background: white;
|
||||
border: 1px solid #dc2626;
|
||||
padding: 25px;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 1px 3px rgba(220, 38, 38, 0.1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.document-item:hover {
|
||||
border-color: #991b1b;
|
||||
box-shadow: 0 4px 12px rgba(220, 38, 38, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.document-content h3 {
|
||||
color: #111827;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.document-content p {
|
||||
color: #6b7280;
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.document-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 검색 섹션 */
|
||||
.search-section {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dc2626;
|
||||
padding: 25px;
|
||||
text-align: center;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.search-section h3 {
|
||||
color: #111827;
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
flex: 1;
|
||||
padding: 12px 15px;
|
||||
background: white;
|
||||
border: 1px solid #dc2626;
|
||||
color: #111827;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-box input::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.search-box input:focus {
|
||||
border-color: #991b1b;
|
||||
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.1);
|
||||
}
|
||||
|
||||
.search-box button {
|
||||
background: white;
|
||||
color: #dc2626;
|
||||
border: 1px solid #dc2626;
|
||||
padding: 12px 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.search-box button:hover {
|
||||
background: #dc2626;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 푸터 */
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
border-top: 1px solid #dc2626;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.footer p {
|
||||
color: #6b7280;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
padding: 25px 20px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- 브레드크럼 네비게이션 -->
|
||||
<nav class="breadcrumb">
|
||||
<a href="index.html">홈</a>
|
||||
<span>></span>
|
||||
<strong>경영방침</strong>
|
||||
</nav>
|
||||
|
||||
<!-- 페이지 헤더 -->
|
||||
<header class="page-header">
|
||||
<h2>경영방침</h2>
|
||||
<p>Management Policy - 회사 방침 및 정책 문서</p>
|
||||
</header>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="main-content">
|
||||
<div class="document-list">
|
||||
<!-- 경영방침서 -->
|
||||
<div class="document-item" onclick="location.href='policy/MP-001.html'">
|
||||
<div class="document-content">
|
||||
<h3>MP-001 경영방침서</h3>
|
||||
<p>테크니컬코리아 경영이념 및 기본방침</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 윤리강령 -->
|
||||
<div class="document-item" onclick="location.href='policy/MP-002.html'">
|
||||
<div class="document-content">
|
||||
<h3>MP-002 윤리강령</h3>
|
||||
<p>임직원 윤리행동 기준 및 가이드라인</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 정보보안정책 -->
|
||||
<div class="document-item" onclick="location.href='policy/MP-003.html'">
|
||||
<div class="document-content">
|
||||
<h3>MP-003 정보보안 정책</h3>
|
||||
<p>회사 정보자산 보호 및 보안 정책</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 조직도 -->
|
||||
<div class="document-item" onclick="location.href='policy/MP-004.html'">
|
||||
<div class="document-content">
|
||||
<h3>MP-004 조직도</h3>
|
||||
<p>회사 조직도 및 부서별 역할</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 권한위임규정 -->
|
||||
<div class="document-item" onclick="location.href='policy/MP-005.html'">
|
||||
<div class="document-content">
|
||||
<h3>MP-005 권한위임 규정</h3>
|
||||
<p>의사결정 권한 및 위임 규정</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 검색 섹션 -->
|
||||
<div class="search-section">
|
||||
<h3>경영방침 검색</h3>
|
||||
<div class="search-box">
|
||||
<input type="text" id="policySearchInput" placeholder="경영방침 검색 (예: 윤리, 보안, 조직)">
|
||||
<button onclick="filterDocuments(document.getElementById('policySearchInput').value)">검색</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<p>테크니컬코리아 내부 전용 문서시스템</p>
|
||||
<p>경영방침 문의: policy@technicalkorea.co.kr | 내선: 5678</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 문서 필터링 기능
|
||||
function filterDocuments(searchTerm) {
|
||||
const documents = document.querySelectorAll('.document-item');
|
||||
const term = searchTerm.toLowerCase();
|
||||
|
||||
documents.forEach(doc => {
|
||||
const title = doc.querySelector('h3').textContent.toLowerCase();
|
||||
const description = doc.querySelector('p').textContent.toLowerCase();
|
||||
|
||||
if (title.includes(term) || description.includes(term)) {
|
||||
doc.style.display = 'block';
|
||||
} else {
|
||||
doc.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Enter 키로 검색
|
||||
document.getElementById('policySearchInput').addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
filterDocuments(this.value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
373
synology_deployment/web-ui/docs/quality.html
Normal file
373
synology_deployment/web-ui/docs/quality.html
Normal file
@@ -0,0 +1,373 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>품질 관리시스템 - 테크니컬코리아</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: #f8f9fa;
|
||||
min-height: 100vh;
|
||||
color: #333;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border: 2px solid #dc2626;
|
||||
box-shadow: 0 10px 30px rgba(220, 38, 38, 0.1);
|
||||
min-height: calc(100vh - 40px);
|
||||
}
|
||||
|
||||
/* 브레드크럼 */
|
||||
.breadcrumb {
|
||||
background: #f8f9fa;
|
||||
padding: 15px 30px;
|
||||
border-bottom: 1px solid #dc2626;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.breadcrumb a {
|
||||
color: #dc2626;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.breadcrumb a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.breadcrumb span {
|
||||
color: #6b7280;
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
/* 헤더 */
|
||||
.page-header {
|
||||
text-align: center;
|
||||
padding: 40px 30px;
|
||||
border-bottom: 1px solid #dc2626;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
color: #dc2626;
|
||||
font-size: 2rem;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
color: #6b7280;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* 메인 컨텐츠 */
|
||||
.main-content {
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
/* 문서 목록 */
|
||||
.document-list {
|
||||
display: grid;
|
||||
gap: 15px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.document-item {
|
||||
background: white;
|
||||
border: 1px solid #dc2626;
|
||||
padding: 25px;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 1px 3px rgba(220, 38, 38, 0.1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.document-item:hover {
|
||||
border-color: #991b1b;
|
||||
box-shadow: 0 4px 12px rgba(220, 38, 38, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.document-content h3 {
|
||||
color: #111827;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.document-content p {
|
||||
color: #6b7280;
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.document-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 검색 섹션 */
|
||||
.search-section {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dc2626;
|
||||
padding: 25px;
|
||||
text-align: center;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.search-section h3 {
|
||||
color: #111827;
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
flex: 1;
|
||||
padding: 12px 15px;
|
||||
background: white;
|
||||
border: 1px solid #dc2626;
|
||||
color: #111827;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-box input::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.search-box input:focus {
|
||||
border-color: #991b1b;
|
||||
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.1);
|
||||
}
|
||||
|
||||
.search-box button {
|
||||
background: white;
|
||||
color: #dc2626;
|
||||
border: 1px solid #dc2626;
|
||||
padding: 12px 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.search-box button:hover {
|
||||
background: #dc2626;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 푸터 */
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
border-top: 1px solid #dc2626;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.footer p {
|
||||
color: #6b7280;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
padding: 25px 20px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- 브레드크럼 네비게이션 -->
|
||||
<nav class="breadcrumb">
|
||||
<a href="index.html">홈</a>
|
||||
<span>></span>
|
||||
<strong>품질 관리시스템</strong>
|
||||
</nav>
|
||||
|
||||
<!-- 페이지 헤더 -->
|
||||
<header class="page-header">
|
||||
<h2>품질 관리시스템</h2>
|
||||
<p>Quality Management System - ISO 9001 기반 품질관리 절차 및 매뉴얼</p>
|
||||
</header>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="main-content">
|
||||
<div class="document-list">
|
||||
<!-- 품질매뉴얼 -->
|
||||
<div class="document-item" onclick="location.href='quality/QM-001.html'">
|
||||
<div class="document-content">
|
||||
<h3>QM-001 품질매뉴얼</h3>
|
||||
<p>ISO 9001 기반 품질경영시스템 매뉴얼</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 문서관리 -->
|
||||
<div class="document-item" onclick="location.href='quality/QP-001.html'">
|
||||
<div class="document-content">
|
||||
<h3>QP-001 문서 및 기록관리 절차</h3>
|
||||
<p>품질문서 작성, 승인, 배포, 보관 절차</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 고객만족 -->
|
||||
<div class="document-item" onclick="location.href='quality/QP-002.html'">
|
||||
<div class="document-content">
|
||||
<h3>QP-002 고객만족 관리절차</h3>
|
||||
<p>고객 요구사항 파악 및 만족도 관리</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 설계관리 -->
|
||||
<div class="document-item" onclick="location.href='quality/QP-003.html'">
|
||||
<div class="document-content">
|
||||
<h3>QP-003 설계 및 개발관리</h3>
|
||||
<p>설계입력, 검토, 검증, 타당성확인 절차</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 구매관리 -->
|
||||
<div class="document-item" onclick="location.href='quality/QP-004.html'">
|
||||
<div class="document-content">
|
||||
<h3>QP-004 구매 및 외주관리</h3>
|
||||
<p>협력업체 평가, 구매품 검증 절차</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 생산관리 -->
|
||||
<div class="document-item" onclick="location.href='quality/QP-005.html'">
|
||||
<div class="document-content">
|
||||
<h3>QP-005 생산 및 서비스 제공</h3>
|
||||
<p>생산공정 관리 및 제품 식별추적성</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 검사시험 -->
|
||||
<div class="document-item" onclick="location.href='quality/QP-006.html'">
|
||||
<div class="document-content">
|
||||
<h3>QP-006 검사 및 시험관리</h3>
|
||||
<p>원자재, 중간품, 최종제품 검사 절차</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 부적합관리 -->
|
||||
<div class="document-item" onclick="location.href='quality/QP-007.html'">
|
||||
<div class="document-content">
|
||||
<h3>QP-007 부적합 및 시정조치</h3>
|
||||
<p>부적합품 관리 및 시정예방조치 절차</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 내부심사 -->
|
||||
<div class="document-item" onclick="location.href='quality/QP-008.html'">
|
||||
<div class="document-content">
|
||||
<h3>QP-008 내부심사 절차</h3>
|
||||
<p>품질경영시스템 내부심사 실시 절차</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 경영검토 -->
|
||||
<div class="document-item" onclick="location.href='quality/QP-009.html'">
|
||||
<div class="document-content">
|
||||
<h3>QP-009 경영검토 절차</h3>
|
||||
<p>품질경영시스템 경영검토 실시 절차</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 측정장비관리 -->
|
||||
<div class="document-item" onclick="location.href='quality/QP-010.html'">
|
||||
<div class="document-content">
|
||||
<h3>QP-010 측정장비 관리절차</h3>
|
||||
<p>측정장비 교정, 점검, 관리 절차</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 교육훈련 -->
|
||||
<div class="document-item" onclick="location.href='quality/QP-011.html'">
|
||||
<div class="document-content">
|
||||
<h3>QP-011 교육훈련 관리절차</h3>
|
||||
<p>품질 관련 교육훈련 계획 및 실시</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 검색 섹션 -->
|
||||
<div class="search-section">
|
||||
<h3>품질문서 검색</h3>
|
||||
<div class="search-box">
|
||||
<input type="text" id="qualitySearchInput" placeholder="품질문서 검색 (예: ISO, 설계, 검사)">
|
||||
<button onclick="filterDocuments(document.getElementById('qualitySearchInput').value)">검색</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<p>테크니컬코리아 내부 전용 문서시스템</p>
|
||||
<p>품질 문의: quality@technicalkorea.co.kr | 내선: 2345</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 문서 필터링 기능
|
||||
function filterDocuments(searchTerm) {
|
||||
const documents = document.querySelectorAll('.document-item');
|
||||
const term = searchTerm.toLowerCase();
|
||||
|
||||
documents.forEach(doc => {
|
||||
const title = doc.querySelector('h3').textContent.toLowerCase();
|
||||
const description = doc.querySelector('p').textContent.toLowerCase();
|
||||
|
||||
if (title.includes(term) || description.includes(term)) {
|
||||
doc.style.display = 'block';
|
||||
} else {
|
||||
doc.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Enter 키로 검색
|
||||
document.getElementById('qualitySearchInput').addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
filterDocuments(this.value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
333
synology_deployment/web-ui/docs/technical.html
Normal file
333
synology_deployment/web-ui/docs/technical.html
Normal file
@@ -0,0 +1,333 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>기술문서 - 테크니컬코리아</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: #f8f9fa;
|
||||
min-height: 100vh;
|
||||
color: #333;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border: 2px solid #dc2626;
|
||||
box-shadow: 0 10px 30px rgba(220, 38, 38, 0.1);
|
||||
min-height: calc(100vh - 40px);
|
||||
}
|
||||
|
||||
/* 브레드크럼 */
|
||||
.breadcrumb {
|
||||
background: #f8f9fa;
|
||||
padding: 15px 30px;
|
||||
border-bottom: 1px solid #dc2626;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.breadcrumb a {
|
||||
color: #dc2626;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.breadcrumb a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.breadcrumb span {
|
||||
color: #6b7280;
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
/* 헤더 */
|
||||
.page-header {
|
||||
text-align: center;
|
||||
padding: 40px 30px;
|
||||
border-bottom: 1px solid #dc2626;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
color: #dc2626;
|
||||
font-size: 2rem;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
color: #6b7280;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* 메인 컨텐츠 */
|
||||
.main-content {
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
/* 문서 목록 */
|
||||
.document-list {
|
||||
display: grid;
|
||||
gap: 15px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.document-item {
|
||||
background: white;
|
||||
border: 1px solid #dc2626;
|
||||
padding: 25px;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 1px 3px rgba(220, 38, 38, 0.1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.document-item:hover {
|
||||
border-color: #991b1b;
|
||||
box-shadow: 0 4px 12px rgba(220, 38, 38, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.document-content h3 {
|
||||
color: #111827;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.document-content p {
|
||||
color: #6b7280;
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.document-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 검색 섹션 */
|
||||
.search-section {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dc2626;
|
||||
padding: 25px;
|
||||
text-align: center;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.search-section h3 {
|
||||
color: #111827;
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
flex: 1;
|
||||
padding: 12px 15px;
|
||||
background: white;
|
||||
border: 1px solid #dc2626;
|
||||
color: #111827;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-box input::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.search-box input:focus {
|
||||
border-color: #991b1b;
|
||||
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.1);
|
||||
}
|
||||
|
||||
.search-box button {
|
||||
background: white;
|
||||
color: #dc2626;
|
||||
border: 1px solid #dc2626;
|
||||
padding: 12px 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.search-box button:hover {
|
||||
background: #dc2626;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 푸터 */
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
border-top: 1px solid #dc2626;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.footer p {
|
||||
color: #6b7280;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
padding: 25px 20px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- 브레드크럼 네비게이션 -->
|
||||
<nav class="breadcrumb">
|
||||
<a href="index.html">홈</a>
|
||||
<span>></span>
|
||||
<strong>기술문서</strong>
|
||||
</nav>
|
||||
|
||||
<!-- 페이지 헤더 -->
|
||||
<header class="page-header">
|
||||
<h2>기술문서</h2>
|
||||
<p>Technical Documents - 기술 표준 및 가이드라인</p>
|
||||
</header>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="main-content">
|
||||
<div class="document-list">
|
||||
<!-- 설계표준 -->
|
||||
<div class="document-item" onclick="location.href='technical/TD-001.html'">
|
||||
<div class="document-content">
|
||||
<h3>TD-001 배관설계 표준</h3>
|
||||
<p>배관 설계 기준 및 표준 사양서</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 용접절차서 -->
|
||||
<div class="document-item" onclick="location.href='technical/TD-002.html'">
|
||||
<div class="document-content">
|
||||
<h3>TD-002 용접절차서 (WPS)</h3>
|
||||
<p>배관 용접 절차 및 품질 기준</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 재료사양서 -->
|
||||
<div class="document-item" onclick="location.href='technical/TD-003.html'">
|
||||
<div class="document-content">
|
||||
<h3>TD-003 재료사양서</h3>
|
||||
<p>배관재료 규격 및 선정 기준</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CAD 표준 -->
|
||||
<div class="document-item" onclick="location.href='technical/TD-004.html'">
|
||||
<div class="document-content">
|
||||
<h3>TD-004 CAD 도면 표준</h3>
|
||||
<p>도면 작성 기준 및 CAD 표준</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 검사기준서 -->
|
||||
<div class="document-item" onclick="location.href='technical/TD-005.html'">
|
||||
<div class="document-content">
|
||||
<h3>TD-005 배관 검사기준서</h3>
|
||||
<p>배관 제작 및 설치 검사 기준</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 압력시험절차 -->
|
||||
<div class="document-item" onclick="location.href='technical/TD-006.html'">
|
||||
<div class="document-content">
|
||||
<h3>TD-006 압력시험 절차서</h3>
|
||||
<p>배관계통 압력시험 절차 및 기준</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BOM 시스템 매뉴얼 -->
|
||||
<div class="document-item" onclick="location.href='technical/TD-007.html'">
|
||||
<div class="document-content">
|
||||
<h3>TD-007 BOM 시스템 사용자 매뉴얼</h3>
|
||||
<p>자재관리 시스템 사용 가이드</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 검색 섹션 -->
|
||||
<div class="search-section">
|
||||
<h3>기술문서 검색</h3>
|
||||
<div class="search-box">
|
||||
<input type="text" id="techSearchInput" placeholder="기술문서 검색 (예: 설계, 용접, CAD)">
|
||||
<button onclick="filterDocuments(document.getElementById('techSearchInput').value)">검색</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<p>테크니컬코리아 내부 전용 문서시스템</p>
|
||||
<p>기술 문의: tech@technicalkorea.co.kr | 내선: 4567</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 문서 필터링 기능
|
||||
function filterDocuments(searchTerm) {
|
||||
const documents = document.querySelectorAll('.document-item');
|
||||
const term = searchTerm.toLowerCase();
|
||||
|
||||
documents.forEach(doc => {
|
||||
const title = doc.querySelector('h3').textContent.toLowerCase();
|
||||
const description = doc.querySelector('p').textContent.toLowerCase();
|
||||
|
||||
if (title.includes(term) || description.includes(term)) {
|
||||
doc.style.display = 'block';
|
||||
} else {
|
||||
doc.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Enter 키로 검색
|
||||
document.getElementById('techSearchInput').addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
filterDocuments(this.value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
synology_deployment/web-ui/img/favicon.png
Normal file
BIN
synology_deployment/web-ui/img/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
synology_deployment/web-ui/img/login-bg.jpeg
Normal file
BIN
synology_deployment/web-ui/img/login-bg.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
BIN
synology_deployment/web-ui/img/logo.png
Normal file
BIN
synology_deployment/web-ui/img/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
synology_deployment/web-ui/img/technicalkorea Logo.jpg
Normal file
BIN
synology_deployment/web-ui/img/technicalkorea Logo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
28071
synology_deployment/web-ui/img/technicalkorea_Logo.ai
Normal file
28071
synology_deployment/web-ui/img/technicalkorea_Logo.ai
Normal file
File diff suppressed because it is too large
Load Diff
28071
synology_deployment/web-ui/img/technicalkorea_Logo.eps
Normal file
28071
synology_deployment/web-ui/img/technicalkorea_Logo.eps
Normal file
File diff suppressed because it is too large
Load Diff
3390
synology_deployment/web-ui/img/technicalkorea_Logo.svg
Normal file
3390
synology_deployment/web-ui/img/technicalkorea_Logo.svg
Normal file
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 369 KiB |
29
synology_deployment/web-ui/index.html
Normal file
29
synology_deployment/web-ui/index.html
Normal file
@@ -0,0 +1,29 @@
|
||||
<!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/login.css" />
|
||||
<link rel="icon" type="image/png" href="img/favicon.png">
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-container">
|
||||
<img src="img/logo.png" alt="테크니컬코리아 로고" class="logo" />
|
||||
<h1>(주)테크니컬코리아</h1>
|
||||
<h3>생산팀 포털 로그인</h3>
|
||||
<form id="loginForm">
|
||||
<input type="text" id="username" placeholder="아이디" required autocomplete="username" />
|
||||
<input type="password" id="password" placeholder="비밀번호" required autocomplete="current-password" />
|
||||
<button type="submit">로그인</button>
|
||||
</form>
|
||||
<div id="error" class="error-message"></div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 스크립트 로딩 (순서 중요) -->
|
||||
<script src="js/api-config.js"></script>
|
||||
<script src="js/api-helper.js"></script>
|
||||
<script src="js/login.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
534
synology_deployment/web-ui/js/admin-settings.js
Normal file
534
synology_deployment/web-ui/js/admin-settings.js
Normal file
@@ -0,0 +1,534 @@
|
||||
// admin-settings.js - 관리자 설정 페이지
|
||||
|
||||
// 전역 변수
|
||||
let currentUser = null;
|
||||
let users = [];
|
||||
let filteredUsers = [];
|
||||
let currentEditingUser = null;
|
||||
|
||||
// DOM 요소
|
||||
const elements = {
|
||||
// 시간
|
||||
timeValue: document.getElementById('timeValue'),
|
||||
|
||||
// 사용자 정보
|
||||
userName: document.getElementById('userName'),
|
||||
userRole: document.getElementById('userRole'),
|
||||
userInitial: document.getElementById('userInitial'),
|
||||
|
||||
// 검색 및 필터
|
||||
userSearch: document.getElementById('userSearch'),
|
||||
filterButtons: document.querySelectorAll('.filter-btn'),
|
||||
|
||||
// 테이블
|
||||
usersTableBody: document.getElementById('usersTableBody'),
|
||||
emptyState: document.getElementById('emptyState'),
|
||||
|
||||
// 버튼
|
||||
addUserBtn: document.getElementById('addUserBtn'),
|
||||
saveUserBtn: document.getElementById('saveUserBtn'),
|
||||
confirmDeleteBtn: document.getElementById('confirmDeleteBtn'),
|
||||
|
||||
// 모달
|
||||
userModal: document.getElementById('userModal'),
|
||||
deleteModal: document.getElementById('deleteModal'),
|
||||
modalTitle: document.getElementById('modalTitle'),
|
||||
|
||||
// 폼
|
||||
userForm: document.getElementById('userForm'),
|
||||
userNameInput: document.getElementById('userName'),
|
||||
userIdInput: document.getElementById('userId'),
|
||||
userPasswordInput: document.getElementById('userPassword'),
|
||||
userRoleSelect: document.getElementById('userRole'),
|
||||
userEmailInput: document.getElementById('userEmail'),
|
||||
userPhoneInput: document.getElementById('userPhone'),
|
||||
passwordGroup: document.getElementById('passwordGroup'),
|
||||
|
||||
// 토스트
|
||||
toastContainer: document.getElementById('toastContainer')
|
||||
};
|
||||
|
||||
// ========== 초기화 ========== //
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
console.log('🔧 관리자 설정 페이지 초기화 시작');
|
||||
|
||||
try {
|
||||
await initializePage();
|
||||
console.log('✅ 관리자 설정 페이지 초기화 완료');
|
||||
} catch (error) {
|
||||
console.error('❌ 페이지 초기화 오류:', error);
|
||||
showToast('페이지를 불러오는 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
async function initializePage() {
|
||||
// 이벤트 리스너 설정
|
||||
setupEventListeners();
|
||||
|
||||
// 사용자 목록 로드
|
||||
await loadUsers();
|
||||
}
|
||||
|
||||
// ========== 사용자 정보 설정 ========== //
|
||||
function setupUserInfo() {
|
||||
const authData = getAuthData();
|
||||
if (authData && authData.user) {
|
||||
currentUser = authData.user;
|
||||
|
||||
// 사용자 이름 설정
|
||||
if (elements.userName) {
|
||||
elements.userName.textContent = currentUser.name || currentUser.username;
|
||||
}
|
||||
|
||||
// 사용자 역할 설정
|
||||
const roleMap = {
|
||||
'admin': '관리자',
|
||||
'system': '시스템 관리자',
|
||||
'leader': '그룹장',
|
||||
'user': '작업자'
|
||||
};
|
||||
if (elements.userRole) {
|
||||
elements.userRole.textContent = roleMap[currentUser.role] || '작업자';
|
||||
}
|
||||
|
||||
// 아바타 초기값 설정
|
||||
if (elements.userInitial) {
|
||||
const initial = (currentUser.name || currentUser.username).charAt(0);
|
||||
elements.userInitial.textContent = initial;
|
||||
}
|
||||
|
||||
console.log('👤 사용자 정보 설정 완료:', currentUser.name);
|
||||
}
|
||||
}
|
||||
|
||||
function getAuthData() {
|
||||
const token = localStorage.getItem('token');
|
||||
const user = localStorage.getItem('user');
|
||||
return {
|
||||
token,
|
||||
user: user ? JSON.parse(user) : null
|
||||
};
|
||||
}
|
||||
|
||||
// ========== 시간 업데이트 ========== //
|
||||
function updateCurrentTime() {
|
||||
const now = new Date();
|
||||
const timeString = now.toLocaleTimeString('ko-KR', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
if (elements.timeValue) {
|
||||
elements.timeValue.textContent = timeString;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 이벤트 리스너 ========== //
|
||||
function setupEventListeners() {
|
||||
// 검색
|
||||
if (elements.userSearch) {
|
||||
elements.userSearch.addEventListener('input', handleSearch);
|
||||
}
|
||||
|
||||
// 필터 버튼
|
||||
elements.filterButtons.forEach(btn => {
|
||||
btn.addEventListener('click', handleFilter);
|
||||
});
|
||||
|
||||
// 사용자 추가 버튼
|
||||
if (elements.addUserBtn) {
|
||||
elements.addUserBtn.addEventListener('click', openAddUserModal);
|
||||
}
|
||||
|
||||
// 사용자 저장 버튼
|
||||
if (elements.saveUserBtn) {
|
||||
elements.saveUserBtn.addEventListener('click', saveUser);
|
||||
}
|
||||
|
||||
// 삭제 확인 버튼
|
||||
if (elements.confirmDeleteBtn) {
|
||||
elements.confirmDeleteBtn.addEventListener('click', confirmDeleteUser);
|
||||
}
|
||||
|
||||
// 로그아웃 버튼
|
||||
const logoutBtn = document.getElementById('logoutBtn');
|
||||
if (logoutBtn) {
|
||||
logoutBtn.addEventListener('click', handleLogout);
|
||||
}
|
||||
|
||||
// 프로필 드롭다운
|
||||
const userProfile = document.getElementById('userProfile');
|
||||
const profileMenu = document.getElementById('profileMenu');
|
||||
if (userProfile && profileMenu) {
|
||||
userProfile.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
profileMenu.style.display = profileMenu.style.display === 'block' ? 'none' : 'block';
|
||||
});
|
||||
|
||||
document.addEventListener('click', () => {
|
||||
profileMenu.style.display = 'none';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 사용자 관리 ========== //
|
||||
async function loadUsers() {
|
||||
try {
|
||||
console.log('👥 사용자 목록 로딩...');
|
||||
|
||||
// 실제 API에서 사용자 데이터 가져오기
|
||||
const response = await window.apiCall('/users');
|
||||
users = Array.isArray(response) ? response : (response.data || []);
|
||||
|
||||
console.log(`✅ 사용자 ${users.length}명 로드 완료`);
|
||||
|
||||
// 필터링된 사용자 목록 초기화
|
||||
filteredUsers = [...users];
|
||||
|
||||
// 테이블 렌더링
|
||||
renderUsersTable();
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 사용자 목록 로딩 오류:', error);
|
||||
showToast('사용자 목록을 불러오는 중 오류가 발생했습니다.', 'error');
|
||||
users = [];
|
||||
filteredUsers = [];
|
||||
renderUsersTable();
|
||||
}
|
||||
}
|
||||
|
||||
function renderUsersTable() {
|
||||
if (!elements.usersTableBody) return;
|
||||
|
||||
if (filteredUsers.length === 0) {
|
||||
elements.usersTableBody.innerHTML = '';
|
||||
if (elements.emptyState) {
|
||||
elements.emptyState.style.display = 'block';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (elements.emptyState) {
|
||||
elements.emptyState.style.display = 'none';
|
||||
}
|
||||
|
||||
elements.usersTableBody.innerHTML = filteredUsers.map(user => `
|
||||
<tr>
|
||||
<td>
|
||||
<div class="user-info">
|
||||
<div class="user-avatar-small">${(user.name || user.username).charAt(0)}</div>
|
||||
<div class="user-details">
|
||||
<h4>${user.name || user.username}</h4>
|
||||
<p>${user.email || '이메일 없음'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td><strong>${user.username}</strong></td>
|
||||
<td>
|
||||
<span class="role-badge ${user.role}">
|
||||
${getRoleIcon(user.role)} ${getRoleName(user.role)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="status-badge ${user.is_active ? 'active' : 'inactive'}">
|
||||
${user.is_active ? '활성' : '비활성'}
|
||||
</span>
|
||||
</td>
|
||||
<td>${formatDate(user.last_login) || '로그인 기록 없음'}</td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
<button class="action-btn edit" onclick="editUser(${user.user_id})">
|
||||
수정
|
||||
</button>
|
||||
<button class="action-btn toggle" onclick="toggleUserStatus(${user.user_id})">
|
||||
${user.is_active ? '비활성화' : '활성화'}
|
||||
</button>
|
||||
<button class="action-btn delete" onclick="deleteUser(${user.user_id})">
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function getRoleIcon(role) {
|
||||
const icons = {
|
||||
admin: '👑',
|
||||
leader: '👨💼',
|
||||
user: '👤'
|
||||
};
|
||||
return icons[role] || '👤';
|
||||
}
|
||||
|
||||
function getRoleName(role) {
|
||||
const names = {
|
||||
admin: '관리자',
|
||||
leader: '그룹장',
|
||||
user: '작업자'
|
||||
};
|
||||
return names[role] || '작업자';
|
||||
}
|
||||
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return null;
|
||||
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
// ========== 검색 및 필터링 ========== //
|
||||
function handleSearch(e) {
|
||||
const searchTerm = e.target.value.toLowerCase();
|
||||
|
||||
filteredUsers = users.filter(user => {
|
||||
return (user.name && user.name.toLowerCase().includes(searchTerm)) ||
|
||||
(user.username && user.username.toLowerCase().includes(searchTerm)) ||
|
||||
(user.email && user.email.toLowerCase().includes(searchTerm));
|
||||
});
|
||||
|
||||
renderUsersTable();
|
||||
}
|
||||
|
||||
function handleFilter(e) {
|
||||
const filterType = e.target.dataset.filter;
|
||||
|
||||
// 활성 버튼 변경
|
||||
elements.filterButtons.forEach(btn => btn.classList.remove('active'));
|
||||
e.target.classList.add('active');
|
||||
|
||||
// 필터링
|
||||
if (filterType === 'all') {
|
||||
filteredUsers = [...users];
|
||||
} else {
|
||||
filteredUsers = users.filter(user => user.role === filterType);
|
||||
}
|
||||
|
||||
renderUsersTable();
|
||||
}
|
||||
|
||||
// ========== 모달 관리 ========== //
|
||||
function openAddUserModal() {
|
||||
currentEditingUser = null;
|
||||
|
||||
if (elements.modalTitle) {
|
||||
elements.modalTitle.textContent = '새 사용자 추가';
|
||||
}
|
||||
|
||||
// 폼 초기화
|
||||
if (elements.userForm) {
|
||||
elements.userForm.reset();
|
||||
}
|
||||
|
||||
// 비밀번호 필드 표시
|
||||
if (elements.passwordGroup) {
|
||||
elements.passwordGroup.style.display = 'block';
|
||||
}
|
||||
|
||||
if (elements.userPasswordInput) {
|
||||
elements.userPasswordInput.required = true;
|
||||
}
|
||||
|
||||
if (elements.userModal) {
|
||||
elements.userModal.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
function editUser(userId) {
|
||||
const user = users.find(u => u.user_id === userId);
|
||||
if (!user) return;
|
||||
|
||||
currentEditingUser = user;
|
||||
|
||||
if (elements.modalTitle) {
|
||||
elements.modalTitle.textContent = '사용자 정보 수정';
|
||||
}
|
||||
|
||||
// 폼에 데이터 채우기
|
||||
if (elements.userNameInput) elements.userNameInput.value = user.name || '';
|
||||
if (elements.userIdInput) elements.userIdInput.value = user.username || '';
|
||||
if (elements.userRoleSelect) elements.userRoleSelect.value = user.role || '';
|
||||
if (elements.userEmailInput) elements.userEmailInput.value = user.email || '';
|
||||
if (elements.userPhoneInput) elements.userPhoneInput.value = user.phone || '';
|
||||
|
||||
// 비밀번호 필드 숨기기 (수정 시에는 선택사항)
|
||||
if (elements.passwordGroup) {
|
||||
elements.passwordGroup.style.display = 'none';
|
||||
}
|
||||
|
||||
if (elements.userPasswordInput) {
|
||||
elements.userPasswordInput.required = false;
|
||||
}
|
||||
|
||||
if (elements.userModal) {
|
||||
elements.userModal.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
function closeUserModal() {
|
||||
if (elements.userModal) {
|
||||
elements.userModal.style.display = 'none';
|
||||
}
|
||||
currentEditingUser = null;
|
||||
}
|
||||
|
||||
function deleteUser(userId) {
|
||||
const user = users.find(u => u.user_id === userId);
|
||||
if (!user) return;
|
||||
|
||||
currentEditingUser = user;
|
||||
|
||||
if (elements.deleteModal) {
|
||||
elements.deleteModal.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
function closeDeleteModal() {
|
||||
if (elements.deleteModal) {
|
||||
elements.deleteModal.style.display = 'none';
|
||||
}
|
||||
currentEditingUser = null;
|
||||
}
|
||||
|
||||
// ========== 사용자 CRUD ========== //
|
||||
async function saveUser() {
|
||||
try {
|
||||
const formData = {
|
||||
name: elements.userNameInput?.value,
|
||||
username: elements.userIdInput?.value,
|
||||
role: elements.userRoleSelect?.value,
|
||||
email: elements.userEmailInput?.value,
|
||||
phone: elements.userPhoneInput?.value
|
||||
};
|
||||
|
||||
// 유효성 검사
|
||||
if (!formData.name || !formData.username || !formData.role) {
|
||||
showToast('필수 항목을 모두 입력해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 비밀번호 처리
|
||||
if (!currentEditingUser && elements.userPasswordInput?.value) {
|
||||
formData.password = elements.userPasswordInput.value;
|
||||
} else if (currentEditingUser && elements.userPasswordInput?.value) {
|
||||
formData.password = elements.userPasswordInput.value;
|
||||
}
|
||||
|
||||
let response;
|
||||
if (currentEditingUser) {
|
||||
// 수정
|
||||
response = await window.apiCall(`/users/${currentEditingUser.user_id}`, 'PUT', formData);
|
||||
} else {
|
||||
// 생성
|
||||
response = await window.apiCall('/users', 'POST', formData);
|
||||
}
|
||||
|
||||
if (response.success || response.user_id) {
|
||||
const action = currentEditingUser ? '수정' : '생성';
|
||||
showToast(`사용자가 성공적으로 ${action}되었습니다.`, 'success');
|
||||
|
||||
closeUserModal();
|
||||
await loadUsers();
|
||||
} else {
|
||||
throw new Error(response.message || '사용자 저장에 실패했습니다.');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('사용자 저장 오류:', error);
|
||||
showToast(`사용자 저장 중 오류가 발생했습니다: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteUser() {
|
||||
if (!currentEditingUser) return;
|
||||
|
||||
try {
|
||||
const response = await window.apiCall(`/users/${currentEditingUser.user_id}`, 'DELETE');
|
||||
|
||||
if (response.success) {
|
||||
showToast('사용자가 성공적으로 삭제되었습니다.', 'success');
|
||||
closeDeleteModal();
|
||||
await loadUsers();
|
||||
} else {
|
||||
throw new Error(response.message || '사용자 삭제에 실패했습니다.');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('사용자 삭제 오류:', error);
|
||||
showToast(`사용자 삭제 중 오류가 발생했습니다: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleUserStatus(userId) {
|
||||
try {
|
||||
const user = users.find(u => u.user_id === userId);
|
||||
if (!user) return;
|
||||
|
||||
const newStatus = !user.is_active;
|
||||
const response = await window.apiCall(`/users/${userId}/status`, 'PUT', { is_active: newStatus });
|
||||
|
||||
if (response.success) {
|
||||
const action = newStatus ? '활성화' : '비활성화';
|
||||
showToast(`사용자가 성공적으로 ${action}되었습니다.`, 'success');
|
||||
await loadUsers();
|
||||
} else {
|
||||
throw new Error(response.message || '사용자 상태 변경에 실패했습니다.');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('사용자 상태 변경 오류:', error);
|
||||
showToast(`사용자 상태 변경 중 오류가 발생했습니다: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 로그아웃 ========== //
|
||||
function handleLogout() {
|
||||
if (confirm('로그아웃하시겠습니까?')) {
|
||||
localStorage.clear();
|
||||
window.location.href = '/index.html';
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 토스트 알림 ========== //
|
||||
function showToast(message, type = 'info', duration = 3000) {
|
||||
if (!elements.toastContainer) return;
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type}`;
|
||||
|
||||
const iconMap = {
|
||||
success: '✅',
|
||||
error: '❌',
|
||||
warning: '⚠️',
|
||||
info: 'ℹ️'
|
||||
};
|
||||
|
||||
toast.innerHTML = `
|
||||
<div class="toast-icon">${iconMap[type] || 'ℹ️'}</div>
|
||||
<div class="toast-message">${message}</div>
|
||||
<button class="toast-close" onclick="this.parentElement.remove()">×</button>
|
||||
`;
|
||||
|
||||
elements.toastContainer.appendChild(toast);
|
||||
|
||||
// 자동 제거
|
||||
setTimeout(() => {
|
||||
if (toast.parentElement) {
|
||||
toast.remove();
|
||||
}
|
||||
}, duration);
|
||||
}
|
||||
|
||||
// ========== 전역 함수 (HTML에서 호출) ========== //
|
||||
window.editUser = editUser;
|
||||
window.deleteUser = deleteUser;
|
||||
window.toggleUserStatus = toggleUserStatus;
|
||||
window.closeUserModal = closeUserModal;
|
||||
window.closeDeleteModal = closeDeleteModal;
|
||||
34
synology_deployment/web-ui/js/admin.js
Normal file
34
synology_deployment/web-ui/js/admin.js
Normal file
@@ -0,0 +1,34 @@
|
||||
// ✅ /js/admin.js (수정됨 - 중복 로딩 제거)
|
||||
async function initDashboard() {
|
||||
// 로그인 토큰 확인
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
location.href = '/index.html';
|
||||
return;
|
||||
}
|
||||
|
||||
// ✅ navbar, sidebar는 각각의 모듈에서 처리하도록 변경
|
||||
// load-navbar.js, load-sidebar.js가 자동으로 처리함
|
||||
|
||||
// ✅ 콘텐츠만 직접 로딩 (admin-sections.html이 자동 로딩됨)
|
||||
console.log('관리자 대시보드 초기화 완료');
|
||||
}
|
||||
|
||||
// ✅ 보조 함수 - 필요시 수동 컴포넌트 로딩용
|
||||
async function loadComponent(id, url) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const html = await res.text();
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
element.innerHTML = html;
|
||||
} else {
|
||||
console.warn(`요소를 찾을 수 없습니다: ${id}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`컴포넌트 로딩 실패 (${url}):`, err);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initDashboard);
|
||||
198
synology_deployment/web-ui/js/api-config.js
Normal file
198
synology_deployment/web-ui/js/api-config.js
Normal file
@@ -0,0 +1,198 @@
|
||||
// api-config.js - nginx 프록시 대응 API 설정
|
||||
|
||||
function getApiBaseUrl() {
|
||||
const hostname = window.location.hostname;
|
||||
const protocol = window.location.protocol;
|
||||
const port = window.location.port;
|
||||
|
||||
console.log('🌐 감지된 환경:', { hostname, protocol, port });
|
||||
|
||||
// 🔗 nginx 프록시를 통한 접근 (권장)
|
||||
// nginx가 /api/ 요청을 백엔드로 프록시하므로 포트 없이 접근
|
||||
if (hostname.startsWith('192.168.') || hostname.startsWith('10.') || hostname.startsWith('172.') ||
|
||||
hostname === 'localhost' || hostname === '127.0.0.1' ||
|
||||
hostname.includes('.local') || hostname.includes('hyungi')) {
|
||||
|
||||
// 현재 웹서버의 도메인/IP를 그대로 사용하되 API 포트(20005)로 직접 연결
|
||||
const baseUrl = `${protocol}//${hostname}:20005/api`;
|
||||
|
||||
console.log('✅ nginx 프록시 사용:', baseUrl);
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
// 🚨 백업: 직접 접근 (nginx 프록시 실패시에만)
|
||||
console.warn('⚠️ 직접 API 접근 (백업 모드)');
|
||||
return `${protocol}//${hostname}:20005/api`;
|
||||
}
|
||||
|
||||
// API 설정
|
||||
const API_URL = getApiBaseUrl();
|
||||
|
||||
// 전역 변수로 설정
|
||||
window.API = API_URL;
|
||||
window.API_BASE_URL = API_URL;
|
||||
|
||||
function ensureAuthenticated() {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token || token === 'undefined' || token === 'null') {
|
||||
console.log('🚨 인증되지 않은 사용자. 로그인 페이지로 이동합니다.');
|
||||
clearAuthData(); // 만약을 위해 한번 더 정리
|
||||
window.location.href = '/index.html';
|
||||
return false; // 이후 코드 실행 방지
|
||||
}
|
||||
|
||||
// 토큰 만료 확인
|
||||
if (isTokenExpired(token)) {
|
||||
console.log('🚨 토큰이 만료되었습니다. 로그인 페이지로 이동합니다.');
|
||||
clearAuthData();
|
||||
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
|
||||
window.location.href = '/index.html';
|
||||
return false;
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
// 토큰 만료 확인 함수
|
||||
function isTokenExpired(token) {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
return payload.exp < currentTime;
|
||||
} catch (error) {
|
||||
console.error('토큰 파싱 오류:', error);
|
||||
return true; // 파싱 실패 시 만료된 것으로 간주
|
||||
}
|
||||
}
|
||||
|
||||
// 인증 데이터 정리 함수
|
||||
function clearAuthData() {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('userInfo');
|
||||
localStorage.removeItem('currentUser');
|
||||
}
|
||||
|
||||
function getAuthHeaders() {
|
||||
const token = localStorage.getItem('token');
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
};
|
||||
}
|
||||
|
||||
// 🔧 개선된 API 호출 함수 (에러 처리 강화)
|
||||
async function apiCall(url, method = 'GET', data = null) {
|
||||
// 상대 경로를 절대 경로로 변환
|
||||
const fullUrl = url.startsWith('http') ? url : `${API}${url}`;
|
||||
|
||||
const options = {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getAuthHeaders()
|
||||
}
|
||||
};
|
||||
|
||||
// POST/PUT 요청시 데이터 추가
|
||||
if (data && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
|
||||
options.body = JSON.stringify(data);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`📡 API 호출: ${fullUrl} (${method})`);
|
||||
const response = await fetch(fullUrl, options);
|
||||
|
||||
// 인증 만료 처리
|
||||
if (response.status === 401) {
|
||||
console.error('🚨 인증 실패: 토큰이 만료되었거나 유효하지 않습니다.');
|
||||
clearAuthData();
|
||||
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
|
||||
window.location.href = '/index.html';
|
||||
throw new Error('인증에 실패했습니다.');
|
||||
}
|
||||
|
||||
// 응답 실패 처리
|
||||
if (!response.ok) {
|
||||
let errorMessage = `HTTP ${response.status}`;
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
errorMessage = errorData.error || errorData.message || errorMessage;
|
||||
} catch (e) {
|
||||
// JSON 파싱 실패시 기본 메시지 사용
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log(`✅ API 성공: ${fullUrl}`);
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ API 오류 (${fullUrl}):`, error);
|
||||
|
||||
// 네트워크 오류 vs 서버 오류 구분
|
||||
if (error.name === 'TypeError' && error.message.includes('fetch')) {
|
||||
throw new Error('네트워크 연결 오류입니다. 인터넷 연결을 확인해주세요.');
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 디버깅 정보
|
||||
console.log('🔗 API Base URL:', API);
|
||||
console.log('🌐 Current Location:', {
|
||||
hostname: window.location.hostname,
|
||||
protocol: window.location.protocol,
|
||||
port: window.location.port,
|
||||
href: window.location.href
|
||||
});
|
||||
|
||||
// 🧪 API 연결 테스트 함수 (개발용)
|
||||
async function testApiConnection() {
|
||||
try {
|
||||
console.log('🧪 API 연결 테스트 시작...');
|
||||
const response = await fetch(`${API}/health`, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
console.log('✅ API 연결 성공!');
|
||||
return true;
|
||||
} else {
|
||||
console.log('❌ API 연결 실패:', response.status);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('❌ API 연결 오류:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 함수로 설정
|
||||
window.ensureAuthenticated = ensureAuthenticated;
|
||||
window.getAuthHeaders = getAuthHeaders;
|
||||
window.apiCall = apiCall;
|
||||
window.testApiConnection = testApiConnection;
|
||||
window.isTokenExpired = isTokenExpired;
|
||||
window.clearAuthData = clearAuthData;
|
||||
|
||||
// 개발 모드에서 자동 테스트
|
||||
if (window.location.hostname === 'localhost' || window.location.hostname.startsWith('192.168.')) {
|
||||
setTimeout(() => {
|
||||
testApiConnection();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// 주기적으로 토큰 만료 확인 (5분마다)
|
||||
setInterval(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token && isTokenExpired(token)) {
|
||||
console.log('🚨 주기적 확인: 토큰이 만료되었습니다.');
|
||||
clearAuthData();
|
||||
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
|
||||
window.location.href = '/index.html';
|
||||
}
|
||||
}, 5 * 60 * 1000); // 5분마다 확인
|
||||
136
synology_deployment/web-ui/js/api-helper.js
Normal file
136
synology_deployment/web-ui/js/api-helper.js
Normal file
@@ -0,0 +1,136 @@
|
||||
// /public/js/api-helper.js
|
||||
// ES6 모듈 의존성 제거 - 브라우저 호환성 개선
|
||||
|
||||
// API 설정 (window 객체에서 가져오기)
|
||||
const API_BASE_URL = window.API_BASE_URL || 'http://localhost:20005/api';
|
||||
|
||||
// 인증 관련 함수들 (직접 구현)
|
||||
function getToken() {
|
||||
const token = localStorage.getItem('token');
|
||||
return token && token !== 'undefined' && token !== 'null' ? token : null;
|
||||
}
|
||||
|
||||
function clearAuthData() {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 API를 호출합니다. (인증이 필요 없는 public 요청)
|
||||
* @param {string} username - 사용자 아이디
|
||||
* @param {string} password - 사용자 비밀번호
|
||||
* @returns {Promise<object>} - API 응답 결과
|
||||
*/
|
||||
async function login(username, password) {
|
||||
const response = await fetch(`${API_BASE_URL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok) {
|
||||
// API 에러 응답을 그대로 에러 객체로 던져서 호출부에서 처리하도록 함
|
||||
throw new Error(result.error || '로그인에 실패했습니다.');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 인증이 필요한 API 요청을 위한 fetch 래퍼 함수
|
||||
* @param {string} endpoint - /로 시작하는 API 엔드포인트
|
||||
* @param {object} options - fetch 함수에 전달할 옵션
|
||||
* @returns {Promise<Response>} - fetch 응답 객체
|
||||
*/
|
||||
async function authFetch(endpoint, options = {}) {
|
||||
const token = getToken();
|
||||
|
||||
if (!token) {
|
||||
console.error('토큰이 없습니다. 로그인이 필요합니다.');
|
||||
clearAuthData(); // 인증 정보 정리
|
||||
window.location.href = '/index.html'; // 로그인 페이지로 리디렉션
|
||||
// 에러를 던져서 후속 실행을 중단
|
||||
throw new Error('인증 토큰이 없습니다.');
|
||||
}
|
||||
|
||||
const defaultHeaders = {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
...defaultHeaders,
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
// 401 Unauthorized 에러 발생 시, 토큰이 유효하지 않다는 의미
|
||||
if (response.status === 401) {
|
||||
console.error('인증 실패. 토큰이 만료되었거나 유효하지 않습니다.');
|
||||
clearAuthData(); // 만료된 인증 정보 정리
|
||||
window.location.href = '/index.html';
|
||||
throw new Error('인증에 실패했습니다.');
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// 공통 API 요청 함수들
|
||||
|
||||
/**
|
||||
* GET 요청 헬퍼
|
||||
* @param {string} endpoint - API 엔드포인트
|
||||
*/
|
||||
async function apiGet(endpoint) {
|
||||
const response = await authFetch(endpoint);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* POST 요청 헬퍼
|
||||
* @param {string} endpoint - API 엔드포인트
|
||||
* @param {object} data - 전송할 데이터
|
||||
*/
|
||||
async function apiPost(endpoint, data) {
|
||||
const response = await authFetch(endpoint, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT 요청 헬퍼
|
||||
* @param {string} endpoint - API 엔드포인트
|
||||
* @param {object} data - 전송할 데이터
|
||||
*/
|
||||
async function apiPut(endpoint, data) {
|
||||
const response = await authFetch(endpoint, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE 요청 헬퍼
|
||||
* @param {string} endpoint - API 엔드포인트
|
||||
*/
|
||||
async function apiDelete(endpoint) {
|
||||
const response = await authFetch(endpoint, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// 전역 함수로 설정
|
||||
window.login = login;
|
||||
window.apiGet = apiGet;
|
||||
window.apiPost = apiPost;
|
||||
window.apiPut = apiPut;
|
||||
window.apiDelete = apiDelete;
|
||||
window.getToken = getToken;
|
||||
window.clearAuthData = clearAuthData;
|
||||
1038
synology_deployment/web-ui/js/attendance-validation.js
Normal file
1038
synology_deployment/web-ui/js/attendance-validation.js
Normal file
File diff suppressed because it is too large
Load Diff
170
synology_deployment/web-ui/js/attendance.js
Normal file
170
synology_deployment/web-ui/js/attendance.js
Normal file
@@ -0,0 +1,170 @@
|
||||
import { API, getAuthHeaders } from '/js/api-config.js';
|
||||
|
||||
const yearSel = document.getElementById('year');
|
||||
const monthSel = document.getElementById('month');
|
||||
const container = document.getElementById('attendanceTableContainer');
|
||||
|
||||
const holidays = [
|
||||
'2025-01-01','2025-01-27','2025-01-28','2025-01-29','2025-01-30','2025-01-31',
|
||||
'2025-03-01','2025-03-03','2025-05-01','2025-05-05','2025-05-06',
|
||||
'2025-06-03','2025-06-06','2025-08-15','2025-10-03','2025-10-09','2025-12-25'
|
||||
];
|
||||
|
||||
const leaveDefaults = {
|
||||
'김두수':16,'임영규':16,'반치원':16,'황인용':16,'표영진':15,
|
||||
'김윤섭':16,'이창호':16,'최광욱':16,'박현수':14,'조윤호':0
|
||||
};
|
||||
|
||||
let workers = [];
|
||||
|
||||
// ✅ 셀렉트 박스 옵션 + 기본 선택 추가
|
||||
function fillSelectOptions() {
|
||||
const currentY = new Date().getFullYear();
|
||||
const currentM = String(new Date().getMonth() + 1).padStart(2, '0');
|
||||
|
||||
for (let y = currentY; y <= currentY + 5; y++) {
|
||||
const selected = y === currentY ? 'selected' : '';
|
||||
yearSel.insertAdjacentHTML('beforeend', `<option value="${y}" ${selected}>${y}</option>`);
|
||||
}
|
||||
|
||||
for (let m = 1; m <= 12; m++) {
|
||||
const mm = String(m).padStart(2, '0');
|
||||
const selected = mm === currentM ? 'selected' : '';
|
||||
monthSel.insertAdjacentHTML('beforeend', `<option value="${mm}" ${selected}>${m}월</option>`);
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 작업자 목록 불러오기
|
||||
async function fetchWorkers() {
|
||||
try {
|
||||
const res = await fetch(`${API}/workers`, { headers: getAuthHeaders() });
|
||||
workers = await res.json();
|
||||
workers.sort((a, b) => a.worker_id - b.worker_id);
|
||||
} catch (err) {
|
||||
alert('작업자 불러오기 실패');
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 출근부 불러오기 (해당 연도 전체)
|
||||
async function loadAttendance() {
|
||||
const year = yearSel.value;
|
||||
const month = monthSel.value;
|
||||
if (!year || !month) return alert('연도와 월을 선택하세요');
|
||||
|
||||
const lastDay = new Date(+year, +month, 0).getDate();
|
||||
const start = `${year}-01-01`;
|
||||
const end = `${year}-12-31`;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/workreports?start=${start}&end=${end}`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const data = await res.json();
|
||||
renderTable(data, year, month, lastDay);
|
||||
} catch (err) {
|
||||
alert('출근부 로딩 실패');
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 테이블 렌더링
|
||||
function renderTable(data, year, month, lastDay) {
|
||||
container.innerHTML = '';
|
||||
const weekdays = ['일','월','화','수','목','금','토'];
|
||||
const tbl = document.createElement('table');
|
||||
|
||||
// ⬆️ 헤더 구성
|
||||
let thead = `<thead><tr><th rowspan="2">작업자</th>`;
|
||||
for (let d = 1; d <= lastDay; d++) thead += `<th>${d}</th>`;
|
||||
thead += `<th class="divider" rowspan="2">잔업합계</th><th rowspan="2">사용연차</th><th rowspan="2">잔여연차</th></tr><tr>`;
|
||||
for (let d = 1; d <= lastDay; d++) {
|
||||
const dow = new Date(+year, +month - 1, d).getDay();
|
||||
thead += `<th>${weekdays[dow]}</th>`;
|
||||
}
|
||||
thead += '</tr></thead>';
|
||||
tbl.innerHTML = thead;
|
||||
|
||||
// ⬇️ 본문
|
||||
workers.forEach(w => {
|
||||
// ✅ 월간 데이터 (표에 표시용)
|
||||
const recsThisMonth = data.filter(r =>
|
||||
r.worker_id === w.worker_id &&
|
||||
new Date(r.date).getFullYear() === +year &&
|
||||
new Date(r.date).getMonth() + 1 === +month
|
||||
);
|
||||
|
||||
// ✅ 연간 데이터 (연차 계산용)
|
||||
const recsThisYear = data.filter(r =>
|
||||
r.worker_id === w.worker_id &&
|
||||
new Date(r.date).getFullYear() === +year
|
||||
);
|
||||
|
||||
let otSum = 0;
|
||||
let row = `<tr><td>${w.worker_name}</td>`;
|
||||
|
||||
for (let d = 1; d <= lastDay; d++) {
|
||||
const dd = String(d).padStart(2, '0');
|
||||
const date = `${year}-${month}-${dd}`;
|
||||
|
||||
const rec = recsThisMonth.find(r => {
|
||||
const rDate = new Date(r.date);
|
||||
const yyyy = rDate.getFullYear();
|
||||
const mm = String(rDate.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(rDate.getDate()).padStart(2, '0');
|
||||
return `${yyyy}-${mm}-${dd}` === date;
|
||||
});
|
||||
|
||||
const dow = new Date(+year, +month - 1, d).getDay();
|
||||
const isWe = dow === 0 || dow === 6;
|
||||
const isHo = holidays.includes(date);
|
||||
|
||||
let txt = '', cls = '';
|
||||
if (rec) {
|
||||
const ot = +rec.overtime_hours || 0;
|
||||
if (ot > 0) {
|
||||
txt = ot; cls = 'overtime-cell'; otSum += ot;
|
||||
} else if (rec.work_details) {
|
||||
const d = rec.work_details;
|
||||
if (['연차','반차','반반차','조퇴'].includes(d)) {
|
||||
txt = d; cls = 'leave';
|
||||
} else if (d === '유급') {
|
||||
txt = d; cls = 'paid-leave';
|
||||
} else if (d === '휴무') {
|
||||
txt = d; cls = 'holiday';
|
||||
} else {
|
||||
txt = d;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
txt = (isWe || isHo) ? '휴무' : '';
|
||||
cls = (isWe || isHo) ? 'holiday' : 'no-data';
|
||||
}
|
||||
|
||||
row += `<td class="${cls}">${txt}</td>`;
|
||||
}
|
||||
|
||||
const usedTot = recsThisYear
|
||||
.filter(r => ['연차','반차','반반차','조퇴'].includes(r.work_details))
|
||||
.reduce((s, r) => s + (
|
||||
r.work_details === '연차' ? 1 :
|
||||
r.work_details === '반차' ? 0.5 :
|
||||
r.work_details === '반반차' ? 0.25 : 0.75
|
||||
), 0);
|
||||
|
||||
const remain = (leaveDefaults[w.worker_name] || 0) - usedTot;
|
||||
|
||||
row += `<td class="divider overtime-sum">${otSum.toFixed(1)}</td>`;
|
||||
row += `<td>${usedTot.toFixed(2)}</td><td>${remain.toFixed(2)}</td></tr>`;
|
||||
row += `<tr class="separator"><td colspan="${lastDay + 4}"></td></tr>`;
|
||||
|
||||
tbl.insertAdjacentHTML('beforeend', row);
|
||||
});
|
||||
|
||||
container.appendChild(tbl);
|
||||
}
|
||||
|
||||
// ✅ 초기 로딩
|
||||
fillSelectOptions();
|
||||
fetchWorkers().then(() => {
|
||||
loadAttendance(); // 자동 조회
|
||||
});
|
||||
document.getElementById('loadAttendance').addEventListener('click', loadAttendance);
|
||||
42
synology_deployment/web-ui/js/auth-check.js
Normal file
42
synology_deployment/web-ui/js/auth-check.js
Normal file
@@ -0,0 +1,42 @@
|
||||
// /js/auth-check.js
|
||||
// auth.js의 함수들을 직접 구현 (모듈 의존성 제거)
|
||||
|
||||
function isLoggedIn() {
|
||||
const token = localStorage.getItem('token');
|
||||
return token && token !== 'undefined' && token !== 'null';
|
||||
}
|
||||
|
||||
function getUser() {
|
||||
const user = localStorage.getItem('user');
|
||||
return user ? JSON.parse(user) : null;
|
||||
}
|
||||
|
||||
function clearAuthData() {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
}
|
||||
|
||||
// 즉시 실행 함수로 스코프를 보호하고 로직을 실행
|
||||
(function() {
|
||||
if (!isLoggedIn()) {
|
||||
console.log('🚨 인증되지 않은 사용자. 로그인 페이지로 이동합니다.');
|
||||
clearAuthData(); // 만약을 위해 한번 더 정리
|
||||
window.location.href = '/index.html';
|
||||
return; // 이후 코드 실행 방지
|
||||
}
|
||||
|
||||
const currentUser = getUser();
|
||||
|
||||
// 사용자 정보가 유효한지 확인 (토큰은 있지만 유저 정보가 깨졌을 경우)
|
||||
if (!currentUser || !currentUser.username || !currentUser.role) {
|
||||
console.error('🚨 사용자 정보가 유효하지 않습니다. 강제 로그아웃 처리합니다.');
|
||||
clearAuthData();
|
||||
window.location.href = '/index.html';
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`✅ ${currentUser.username}(${currentUser.role})님 인증 성공.`);
|
||||
|
||||
// 역할 기반 메뉴 제어 로직은 각 컴포넌트 로더(load-navbar.js 등)로 이전함.
|
||||
// 전역 변수 할당(window.currentUser) 제거.
|
||||
})();
|
||||
76
synology_deployment/web-ui/js/auth.js
Normal file
76
synology_deployment/web-ui/js/auth.js
Normal file
@@ -0,0 +1,76 @@
|
||||
// js/auth.js
|
||||
|
||||
/**
|
||||
* JWT 토큰을 디코딩하여 페이로드(내용)를 반환합니다.
|
||||
* @param {string} token - JWT 토큰
|
||||
* @returns {object|null} - 디코딩된 페이로드 객체 또는 파싱 실패 시 null
|
||||
*/
|
||||
export function parseJwt(token) {
|
||||
try {
|
||||
// 토큰의 두 번째 부분(payload)을 base64 디코딩하고 JSON으로 파싱
|
||||
return JSON.parse(atob(token.split('.')[1]));
|
||||
} catch (e) {
|
||||
console.error("잘못된 토큰입니다.", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* localStorage에서 인증 토큰을 가져옵니다.
|
||||
* @returns {string|null} - 저장된 토큰 또는 토큰이 없을 경우 null
|
||||
*/
|
||||
export function getToken() {
|
||||
return localStorage.getItem('token');
|
||||
}
|
||||
|
||||
/**
|
||||
* localStorage에서 사용자 정보를 가져옵니다.
|
||||
* @returns {object|null} - 저장된 사용자 객체 또는 정보가 없을 경우 null
|
||||
*/
|
||||
export function getUser() {
|
||||
const user = localStorage.getItem('user');
|
||||
try {
|
||||
return user ? JSON.parse(user) : null;
|
||||
} catch(e) {
|
||||
console.error("사용자 정보를 파싱하는 데 실패했습니다.", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 성공 후 토큰과 사용자 정보를 localStorage에 저장합니다.
|
||||
* @param {string} token - 서버에서 받은 JWT 토큰
|
||||
* @param {object} user - 서버에서 받은 사용자 정보 객체
|
||||
*/
|
||||
export function saveAuthData(token, user) {
|
||||
localStorage.setItem('token', token);
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그아웃 시 localStorage에서 인증 정보를 제거합니다.
|
||||
*/
|
||||
export function clearAuthData() {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 사용자가 로그인 상태인지 확인합니다.
|
||||
* @returns {boolean} - 로그인 상태이면 true, 아니면 false
|
||||
*/
|
||||
export function isLoggedIn() {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 선택 사항: 토큰 만료 여부 확인 로직 추가 가능
|
||||
// const payload = parseJwt(token);
|
||||
// if (payload && payload.exp * 1000 > Date.now()) {
|
||||
// return true;
|
||||
// }
|
||||
// return false;
|
||||
|
||||
return !!token;
|
||||
}
|
||||
59
synology_deployment/web-ui/js/calendar.js
Normal file
59
synology_deployment/web-ui/js/calendar.js
Normal file
@@ -0,0 +1,59 @@
|
||||
// ✅ /js/calendar.js
|
||||
export function renderCalendar(containerId, onDateSelect) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
|
||||
let currentDate = new Date();
|
||||
let selectedDateStr = '';
|
||||
|
||||
function drawCalendar(date) {
|
||||
container.innerHTML = '';
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth();
|
||||
const firstDay = new Date(year, month, 1).getDay();
|
||||
const lastDate = new Date(year, month + 1, 0).getDate();
|
||||
|
||||
const nav = document.createElement('div');
|
||||
nav.className = 'nav';
|
||||
const prev = document.createElement('button');
|
||||
prev.textContent = '◀';
|
||||
prev.addEventListener('click', () => {
|
||||
currentDate = new Date(year, month - 1, 1);
|
||||
drawCalendar(currentDate);
|
||||
});
|
||||
const title = document.createElement('div');
|
||||
title.innerHTML = `<strong>${year}년 ${month + 1}월</strong>`;
|
||||
const next = document.createElement('button');
|
||||
next.textContent = '▶';
|
||||
next.addEventListener('click', () => {
|
||||
currentDate = new Date(year, month + 1, 1);
|
||||
drawCalendar(currentDate);
|
||||
});
|
||||
nav.append(prev, title, next);
|
||||
container.appendChild(nav);
|
||||
|
||||
['일','월','화','수','목','금','토'].forEach(day => {
|
||||
const el = document.createElement('div');
|
||||
el.innerHTML = `<strong>${day}</strong>`;
|
||||
container.appendChild(el);
|
||||
});
|
||||
|
||||
for (let i = 0; i < firstDay; i++) container.appendChild(document.createElement('div'));
|
||||
|
||||
for (let i = 1; i <= lastDate; i++) {
|
||||
const btn = document.createElement('button');
|
||||
const ymd = `${year}-${String(month + 1).padStart(2, '0')}-${String(i).padStart(2, '0')}`;
|
||||
btn.textContent = i;
|
||||
btn.className = (ymd === selectedDateStr) ? 'selected-date' : '';
|
||||
btn.addEventListener('click', () => {
|
||||
selectedDateStr = ymd;
|
||||
drawCalendar(currentDate);
|
||||
onDateSelect(ymd);
|
||||
});
|
||||
container.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
drawCalendar(currentDate);
|
||||
}
|
||||
|
||||
211
synology_deployment/web-ui/js/change-password.js
Normal file
211
synology_deployment/web-ui/js/change-password.js
Normal file
@@ -0,0 +1,211 @@
|
||||
// js/change-password.js
|
||||
// 개인 비밀번호 변경 페이지 JavaScript
|
||||
|
||||
import { API, getAuthHeaders, ensureAuthenticated } from '/js/api-config.js';
|
||||
|
||||
// 인증 확인
|
||||
const token = ensureAuthenticated();
|
||||
|
||||
// DOM 요소
|
||||
const form = document.getElementById('changePasswordForm');
|
||||
const messageArea = document.getElementById('message-area');
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const resetBtn = document.getElementById('resetBtn');
|
||||
|
||||
// 비밀번호 토글 기능
|
||||
document.querySelectorAll('.password-toggle').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const targetId = this.getAttribute('data-target');
|
||||
const input = document.getElementById(targetId);
|
||||
|
||||
if (input) {
|
||||
const isPassword = input.type === 'password';
|
||||
input.type = isPassword ? 'text' : 'password';
|
||||
this.textContent = isPassword ? '👁️🗨️' : '👁️';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 초기화 버튼
|
||||
resetBtn?.addEventListener('click', () => {
|
||||
form.reset();
|
||||
clearMessages();
|
||||
document.getElementById('passwordStrength').innerHTML = '';
|
||||
});
|
||||
|
||||
// 메시지 표시 함수
|
||||
function showMessage(type, message) {
|
||||
messageArea.innerHTML = `
|
||||
<div class="message-box ${type}">
|
||||
${type === 'error' ? '❌' : '✅'} ${message}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 에러 메시지는 5초 후 자동 제거
|
||||
if (type === 'error') {
|
||||
setTimeout(clearMessages, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function clearMessages() {
|
||||
messageArea.innerHTML = '';
|
||||
}
|
||||
|
||||
// 비밀번호 강도 체크
|
||||
async function checkPasswordStrength(password) {
|
||||
if (!password) {
|
||||
document.getElementById('passwordStrength').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/auth/check-password-strength`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ password })
|
||||
});
|
||||
|
||||
const result = await res.json();
|
||||
updatePasswordStrengthUI(result);
|
||||
} catch (error) {
|
||||
console.error('Password strength check error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 비밀번호 강도 UI 업데이트
|
||||
function updatePasswordStrengthUI(strength) {
|
||||
const container = document.getElementById('passwordStrength');
|
||||
if (!container) return;
|
||||
|
||||
const colors = {
|
||||
0: '#f44336',
|
||||
1: '#ff9800',
|
||||
2: '#ffc107',
|
||||
3: '#4caf50',
|
||||
4: '#2196f3'
|
||||
};
|
||||
|
||||
const strengthText = strength.strengthText || '비밀번호를 입력하세요';
|
||||
const color = colors[strength.strength] || '#ccc';
|
||||
const percentage = (strength.score / strength.maxScore) * 100;
|
||||
|
||||
container.innerHTML = `
|
||||
<div style="margin-top: 12px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
|
||||
<span style="font-size: 0.85rem; color: ${color}; font-weight: 500;">
|
||||
${strengthText}
|
||||
</span>
|
||||
<span style="font-size: 0.8rem; color: #666;">
|
||||
${strength.score}/${strength.maxScore}
|
||||
</span>
|
||||
</div>
|
||||
<div style="height: 6px; background: #e0e0e0; border-radius: 3px; overflow: hidden;">
|
||||
<div style="width: ${percentage}%; height: 100%; background: ${color}; transition: all 0.3s;"></div>
|
||||
</div>
|
||||
${strength.feedback && strength.feedback.length > 0 ? `
|
||||
<ul style="margin-top: 10px; font-size: 0.8rem; color: #666; padding-left: 20px;">
|
||||
${strength.feedback.map(f => `<li>${f}</li>`).join('')}
|
||||
</ul>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 비밀번호 입력 이벤트
|
||||
let strengthCheckTimer;
|
||||
document.getElementById('newPassword')?.addEventListener('input', (e) => {
|
||||
clearTimeout(strengthCheckTimer);
|
||||
strengthCheckTimer = setTimeout(() => {
|
||||
checkPasswordStrength(e.target.value);
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// 폼 제출
|
||||
form?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
clearMessages();
|
||||
|
||||
const currentPassword = document.getElementById('currentPassword').value;
|
||||
const newPassword = document.getElementById('newPassword').value;
|
||||
const confirmPassword = document.getElementById('confirmPassword').value;
|
||||
|
||||
// 유효성 검사
|
||||
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||
showMessage('error', '모든 필드를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
showMessage('error', '새 비밀번호가 일치하지 않습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
showMessage('error', '비밀번호는 최소 6자 이상이어야 합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentPassword === newPassword) {
|
||||
showMessage('error', '새 비밀번호는 현재 비밀번호와 달라야 합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 버튼 상태 변경
|
||||
const originalText = submitBtn.innerHTML;
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<span>⏳</span><span>처리 중...</span>';
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/auth/change-password`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
currentPassword,
|
||||
newPassword
|
||||
})
|
||||
});
|
||||
|
||||
const result = await res.json();
|
||||
|
||||
if (res.ok && result.success) {
|
||||
showMessage('success', '비밀번호가 성공적으로 변경되었습니다.');
|
||||
form.reset();
|
||||
document.getElementById('passwordStrength').innerHTML = '';
|
||||
|
||||
// 카운트다운 시작
|
||||
let countdown = 3;
|
||||
const countdownInterval = setInterval(() => {
|
||||
showMessage('success',
|
||||
`비밀번호가 변경되었습니다. ${countdown}초 후 로그인 페이지로 이동합니다.`
|
||||
);
|
||||
countdown--;
|
||||
|
||||
if (countdown < 0) {
|
||||
clearInterval(countdownInterval);
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/index.html';
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
} else {
|
||||
const errorMessage = result.error || '비밀번호 변경에 실패했습니다.';
|
||||
showMessage('error', errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Password change error:', error);
|
||||
showMessage('error', '서버와의 연결에 실패했습니다. 잠시 후 다시 시도해주세요.');
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = originalText;
|
||||
}
|
||||
});
|
||||
|
||||
// 페이지 로드 시 현재 사용자 정보 표시
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
console.log('🔐 비밀번호 변경 페이지 로드됨');
|
||||
console.log('👤 현재 사용자:', user.username || 'Unknown');
|
||||
});
|
||||
795
synology_deployment/web-ui/js/code-management.js
Normal file
795
synology_deployment/web-ui/js/code-management.js
Normal file
@@ -0,0 +1,795 @@
|
||||
// 코드 관리 페이지 JavaScript
|
||||
|
||||
// 전역 변수
|
||||
let workStatusTypes = [];
|
||||
let errorTypes = [];
|
||||
let workTypes = [];
|
||||
let currentCodeType = 'work-status';
|
||||
let currentEditingCode = null;
|
||||
|
||||
// 페이지 초기화
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('🏷️ 코드 관리 페이지 초기화 시작');
|
||||
|
||||
initializePage();
|
||||
loadAllCodes();
|
||||
});
|
||||
|
||||
// 페이지 초기화
|
||||
function initializePage() {
|
||||
// 시간 업데이트 시작
|
||||
updateCurrentTime();
|
||||
setInterval(updateCurrentTime, 1000);
|
||||
|
||||
// 사용자 정보 업데이트
|
||||
updateUserInfo();
|
||||
|
||||
// 프로필 메뉴 토글
|
||||
setupProfileMenu();
|
||||
|
||||
// 로그아웃 버튼
|
||||
setupLogoutButton();
|
||||
}
|
||||
|
||||
// 현재 시간 업데이트
|
||||
function updateCurrentTime() {
|
||||
const now = new Date();
|
||||
const timeString = now.toLocaleTimeString('ko-KR', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
|
||||
const timeElement = document.getElementById('timeValue');
|
||||
if (timeElement) {
|
||||
timeElement.textContent = timeString;
|
||||
}
|
||||
}
|
||||
|
||||
// 사용자 정보 업데이트
|
||||
function updateUserInfo() {
|
||||
let userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}');
|
||||
let authUser = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
|
||||
const finalUserInfo = {
|
||||
worker_name: userInfo.worker_name || authUser.username || authUser.worker_name,
|
||||
job_type: userInfo.job_type || authUser.role || authUser.job_type,
|
||||
username: authUser.username || userInfo.username
|
||||
};
|
||||
|
||||
const userNameElement = document.getElementById('userName');
|
||||
const userRoleElement = document.getElementById('userRole');
|
||||
const userInitialElement = document.getElementById('userInitial');
|
||||
|
||||
if (userNameElement) {
|
||||
userNameElement.textContent = finalUserInfo.worker_name || '사용자';
|
||||
}
|
||||
|
||||
if (userRoleElement) {
|
||||
const roleMap = {
|
||||
'leader': '그룹장',
|
||||
'worker': '작업자',
|
||||
'admin': '관리자',
|
||||
'system': '시스템 관리자'
|
||||
};
|
||||
userRoleElement.textContent = roleMap[finalUserInfo.job_type] || finalUserInfo.job_type || '작업자';
|
||||
}
|
||||
|
||||
if (userInitialElement) {
|
||||
const name = finalUserInfo.worker_name || '사용자';
|
||||
userInitialElement.textContent = name.charAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
// 프로필 메뉴 설정
|
||||
function setupProfileMenu() {
|
||||
const userProfile = document.getElementById('userProfile');
|
||||
const profileMenu = document.getElementById('profileMenu');
|
||||
|
||||
if (userProfile && profileMenu) {
|
||||
userProfile.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
const isVisible = profileMenu.style.display === 'block';
|
||||
profileMenu.style.display = isVisible ? 'none' : 'block';
|
||||
});
|
||||
|
||||
// 외부 클릭 시 메뉴 닫기
|
||||
document.addEventListener('click', function() {
|
||||
profileMenu.style.display = 'none';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 로그아웃 버튼 설정
|
||||
function setupLogoutButton() {
|
||||
const logoutBtn = document.getElementById('logoutBtn');
|
||||
if (logoutBtn) {
|
||||
logoutBtn.addEventListener('click', function() {
|
||||
if (confirm('로그아웃 하시겠습니까?')) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('userInfo');
|
||||
window.location.href = '/index.html';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 모든 코드 데이터 로드
|
||||
async function loadAllCodes() {
|
||||
try {
|
||||
console.log('📊 모든 코드 데이터 로딩 시작');
|
||||
|
||||
await Promise.all([
|
||||
loadWorkStatusTypes(),
|
||||
loadErrorTypes(),
|
||||
loadWorkTypes()
|
||||
]);
|
||||
|
||||
// 현재 활성 탭 렌더링
|
||||
renderCurrentTab();
|
||||
|
||||
} catch (error) {
|
||||
console.error('코드 데이터 로딩 오류:', error);
|
||||
showToast('코드 데이터를 불러오는데 실패했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 작업 상태 유형 로드
|
||||
async function loadWorkStatusTypes() {
|
||||
try {
|
||||
console.log('📊 작업 상태 유형 로딩...');
|
||||
|
||||
const response = await apiCall('/daily-work-reports/work-status-types', 'GET');
|
||||
|
||||
let statusData = [];
|
||||
if (response && response.success && Array.isArray(response.data)) {
|
||||
statusData = response.data;
|
||||
} else if (Array.isArray(response)) {
|
||||
statusData = response;
|
||||
}
|
||||
|
||||
workStatusTypes = statusData;
|
||||
console.log(`✅ 작업 상태 유형 ${workStatusTypes.length}개 로드 완료`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('작업 상태 유형 로딩 오류:', error);
|
||||
workStatusTypes = [];
|
||||
}
|
||||
}
|
||||
|
||||
// 오류 유형 로드
|
||||
async function loadErrorTypes() {
|
||||
try {
|
||||
console.log('⚠️ 오류 유형 로딩...');
|
||||
|
||||
const response = await apiCall('/daily-work-reports/error-types', 'GET');
|
||||
|
||||
let errorData = [];
|
||||
if (response && response.success && Array.isArray(response.data)) {
|
||||
errorData = response.data;
|
||||
} else if (Array.isArray(response)) {
|
||||
errorData = response;
|
||||
}
|
||||
|
||||
errorTypes = errorData;
|
||||
console.log(`✅ 오류 유형 ${errorTypes.length}개 로드 완료`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('오류 유형 로딩 오류:', error);
|
||||
errorTypes = [];
|
||||
}
|
||||
}
|
||||
|
||||
// 작업 유형 로드
|
||||
async function loadWorkTypes() {
|
||||
try {
|
||||
console.log('🔧 작업 유형 로딩...');
|
||||
|
||||
const response = await apiCall('/daily-work-reports/work-types', 'GET');
|
||||
|
||||
let typeData = [];
|
||||
if (response && response.success && Array.isArray(response.data)) {
|
||||
typeData = response.data;
|
||||
} else if (Array.isArray(response)) {
|
||||
typeData = response;
|
||||
}
|
||||
|
||||
workTypes = typeData;
|
||||
console.log(`✅ 작업 유형 ${workTypes.length}개 로드 완료`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('작업 유형 로딩 오류:', error);
|
||||
workTypes = [];
|
||||
}
|
||||
}
|
||||
|
||||
// 코드 탭 전환
|
||||
function switchCodeTab(tabName) {
|
||||
// 탭 버튼 활성화 상태 변경
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
document.querySelector(`[data-tab="${tabName}"]`).classList.add('active');
|
||||
|
||||
// 탭 콘텐츠 표시/숨김
|
||||
document.querySelectorAll('.code-tab-content').forEach(content => {
|
||||
content.classList.remove('active');
|
||||
});
|
||||
document.getElementById(`${tabName}-tab`).classList.add('active');
|
||||
|
||||
currentCodeType = tabName;
|
||||
renderCurrentTab();
|
||||
}
|
||||
|
||||
// 현재 탭 렌더링
|
||||
function renderCurrentTab() {
|
||||
switch (currentCodeType) {
|
||||
case 'work-status':
|
||||
renderWorkStatusTypes();
|
||||
break;
|
||||
case 'error-types':
|
||||
renderErrorTypes();
|
||||
break;
|
||||
case 'work-types':
|
||||
renderWorkTypes();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 작업 상태 유형 렌더링
|
||||
function renderWorkStatusTypes() {
|
||||
const grid = document.getElementById('workStatusGrid');
|
||||
if (!grid) return;
|
||||
|
||||
if (workStatusTypes.length === 0) {
|
||||
grid.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">📊</div>
|
||||
<h3>등록된 작업 상태 유형이 없습니다.</h3>
|
||||
<p>"새 상태 추가" 버튼을 눌러 작업 상태를 등록해보세요.</p>
|
||||
<button class="btn btn-primary" onclick="openCodeModal('work-status')">
|
||||
➕ 첫 상태 추가하기
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
updateWorkStatusStats();
|
||||
return;
|
||||
}
|
||||
|
||||
let gridHtml = '';
|
||||
|
||||
workStatusTypes.forEach(status => {
|
||||
const isError = status.is_error === 1 || status.is_error === true;
|
||||
const statusClass = isError ? 'error-status' : 'normal-status';
|
||||
const statusIcon = isError ? '❌' : '✅';
|
||||
const statusLabel = isError ? '오류' : '정상';
|
||||
|
||||
gridHtml += `
|
||||
<div class="code-card ${statusClass}" onclick="editCode('work-status', ${status.id})">
|
||||
<div class="code-header">
|
||||
<div class="code-icon">${statusIcon}</div>
|
||||
<div class="code-info">
|
||||
<h3 class="code-name">${status.name}</h3>
|
||||
<span class="code-label">${statusLabel}</span>
|
||||
</div>
|
||||
<div class="code-actions">
|
||||
<button class="btn-small btn-edit" onclick="event.stopPropagation(); editCode('work-status', ${status.id})" title="수정">
|
||||
✏️
|
||||
</button>
|
||||
<button class="btn-small btn-delete" onclick="event.stopPropagation(); confirmDeleteCode('work-status', ${status.id})" title="삭제">
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
${status.description ? `<p class="code-description">${status.description}</p>` : ''}
|
||||
<div class="code-meta">
|
||||
<span class="code-date">등록: ${formatDate(status.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
grid.innerHTML = gridHtml;
|
||||
updateWorkStatusStats();
|
||||
}
|
||||
|
||||
// 오류 유형 렌더링
|
||||
function renderErrorTypes() {
|
||||
const grid = document.getElementById('errorTypesGrid');
|
||||
if (!grid) return;
|
||||
|
||||
if (errorTypes.length === 0) {
|
||||
grid.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">⚠️</div>
|
||||
<h3>등록된 오류 유형이 없습니다.</h3>
|
||||
<p>"새 오류 유형 추가" 버튼을 눌러 오류 유형을 등록해보세요.</p>
|
||||
<button class="btn btn-primary" onclick="openCodeModal('error-types')">
|
||||
➕ 첫 오류 유형 추가하기
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
updateErrorTypesStats();
|
||||
return;
|
||||
}
|
||||
|
||||
let gridHtml = '';
|
||||
|
||||
errorTypes.forEach(error => {
|
||||
const severityMap = {
|
||||
'low': { icon: '🟢', label: '낮음', class: 'severity-low' },
|
||||
'medium': { icon: '🟡', label: '보통', class: 'severity-medium' },
|
||||
'high': { icon: '🟠', label: '높음', class: 'severity-high' },
|
||||
'critical': { icon: '🔴', label: '심각', class: 'severity-critical' }
|
||||
};
|
||||
|
||||
const severity = severityMap[error.severity] || severityMap.medium;
|
||||
|
||||
gridHtml += `
|
||||
<div class="code-card error-type-card ${severity.class}" onclick="editCode('error-types', ${error.id})">
|
||||
<div class="code-header">
|
||||
<div class="code-icon">⚠️</div>
|
||||
<div class="code-info">
|
||||
<h3 class="code-name">${error.name}</h3>
|
||||
<span class="code-label">${severity.icon} ${severity.label}</span>
|
||||
</div>
|
||||
<div class="code-actions">
|
||||
<button class="btn-small btn-edit" onclick="event.stopPropagation(); editCode('error-types', ${error.id})" title="수정">
|
||||
✏️
|
||||
</button>
|
||||
<button class="btn-small btn-delete" onclick="event.stopPropagation(); confirmDeleteCode('error-types', ${error.id})" title="삭제">
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
${error.description ? `<p class="code-description">${error.description}</p>` : ''}
|
||||
${error.solution_guide ? `<div class="solution-guide"><strong>해결 가이드:</strong><br>${error.solution_guide}</div>` : ''}
|
||||
<div class="code-meta">
|
||||
<span class="code-date">등록: ${formatDate(error.created_at)}</span>
|
||||
${error.updated_at !== error.created_at ? `<span class="code-date">수정: ${formatDate(error.updated_at)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
grid.innerHTML = gridHtml;
|
||||
updateErrorTypesStats();
|
||||
}
|
||||
|
||||
// 작업 유형 렌더링
|
||||
function renderWorkTypes() {
|
||||
const grid = document.getElementById('workTypesGrid');
|
||||
if (!grid) return;
|
||||
|
||||
if (workTypes.length === 0) {
|
||||
grid.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">🔧</div>
|
||||
<h3>등록된 작업 유형이 없습니다.</h3>
|
||||
<p>"새 작업 유형 추가" 버튼을 눌러 작업 유형을 등록해보세요.</p>
|
||||
<button class="btn btn-primary" onclick="openCodeModal('work-types')">
|
||||
➕ 첫 작업 유형 추가하기
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
updateWorkTypesStats();
|
||||
return;
|
||||
}
|
||||
|
||||
let gridHtml = '';
|
||||
|
||||
workTypes.forEach(type => {
|
||||
gridHtml += `
|
||||
<div class="code-card work-type-card" onclick="editCode('work-types', ${type.id})">
|
||||
<div class="code-header">
|
||||
<div class="code-icon">🔧</div>
|
||||
<div class="code-info">
|
||||
<h3 class="code-name">${type.name}</h3>
|
||||
${type.category ? `<span class="code-label">📁 ${type.category}</span>` : ''}
|
||||
</div>
|
||||
<div class="code-actions">
|
||||
<button class="btn-small btn-edit" onclick="event.stopPropagation(); editCode('work-types', ${type.id})" title="수정">
|
||||
✏️
|
||||
</button>
|
||||
<button class="btn-small btn-delete" onclick="event.stopPropagation(); confirmDeleteCode('work-types', ${type.id})" title="삭제">
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
${type.description ? `<p class="code-description">${type.description}</p>` : ''}
|
||||
<div class="code-meta">
|
||||
<span class="code-date">등록: ${formatDate(type.created_at)}</span>
|
||||
${type.updated_at !== type.created_at ? `<span class="code-date">수정: ${formatDate(type.updated_at)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
grid.innerHTML = gridHtml;
|
||||
updateWorkTypesStats();
|
||||
}
|
||||
|
||||
// 작업 상태 통계 업데이트
|
||||
function updateWorkStatusStats() {
|
||||
const total = workStatusTypes.length;
|
||||
const normal = workStatusTypes.filter(s => !s.is_error).length;
|
||||
const error = workStatusTypes.filter(s => s.is_error).length;
|
||||
|
||||
document.getElementById('workStatusCount').textContent = total;
|
||||
document.getElementById('normalStatusCount').textContent = normal;
|
||||
document.getElementById('errorStatusCount').textContent = error;
|
||||
}
|
||||
|
||||
// 오류 유형 통계 업데이트
|
||||
function updateErrorTypesStats() {
|
||||
const total = errorTypes.length;
|
||||
const critical = errorTypes.filter(e => e.severity === 'critical').length;
|
||||
const high = errorTypes.filter(e => e.severity === 'high').length;
|
||||
const medium = errorTypes.filter(e => e.severity === 'medium').length;
|
||||
const low = errorTypes.filter(e => e.severity === 'low').length;
|
||||
|
||||
document.getElementById('errorTypesCount').textContent = total;
|
||||
document.getElementById('criticalErrorsCount').textContent = critical;
|
||||
document.getElementById('highErrorsCount').textContent = high;
|
||||
document.getElementById('mediumErrorsCount').textContent = medium;
|
||||
document.getElementById('lowErrorsCount').textContent = low;
|
||||
}
|
||||
|
||||
// 작업 유형 통계 업데이트
|
||||
function updateWorkTypesStats() {
|
||||
const total = workTypes.length;
|
||||
const categories = new Set(workTypes.map(t => t.category).filter(Boolean)).size;
|
||||
|
||||
document.getElementById('workTypesCount').textContent = total;
|
||||
document.getElementById('workCategoriesCount').textContent = categories;
|
||||
}
|
||||
|
||||
// 코드 모달 열기
|
||||
function openCodeModal(codeType, codeData = null) {
|
||||
const modal = document.getElementById('codeModal');
|
||||
const modalTitle = document.getElementById('modalTitle');
|
||||
const deleteBtn = document.getElementById('deleteCodeBtn');
|
||||
|
||||
if (!modal) return;
|
||||
|
||||
currentEditingCode = codeData;
|
||||
|
||||
// 모든 전용 필드 숨기기
|
||||
document.getElementById('isErrorGroup').style.display = 'none';
|
||||
document.getElementById('severityGroup').style.display = 'none';
|
||||
document.getElementById('solutionGuideGroup').style.display = 'none';
|
||||
document.getElementById('categoryGroup').style.display = 'none';
|
||||
|
||||
// 코드 유형별 설정
|
||||
switch (codeType) {
|
||||
case 'work-status':
|
||||
modalTitle.textContent = codeData ? '작업 상태 수정' : '새 작업 상태 추가';
|
||||
document.getElementById('isErrorGroup').style.display = 'block';
|
||||
break;
|
||||
case 'error-types':
|
||||
modalTitle.textContent = codeData ? '오류 유형 수정' : '새 오류 유형 추가';
|
||||
document.getElementById('severityGroup').style.display = 'block';
|
||||
document.getElementById('solutionGuideGroup').style.display = 'block';
|
||||
break;
|
||||
case 'work-types':
|
||||
modalTitle.textContent = codeData ? '작업 유형 수정' : '새 작업 유형 추가';
|
||||
document.getElementById('categoryGroup').style.display = 'block';
|
||||
updateCategoryList();
|
||||
break;
|
||||
}
|
||||
|
||||
document.getElementById('codeType').value = codeType;
|
||||
|
||||
if (codeData) {
|
||||
// 수정 모드
|
||||
deleteBtn.style.display = 'inline-flex';
|
||||
|
||||
// 폼에 데이터 채우기
|
||||
document.getElementById('codeId').value = codeData.id;
|
||||
document.getElementById('codeName').value = codeData.name || '';
|
||||
document.getElementById('codeDescription').value = codeData.description || '';
|
||||
|
||||
// 코드 유형별 필드 채우기
|
||||
if (codeType === 'work-status') {
|
||||
document.getElementById('isError').checked = codeData.is_error === 1 || codeData.is_error === true;
|
||||
} else if (codeType === 'error-types') {
|
||||
document.getElementById('severity').value = codeData.severity || 'medium';
|
||||
document.getElementById('solutionGuide').value = codeData.solution_guide || '';
|
||||
} else if (codeType === 'work-types') {
|
||||
document.getElementById('category').value = codeData.category || '';
|
||||
}
|
||||
} else {
|
||||
// 신규 등록 모드
|
||||
deleteBtn.style.display = 'none';
|
||||
|
||||
// 폼 초기화
|
||||
document.getElementById('codeForm').reset();
|
||||
document.getElementById('codeId').value = '';
|
||||
document.getElementById('codeType').value = codeType;
|
||||
}
|
||||
|
||||
modal.style.display = 'flex';
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// 첫 번째 입력 필드에 포커스
|
||||
setTimeout(() => {
|
||||
document.getElementById('codeName').focus();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// 카테고리 목록 업데이트
|
||||
function updateCategoryList() {
|
||||
const categoryList = document.getElementById('categoryList');
|
||||
if (categoryList) {
|
||||
const categories = [...new Set(workTypes.map(t => t.category).filter(Boolean))].sort();
|
||||
categoryList.innerHTML = categories.map(cat => `<option value="${cat}">`).join('');
|
||||
}
|
||||
}
|
||||
|
||||
// 코드 모달 닫기
|
||||
function closeCodeModal() {
|
||||
const modal = document.getElementById('codeModal');
|
||||
if (modal) {
|
||||
modal.style.display = 'none';
|
||||
document.body.style.overflow = '';
|
||||
currentEditingCode = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 코드 편집
|
||||
function editCode(codeType, codeId) {
|
||||
let codeData = null;
|
||||
|
||||
switch (codeType) {
|
||||
case 'work-status':
|
||||
codeData = workStatusTypes.find(s => s.id === codeId);
|
||||
break;
|
||||
case 'error-types':
|
||||
codeData = errorTypes.find(e => e.id === codeId);
|
||||
break;
|
||||
case 'work-types':
|
||||
codeData = workTypes.find(t => t.id === codeId);
|
||||
break;
|
||||
}
|
||||
|
||||
if (codeData) {
|
||||
openCodeModal(codeType, codeData);
|
||||
} else {
|
||||
showToast('코드를 찾을 수 없습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 코드 저장
|
||||
async function saveCode() {
|
||||
try {
|
||||
const codeType = document.getElementById('codeType').value;
|
||||
const codeId = document.getElementById('codeId').value;
|
||||
|
||||
const codeData = {
|
||||
name: document.getElementById('codeName').value.trim(),
|
||||
description: document.getElementById('codeDescription').value.trim() || null
|
||||
};
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!codeData.name) {
|
||||
showToast('이름은 필수 입력 항목입니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 코드 유형별 추가 필드
|
||||
if (codeType === 'work-status') {
|
||||
codeData.is_error = document.getElementById('isError').checked ? 1 : 0;
|
||||
} else if (codeType === 'error-types') {
|
||||
codeData.severity = document.getElementById('severity').value;
|
||||
codeData.solution_guide = document.getElementById('solutionGuide').value.trim() || null;
|
||||
} else if (codeType === 'work-types') {
|
||||
codeData.category = document.getElementById('category').value.trim() || null;
|
||||
}
|
||||
|
||||
console.log('💾 저장할 코드 데이터:', codeData);
|
||||
|
||||
let endpoint = '';
|
||||
switch (codeType) {
|
||||
case 'work-status':
|
||||
endpoint = '/daily-work-reports/work-status-types';
|
||||
break;
|
||||
case 'error-types':
|
||||
endpoint = '/daily-work-reports/error-types';
|
||||
break;
|
||||
case 'work-types':
|
||||
endpoint = '/daily-work-reports/work-types';
|
||||
break;
|
||||
}
|
||||
|
||||
let response;
|
||||
if (codeId) {
|
||||
// 수정
|
||||
response = await apiCall(`${endpoint}/${codeId}`, 'PUT', codeData);
|
||||
} else {
|
||||
// 신규 등록
|
||||
response = await apiCall(endpoint, 'POST', codeData);
|
||||
}
|
||||
|
||||
if (response && (response.success || response.id)) {
|
||||
const action = codeId ? '수정' : '등록';
|
||||
showToast(`코드가 성공적으로 ${action}되었습니다.`, 'success');
|
||||
|
||||
closeCodeModal();
|
||||
await loadAllCodes();
|
||||
} else {
|
||||
throw new Error(response?.message || '저장에 실패했습니다.');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('코드 저장 오류:', error);
|
||||
showToast(error.message || '코드 저장 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 코드 삭제 확인
|
||||
function confirmDeleteCode(codeType, codeId) {
|
||||
let codeData = null;
|
||||
let typeName = '';
|
||||
|
||||
switch (codeType) {
|
||||
case 'work-status':
|
||||
codeData = workStatusTypes.find(s => s.id === codeId);
|
||||
typeName = '작업 상태';
|
||||
break;
|
||||
case 'error-types':
|
||||
codeData = errorTypes.find(e => e.id === codeId);
|
||||
typeName = '오류 유형';
|
||||
break;
|
||||
case 'work-types':
|
||||
codeData = workTypes.find(t => t.id === codeId);
|
||||
typeName = '작업 유형';
|
||||
break;
|
||||
}
|
||||
|
||||
if (!codeData) {
|
||||
showToast('코드를 찾을 수 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (confirm(`"${codeData.name}" ${typeName}을 정말 삭제하시겠습니까?\n\n⚠️ 삭제된 코드는 복구할 수 없습니다.`)) {
|
||||
deleteCodeById(codeType, codeId);
|
||||
}
|
||||
}
|
||||
|
||||
// 코드 삭제 (수정 모드에서)
|
||||
function deleteCode() {
|
||||
if (currentEditingCode) {
|
||||
const codeType = document.getElementById('codeType').value;
|
||||
confirmDeleteCode(codeType, currentEditingCode.id);
|
||||
}
|
||||
}
|
||||
|
||||
// 코드 삭제 실행
|
||||
async function deleteCodeById(codeType, codeId) {
|
||||
try {
|
||||
let endpoint = '';
|
||||
switch (codeType) {
|
||||
case 'work-status':
|
||||
endpoint = '/daily-work-reports/work-status-types';
|
||||
break;
|
||||
case 'error-types':
|
||||
endpoint = '/daily-work-reports/error-types';
|
||||
break;
|
||||
case 'work-types':
|
||||
endpoint = '/daily-work-reports/work-types';
|
||||
break;
|
||||
}
|
||||
|
||||
const response = await apiCall(`${endpoint}/${codeId}`, 'DELETE');
|
||||
|
||||
if (response && response.success) {
|
||||
showToast('코드가 성공적으로 삭제되었습니다.', 'success');
|
||||
|
||||
closeCodeModal();
|
||||
await loadAllCodes();
|
||||
} else {
|
||||
throw new Error(response?.message || '삭제에 실패했습니다.');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('코드 삭제 오류:', error);
|
||||
showToast(error.message || '코드 삭제 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 전체 새로고침
|
||||
async function refreshAllCodes() {
|
||||
const refreshBtn = document.querySelector('.btn-secondary');
|
||||
if (refreshBtn) {
|
||||
const originalText = refreshBtn.innerHTML;
|
||||
refreshBtn.innerHTML = '<span class="btn-icon">⏳</span>새로고침 중...';
|
||||
refreshBtn.disabled = true;
|
||||
|
||||
await loadAllCodes();
|
||||
|
||||
refreshBtn.innerHTML = originalText;
|
||||
refreshBtn.disabled = false;
|
||||
} else {
|
||||
await loadAllCodes();
|
||||
}
|
||||
|
||||
showToast('모든 코드 데이터가 새로고침되었습니다.', 'success');
|
||||
}
|
||||
|
||||
// 날짜 포맷팅
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return '';
|
||||
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
// 토스트 메시지 표시
|
||||
function showToast(message, type = 'info') {
|
||||
// 기존 토스트 제거
|
||||
const existingToast = document.querySelector('.toast');
|
||||
if (existingToast) {
|
||||
existingToast.remove();
|
||||
}
|
||||
|
||||
// 새 토스트 생성
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.textContent = message;
|
||||
|
||||
// 스타일 적용
|
||||
Object.assign(toast.style, {
|
||||
position: 'fixed',
|
||||
top: '20px',
|
||||
right: '20px',
|
||||
padding: '12px 24px',
|
||||
borderRadius: '8px',
|
||||
color: 'white',
|
||||
fontWeight: '500',
|
||||
zIndex: '1000',
|
||||
transform: 'translateX(100%)',
|
||||
transition: 'transform 0.3s ease'
|
||||
});
|
||||
|
||||
// 타입별 배경색
|
||||
const colors = {
|
||||
success: '#10b981',
|
||||
error: '#ef4444',
|
||||
warning: '#f59e0b',
|
||||
info: '#3b82f6'
|
||||
};
|
||||
toast.style.backgroundColor = colors[type] || colors.info;
|
||||
|
||||
document.body.appendChild(toast);
|
||||
|
||||
// 애니메이션
|
||||
setTimeout(() => {
|
||||
toast.style.transform = 'translateX(0)';
|
||||
}, 100);
|
||||
|
||||
// 자동 제거
|
||||
setTimeout(() => {
|
||||
toast.style.transform = 'translateX(100%)';
|
||||
setTimeout(() => {
|
||||
if (toast.parentNode) {
|
||||
toast.remove();
|
||||
}
|
||||
}, 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// 전역 함수로 노출
|
||||
window.switchCodeTab = switchCodeTab;
|
||||
window.openCodeModal = openCodeModal;
|
||||
window.closeCodeModal = closeCodeModal;
|
||||
window.editCode = editCode;
|
||||
window.saveCode = saveCode;
|
||||
window.deleteCode = deleteCode;
|
||||
window.confirmDeleteCode = confirmDeleteCode;
|
||||
window.refreshAllCodes = refreshAllCodes;
|
||||
66
synology_deployment/web-ui/js/daily-issue-api.js
Normal file
66
synology_deployment/web-ui/js/daily-issue-api.js
Normal file
@@ -0,0 +1,66 @@
|
||||
// /js/daily-issue-api.js
|
||||
import { apiGet, apiPost } from './api-helper.js';
|
||||
|
||||
/**
|
||||
* 이슈 보고서 작성을 위해 필요한 초기 데이터(프로젝트, 이슈 유형)를 가져옵니다.
|
||||
* @returns {Promise<{projects: Array, issueTypes: Array}>}
|
||||
*/
|
||||
export async function getInitialData() {
|
||||
try {
|
||||
const [projects, issueTypes] = await Promise.all([
|
||||
apiGet('/projects'),
|
||||
apiGet('/issue-types')
|
||||
]);
|
||||
return { projects, issueTypes };
|
||||
} catch (error) {
|
||||
console.error('이슈 보고서 초기 데이터 로딩 실패:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 날짜에 근무한 작업자 목록을 가져옵니다.
|
||||
* @param {string} date - 조회할 날짜 (YYYY-MM-DD)
|
||||
* @returns {Promise<Array>} - 작업자 목록
|
||||
*/
|
||||
export async function getWorkersByDate(date) {
|
||||
try {
|
||||
// 백엔드에 해당 날짜의 작업자 목록을 요청하는 API가 있다고 가정합니다.
|
||||
// (예: /api/workers?work_date=YYYY-MM-DD)
|
||||
// 현재는 기존 로직을 최대한 활용하여 구현합니다.
|
||||
let workers = [];
|
||||
const reports = await apiGet(`/daily-work-reports?date=${date}`);
|
||||
|
||||
if (reports && reports.length > 0) {
|
||||
const workerMap = new Map();
|
||||
reports.forEach(r => {
|
||||
if (!workerMap.has(r.worker_id)) {
|
||||
workerMap.set(r.worker_id, { worker_id: r.worker_id, worker_name: r.worker_name });
|
||||
}
|
||||
});
|
||||
workers = Array.from(workerMap.values());
|
||||
} else {
|
||||
// 보고서가 없으면 전체 작업자 목록을 가져옵니다.
|
||||
workers = await apiGet('/workers');
|
||||
}
|
||||
return workers.sort((a, b) => a.worker_name.localeCompare(b.worker_name));
|
||||
} catch (error) {
|
||||
console.error(`${date}의 작업자 목록 로딩 실패:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 작성된 이슈 보고서 데이터를 서버에 전송합니다.
|
||||
* @param {object} issueData - 전송할 이슈 데이터
|
||||
* @returns {Promise<object>} - 서버 응답 결과
|
||||
*/
|
||||
export async function createIssueReport(issueData) {
|
||||
try {
|
||||
const result = await apiPost('/issue-reports', issueData);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('이슈 보고서 생성 요청 실패:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
103
synology_deployment/web-ui/js/daily-issue-ui.js
Normal file
103
synology_deployment/web-ui/js/daily-issue-ui.js
Normal file
@@ -0,0 +1,103 @@
|
||||
// /js/daily-issue-ui.js
|
||||
|
||||
const DOM = {
|
||||
dateSelect: document.getElementById('dateSelect'),
|
||||
projectSelect: document.getElementById('projectSelect'),
|
||||
issueTypeSelect: document.getElementById('issueTypeSelect'),
|
||||
timeStart: document.getElementById('timeStart'),
|
||||
timeEnd: document.getElementById('timeEnd'),
|
||||
workerList: document.getElementById('workerList'),
|
||||
form: document.getElementById('issueForm'),
|
||||
submitBtn: document.getElementById('submitBtn'),
|
||||
};
|
||||
|
||||
function createOption(value, text) {
|
||||
const option = document.createElement('option');
|
||||
option.value = value;
|
||||
option.textContent = text;
|
||||
return option;
|
||||
}
|
||||
|
||||
export function populateProjects(projects) {
|
||||
DOM.projectSelect.innerHTML = '<option value="">-- 프로젝트 선택 --</option>';
|
||||
if (Array.isArray(projects)) {
|
||||
projects.forEach(p => DOM.projectSelect.appendChild(createOption(p.project_id, p.project_name)));
|
||||
}
|
||||
}
|
||||
|
||||
export function populateIssueTypes(issueTypes) {
|
||||
DOM.issueTypeSelect.innerHTML = '<option value="">-- 이슈 유형 선택 --</option>';
|
||||
if (Array.isArray(issueTypes)) {
|
||||
issueTypes.forEach(t => DOM.issueTypeSelect.appendChild(createOption(t.issue_type_id, `${t.category}:${t.subcategory}`)));
|
||||
}
|
||||
}
|
||||
|
||||
export function populateTimeOptions() {
|
||||
for (let h = 0; h < 24; h++) {
|
||||
for (let m of [0, 30]) {
|
||||
const time = `${String(h).padStart(2, '0')}:${m === 0 ? '00' : '30'}`;
|
||||
DOM.timeStart.appendChild(createOption(time, time));
|
||||
DOM.timeEnd.appendChild(createOption(time, time.replace('00:00', '24:00')));
|
||||
}
|
||||
}
|
||||
DOM.timeEnd.value = "24:00"; // 기본값 설정
|
||||
}
|
||||
|
||||
export function renderWorkerList(workers) {
|
||||
DOM.workerList.innerHTML = '';
|
||||
if (!Array.isArray(workers) || workers.length === 0) {
|
||||
DOM.workerList.textContent = '선택 가능한 작업자가 없습니다.';
|
||||
return;
|
||||
}
|
||||
workers.forEach(worker => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'btn';
|
||||
btn.textContent = worker.worker_name;
|
||||
btn.dataset.id = worker.worker_id;
|
||||
btn.addEventListener('click', () => btn.classList.toggle('selected'));
|
||||
DOM.workerList.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
export function getFormData() {
|
||||
const selectedWorkers = [...DOM.workerList.querySelectorAll('.btn.selected')].map(b => b.dataset.id);
|
||||
|
||||
if (selectedWorkers.length === 0) {
|
||||
alert('작업자를 한 명 이상 선택해주세요.');
|
||||
return null;
|
||||
}
|
||||
if (DOM.timeEnd.value <= DOM.timeStart.value) {
|
||||
alert('종료 시간은 시작 시간보다 이후여야 합니다.');
|
||||
return null;
|
||||
}
|
||||
|
||||
const formData = new FormData(DOM.form);
|
||||
const data = {
|
||||
date: formData.get('dateSelect'), // input name 속성이 없어 직접 가져옴
|
||||
project_id: DOM.projectSelect.value,
|
||||
issue_type_id: DOM.issueTypeSelect.value,
|
||||
start_time: DOM.timeStart.value,
|
||||
end_time: DOM.timeEnd.value,
|
||||
worker_ids: selectedWorkers, // worker_id -> worker_ids 로 명확하게 변경
|
||||
};
|
||||
|
||||
for (const key in data) {
|
||||
if (!data[key] || (Array.isArray(data[key]) && data[key].length === 0)) {
|
||||
alert('모든 필수 항목을 입력해주세요.');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export function setSubmitButtonState(isLoading) {
|
||||
if (isLoading) {
|
||||
DOM.submitBtn.disabled = true;
|
||||
DOM.submitBtn.textContent = '등록 중...';
|
||||
} else {
|
||||
DOM.submitBtn.disabled = false;
|
||||
DOM.submitBtn.textContent = '등록';
|
||||
}
|
||||
}
|
||||
89
synology_deployment/web-ui/js/daily-issue.js
Normal file
89
synology_deployment/web-ui/js/daily-issue.js
Normal file
@@ -0,0 +1,89 @@
|
||||
// /js/daily-issue.js
|
||||
|
||||
import { getInitialData, getWorkersByDate, createIssueReport } from './daily-issue-api.js';
|
||||
import {
|
||||
populateProjects,
|
||||
populateIssueTypes,
|
||||
populateTimeOptions,
|
||||
renderWorkerList,
|
||||
getFormData,
|
||||
setSubmitButtonState
|
||||
} from './daily-issue-ui.js';
|
||||
|
||||
const dateSelect = document.getElementById('dateSelect');
|
||||
const form = document.getElementById('issueForm');
|
||||
|
||||
/**
|
||||
* 날짜가 변경될 때마다 해당 날짜의 작업자 목록을 다시 불러옵니다.
|
||||
*/
|
||||
async function handleDateChange() {
|
||||
const selectedDate = dateSelect.value;
|
||||
if (!selectedDate) {
|
||||
document.getElementById('workerList').textContent = '날짜를 먼저 선택하세요.';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('workerList').textContent = '작업자 목록을 불러오는 중...';
|
||||
try {
|
||||
const workers = await getWorkersByDate(selectedDate);
|
||||
renderWorkerList(workers);
|
||||
} catch (error) {
|
||||
document.getElementById('workerList').textContent = '작업자 목록 로딩에 실패했습니다.';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 폼 제출 이벤트를 처리합니다.
|
||||
*/
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
const issueData = getFormData();
|
||||
|
||||
if (!issueData) return; // 유효성 검사 실패
|
||||
|
||||
setSubmitButtonState(true);
|
||||
try {
|
||||
const result = await createIssueReport(issueData);
|
||||
if (result.success) {
|
||||
alert('✅ 이슈가 성공적으로 등록되었습니다.');
|
||||
form.reset(); // 폼 초기화
|
||||
dateSelect.value = new Date().toISOString().split('T')[0]; // 날짜 오늘로 리셋
|
||||
handleDateChange(); // 작업자 목록 새로고침
|
||||
} else {
|
||||
throw new Error(result.error || '알 수 없는 오류가 발생했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
alert(`🚨 등록 실패: ${error.message}`);
|
||||
} finally {
|
||||
setSubmitButtonState(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 초기화 함수
|
||||
*/
|
||||
async function initializePage() {
|
||||
// 오늘 날짜 기본 설정
|
||||
dateSelect.value = new Date().toISOString().split('T')[0];
|
||||
|
||||
populateTimeOptions();
|
||||
|
||||
// 프로젝트, 이슈유형, 작업자 목록을 병렬로 로드
|
||||
try {
|
||||
const [initialData] = await Promise.all([
|
||||
getInitialData(),
|
||||
handleDateChange() // 초기 작업자 목록 로드
|
||||
]);
|
||||
populateProjects(initialData.projects);
|
||||
populateIssueTypes(initialData.issueTypes);
|
||||
} catch (error) {
|
||||
alert('페이지 초기화 중 오류가 발생했습니다. 새로고침 해주세요.');
|
||||
}
|
||||
|
||||
// 이벤트 리스너 설정
|
||||
dateSelect.addEventListener('change', handleDateChange);
|
||||
form.addEventListener('submit', handleSubmit);
|
||||
}
|
||||
|
||||
// DOM이 로드되면 페이지 초기화를 시작합니다.
|
||||
document.addEventListener('DOMContentLoaded', initializePage);
|
||||
1009
synology_deployment/web-ui/js/daily-report-viewer 복사본.js
Normal file
1009
synology_deployment/web-ui/js/daily-report-viewer 복사본.js
Normal file
File diff suppressed because it is too large
Load Diff
81
synology_deployment/web-ui/js/daily-report-viewer.js
Normal file
81
synology_deployment/web-ui/js/daily-report-viewer.js
Normal file
@@ -0,0 +1,81 @@
|
||||
// /js/daily-report-viewer.js
|
||||
|
||||
import { fetchReportData } from './report-viewer-api.js';
|
||||
import { renderReport, processReportData, showLoading, showError } from './report-viewer-ui.js';
|
||||
import { exportToExcel, printReport } from './report-viewer-export.js';
|
||||
import { getUser } from './auth.js';
|
||||
|
||||
// 전역 상태: 현재 화면에 표시된 데이터
|
||||
let currentProcessedData = null;
|
||||
|
||||
/**
|
||||
* 날짜를 기준으로 보고서를 검색하고 화면에 렌더링합니다.
|
||||
*/
|
||||
async function searchReports() {
|
||||
const dateInput = document.getElementById('reportDate');
|
||||
const selectedDate = dateInput.value;
|
||||
|
||||
if (!selectedDate) {
|
||||
showError('날짜를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
showLoading(true);
|
||||
currentProcessedData = null; // 새 검색이 시작되면 이전 데이터 초기화
|
||||
|
||||
try {
|
||||
const rawData = await fetchReportData(selectedDate);
|
||||
currentProcessedData = processReportData(rawData, selectedDate);
|
||||
renderReport(currentProcessedData);
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
renderReport(null); // 에러 발생 시 데이터 없는 화면 표시
|
||||
} finally {
|
||||
showLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지의 모든 이벤트 리스너를 설정합니다.
|
||||
*/
|
||||
function setupEventListeners() {
|
||||
document.getElementById('searchBtn')?.addEventListener('click', searchReports);
|
||||
document.getElementById('todayBtn')?.addEventListener('click', () => {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
document.getElementById('reportDate').value = today;
|
||||
searchReports();
|
||||
});
|
||||
|
||||
document.getElementById('reportDate')?.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') searchReports();
|
||||
});
|
||||
|
||||
document.getElementById('exportExcelBtn')?.addEventListener('click', () => {
|
||||
exportToExcel(currentProcessedData);
|
||||
});
|
||||
|
||||
document.getElementById('printBtn')?.addEventListener('click', printReport);
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지가 처음 로드될 때 실행되는 초기화 함수
|
||||
*/
|
||||
function initializePage() {
|
||||
// auth.js를 사용하여 인증 상태 확인
|
||||
const user = getUser();
|
||||
if (!user) {
|
||||
showError('로그인이 필요합니다. 2초 후 로그인 페이지로 이동합니다.');
|
||||
setTimeout(() => window.location.href = '/index.html', 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
setupEventListeners();
|
||||
|
||||
// 페이지 로드 시 오늘 날짜로 자동 검색
|
||||
const dateInput = document.getElementById('reportDate');
|
||||
dateInput.value = new Date().toISOString().split('T')[0];
|
||||
searchReports();
|
||||
}
|
||||
|
||||
// DOM이 로드되면 페이지 초기화를 시작합니다.
|
||||
document.addEventListener('DOMContentLoaded', initializePage);
|
||||
1127
synology_deployment/web-ui/js/daily-work-report.js
Normal file
1127
synology_deployment/web-ui/js/daily-work-report.js
Normal file
File diff suppressed because it is too large
Load Diff
49
synology_deployment/web-ui/js/factory-upload.js
Normal file
49
synology_deployment/web-ui/js/factory-upload.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import { API, getAuthHeaders } from '/js/api-config.js';
|
||||
|
||||
document.getElementById('uploadForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
|
||||
try {
|
||||
// FormData를 사용할 때는 Content-Type을 설정하지 않음 (자동 설정됨)
|
||||
const token = localStorage.getItem('token');
|
||||
const res = await fetch(`${API}/factoryinfo`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.message || '등록 실패');
|
||||
}
|
||||
|
||||
alert('등록 완료!');
|
||||
location.reload();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('등록 실패: ' + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
// 파일 선택 시 미리보기 (선택사항)
|
||||
const fileInput = document.querySelector('input[name="map_image"]');
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener('change', function(e) {
|
||||
const file = e.target.files[0];
|
||||
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
// 미리보기 요소가 있을 경우에만 동작
|
||||
const preview = document.getElementById('file-preview');
|
||||
if (preview) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
preview.innerHTML = `<img src="${e.target.result}" alt="미리보기" style="max-width: 200px; max-height: 200px; border-radius: 8px;">`;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
38
synology_deployment/web-ui/js/factory-view.js
Normal file
38
synology_deployment/web-ui/js/factory-view.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import { API, getAuthHeaders } from '/js/api-config.js';
|
||||
|
||||
(async () => {
|
||||
const pathParts = location.pathname.split('/');
|
||||
const id = pathParts[pathParts.length - 1];
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/factoryinfo/${id}`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('조회 실패');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
// DOM 요소가 존재하는지 확인 후 설정
|
||||
const nameEl = document.getElementById('factoryName');
|
||||
if (nameEl) nameEl.textContent = data.factory_name;
|
||||
|
||||
const addressEl = document.getElementById('factoryAddress');
|
||||
if (addressEl) addressEl.textContent = '📍 ' + data.address;
|
||||
|
||||
const imageEl = document.getElementById('factoryImage');
|
||||
if (imageEl) imageEl.src = data.map_image_url;
|
||||
|
||||
const descEl = document.getElementById('factoryDescription');
|
||||
if (descEl) descEl.textContent = data.description;
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
const container = document.querySelector('.container');
|
||||
if (container) {
|
||||
container.innerHTML = '<p>공장 정보를 불러올 수 없습니다.</p>';
|
||||
}
|
||||
}
|
||||
})();
|
||||
103
synology_deployment/web-ui/js/group-leader-dashboard.js
Normal file
103
synology_deployment/web-ui/js/group-leader-dashboard.js
Normal file
@@ -0,0 +1,103 @@
|
||||
// /js/group-leader-dashboard.js
|
||||
// 그룹장 전용 대시보드 기능
|
||||
|
||||
console.log('📊 그룹장 대시보드 스크립트 로딩');
|
||||
|
||||
// 팀 현황 새로고침
|
||||
async function refreshTeamStatus() {
|
||||
console.log('🔄 팀 현황 새로고침 시작');
|
||||
|
||||
try {
|
||||
// 로딩 상태 표시
|
||||
const teamList = document.getElementById('team-list');
|
||||
if (teamList) {
|
||||
teamList.innerHTML = '<div style="text-align: center; padding: 20px;">⏳ 로딩 중...</div>';
|
||||
}
|
||||
|
||||
// 실제로는 API 호출
|
||||
// const response = await fetch('/api/team-status', { headers: getAuthHeaders() });
|
||||
// const data = await response.json();
|
||||
|
||||
// 임시 데이터로 업데이트 (실제 API 연동 시 교체)
|
||||
setTimeout(() => {
|
||||
updateTeamStatusUI();
|
||||
}, 1000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 팀 현황 로딩 실패:', error);
|
||||
const teamList = document.getElementById('team-list');
|
||||
if (teamList) {
|
||||
teamList.innerHTML = '<div style="text-align: center; padding: 20px; color: #f44336;">❌ 로딩 실패</div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 팀 현황 UI 업데이트 (임시 데이터)
|
||||
function updateTeamStatusUI() {
|
||||
const teamData = [
|
||||
{ name: '김작업', status: 'present', statusText: '출근' },
|
||||
{ name: '이현장', status: 'present', statusText: '출근' },
|
||||
{ name: '박휴가', status: 'absent', statusText: '휴가' },
|
||||
{ name: '최작업', status: 'present', statusText: '출근' },
|
||||
{ name: '정현장', status: 'present', statusText: '출근' }
|
||||
];
|
||||
|
||||
const teamList = document.getElementById('team-list');
|
||||
if (teamList) {
|
||||
teamList.innerHTML = teamData.map(member => `
|
||||
<div class="team-member ${member.status}">
|
||||
<span class="member-name">${member.name}</span>
|
||||
<span class="member-status">${member.statusText}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 통계 업데이트
|
||||
const presentCount = teamData.filter(m => m.status === 'present').length;
|
||||
const absentCount = teamData.filter(m => m.status === 'absent').length;
|
||||
|
||||
const totalEl = document.getElementById('team-total');
|
||||
const presentEl = document.getElementById('team-present');
|
||||
const absentEl = document.getElementById('team-absent');
|
||||
|
||||
if (totalEl) totalEl.textContent = teamData.length;
|
||||
if (presentEl) presentEl.textContent = presentCount;
|
||||
if (absentEl) absentEl.textContent = absentCount;
|
||||
|
||||
console.log('✅ 팀 현황 업데이트 완료');
|
||||
}
|
||||
|
||||
// 환영 메시지 개인화
|
||||
function personalizeWelcome() {
|
||||
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
const welcomeMsg = document.getElementById('welcome-message');
|
||||
|
||||
if (user && user.name && welcomeMsg) {
|
||||
welcomeMsg.textContent = `${user.name}님의 실시간 팀 현황 및 작업 모니터링`;
|
||||
console.log('✅ 환영 메시지 개인화 완료');
|
||||
}
|
||||
}
|
||||
|
||||
// 페이지 초기화
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('🚀 그룹장 대시보드 초기화 시작');
|
||||
|
||||
// 사용자 정보 확인
|
||||
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
console.log('👤 현재 사용자:', user);
|
||||
|
||||
// 권한 확인
|
||||
if (user.access_level !== 'group_leader') {
|
||||
console.warn('⚠️ 그룹장 권한 없음:', user.access_level);
|
||||
// 필요시 다른 페이지로 리다이렉트
|
||||
}
|
||||
|
||||
// 초기화 작업
|
||||
personalizeWelcome();
|
||||
updateTeamStatusUI();
|
||||
|
||||
console.log('✅ 그룹장 대시보드 초기화 완료');
|
||||
});
|
||||
|
||||
// 전역 함수로 내보내기 (HTML에서 사용)
|
||||
window.refreshTeamStatus = refreshTeamStatus;
|
||||
170
synology_deployment/web-ui/js/load-navbar.js
Normal file
170
synology_deployment/web-ui/js/load-navbar.js
Normal file
@@ -0,0 +1,170 @@
|
||||
// js/load-navbar.js
|
||||
// 브라우저 호환 버전 - ES6 모듈 제거
|
||||
|
||||
// 역할 이름을 한글로 변환하는 맵
|
||||
const ROLE_NAMES = {
|
||||
admin: '관리자',
|
||||
system: '시스템 관리자',
|
||||
leader: '그룹장',
|
||||
user: '작업자',
|
||||
support: '지원팀',
|
||||
default: '사용자',
|
||||
};
|
||||
|
||||
/**
|
||||
* 사용자 역할에 따라 메뉴 항목을 필터링합니다.
|
||||
* @param {Document} doc - 파싱된 HTML 문서 객체
|
||||
* @param {string} userRole - 현재 사용자의 역할
|
||||
*/
|
||||
function filterMenuByRole(doc, userRole) {
|
||||
const selectors = [
|
||||
{ role: 'admin', selector: '.admin-only' },
|
||||
{ role: 'system', selector: '.system-only' },
|
||||
{ role: 'leader', selector: '.leader-only' },
|
||||
];
|
||||
|
||||
selectors.forEach(({ role, selector }) => {
|
||||
// 사용자가 해당 역할을 가지고 있지 않으면 메뉴 항목을 제거
|
||||
if (userRole !== role && userRole !== 'system') { // system 권한도 admin 메뉴 접근 가능
|
||||
doc.querySelectorAll(selector).forEach(el => el.remove());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 네비게이션 바에 사용자 정보를 채웁니다.
|
||||
* @param {Document} doc - 파싱된 HTML 문서 객체
|
||||
* @param {object} user - 현재 사용자 객체
|
||||
*/
|
||||
function populateUserInfo(doc, user) {
|
||||
const displayName = user.name || user.username;
|
||||
const roleName = ROLE_NAMES[user.role] || ROLE_NAMES.default;
|
||||
|
||||
// 상단 바 사용자 이름
|
||||
const userNameEl = doc.getElementById('user-name');
|
||||
if (userNameEl) userNameEl.textContent = displayName;
|
||||
|
||||
// 상단 바 사용자 역할
|
||||
const userRoleEl = doc.getElementById('user-role');
|
||||
if (userRoleEl) userRoleEl.textContent = roleName;
|
||||
|
||||
// 드롭다운 메뉴 사용자 이름
|
||||
const dropdownNameEl = doc.getElementById('dropdown-user-fullname');
|
||||
if (dropdownNameEl) dropdownNameEl.textContent = displayName;
|
||||
|
||||
// 드롭다운 메뉴 사용자 아이디
|
||||
const dropdownIdEl = doc.getElementById('dropdown-user-id');
|
||||
if (dropdownIdEl) dropdownIdEl.textContent = `@${user.username}`;
|
||||
|
||||
// Admin 버튼 제거됨
|
||||
|
||||
// System 버튼 표시 여부 결정 (system 권한만)
|
||||
const systemBtn = doc.getElementById('systemBtn');
|
||||
if (systemBtn && user.role === 'system') {
|
||||
systemBtn.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 네비게이션 바와 관련된 모든 이벤트를 설정합니다.
|
||||
*/
|
||||
function setupNavbarEvents() {
|
||||
const userInfoDropdown = document.getElementById('user-info-dropdown');
|
||||
const profileDropdownMenu = document.getElementById('profile-dropdown-menu');
|
||||
|
||||
// 드롭다운 토글
|
||||
if (userInfoDropdown && profileDropdownMenu) {
|
||||
userInfoDropdown.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
profileDropdownMenu.classList.toggle('show');
|
||||
userInfoDropdown.classList.toggle('active');
|
||||
});
|
||||
}
|
||||
|
||||
// 로그아웃 버튼
|
||||
const logoutButton = document.getElementById('dropdown-logout');
|
||||
if (logoutButton) {
|
||||
logoutButton.addEventListener('click', () => {
|
||||
if (confirm('로그아웃 하시겠습니까?')) {
|
||||
clearAuthData();
|
||||
window.location.href = '/index.html';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Admin 버튼 제거됨
|
||||
|
||||
// System 버튼 클릭 이벤트
|
||||
const systemButton = document.getElementById('systemBtn');
|
||||
if (systemButton) {
|
||||
systemButton.addEventListener('click', () => {
|
||||
window.location.href = '/pages/dashboard/system.html';
|
||||
});
|
||||
}
|
||||
|
||||
// Dashboard 버튼 클릭 이벤트
|
||||
const dashboardButton = document.querySelector('.dashboard-btn');
|
||||
if (dashboardButton) {
|
||||
dashboardButton.addEventListener('click', () => {
|
||||
window.location.href = '/pages/dashboard/group-leader.html';
|
||||
});
|
||||
}
|
||||
|
||||
// 외부 클릭 시 드롭다운 닫기
|
||||
document.addEventListener('click', (e) => {
|
||||
if (profileDropdownMenu && !userInfoDropdown.contains(e.target) && !profileDropdownMenu.contains(e.target)) {
|
||||
profileDropdownMenu.classList.remove('show');
|
||||
userInfoDropdown.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 시간을 업데이트하는 함수
|
||||
*/
|
||||
function updateTime() {
|
||||
const timeElement = document.getElementById('current-time');
|
||||
if (timeElement) {
|
||||
const now = new Date();
|
||||
timeElement.textContent = now.toLocaleTimeString('ko-KR', { hour12: false });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 메인 로직: DOMContentLoaded 시 실행
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const navbarContainer = document.getElementById('navbar-container');
|
||||
if (!navbarContainer) return;
|
||||
|
||||
const currentUser = getUser();
|
||||
if (!currentUser) return; // 사용자가 없으면 아무 작업도 하지 않음
|
||||
|
||||
try {
|
||||
const response = await fetch('/components/navbar.html');
|
||||
const htmlText = await response.text();
|
||||
|
||||
// 1. 텍스트를 가상 DOM으로 파싱
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(htmlText, 'text/html');
|
||||
|
||||
// 2. DOM에 삽입하기 *전*에 내용 수정
|
||||
filterMenuByRole(doc, currentUser.role);
|
||||
populateUserInfo(doc, currentUser);
|
||||
|
||||
// 3. 수정 완료된 HTML을 실제 DOM에 삽입 (깜빡임 방지)
|
||||
navbarContainer.innerHTML = doc.body.innerHTML;
|
||||
|
||||
// 4. DOM에 삽입된 후에 이벤트 리스너 설정
|
||||
setupNavbarEvents();
|
||||
|
||||
// 5. 실시간 시간 업데이트 시작
|
||||
updateTime();
|
||||
setInterval(updateTime, 1000);
|
||||
|
||||
console.log('✅ 네비게이션 바 로딩 완료');
|
||||
|
||||
} catch (error) {
|
||||
console.error('🔴 네비게이션 바 로딩 중 오류 발생:', error);
|
||||
navbarContainer.innerHTML = '<p>네비게이션 바를 불러오는 데 실패했습니다.</p>';
|
||||
}
|
||||
});
|
||||
104
synology_deployment/web-ui/js/load-sections.js
Normal file
104
synology_deployment/web-ui/js/load-sections.js
Normal file
@@ -0,0 +1,104 @@
|
||||
// /js/load-sections.js
|
||||
import { getUser } from './auth.js';
|
||||
import { apiGet } from './api-helper.js';
|
||||
|
||||
// 역할에 따라 불러올 섹션 HTML 파일을 매핑합니다.
|
||||
const SECTION_MAP = {
|
||||
admin: '/components/sections/admin-sections.html',
|
||||
system: '/components/sections/admin-sections.html', // system도 admin과 동일한 섹션을 사용
|
||||
leader: '/components/sections/leader-sections.html',
|
||||
user: '/components/sections/user-sections.html',
|
||||
default: '/components/sections/user-sections.html', // 역할이 없는 경우 기본값
|
||||
};
|
||||
|
||||
/**
|
||||
* API를 통해 대시보드 통계 데이터를 가져옵니다.
|
||||
* @returns {Promise<object|null>} 통계 데이터 또는 에러 시 null
|
||||
*/
|
||||
async function fetchDashboardStats() {
|
||||
try {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
// 실제 백엔드 엔드포인트는 /api/dashboard/stats 와 같은 형태로 구현될 수 있습니다.
|
||||
const stats = await apiGet(`/workreports?start=${today}&end=${today}`);
|
||||
// 필요한 데이터 형태로 가공 (예시)
|
||||
return {
|
||||
today_reports_count: stats.length,
|
||||
today_workers_count: new Set(stats.map(d => d.worker_id)).size,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('대시보드 통계 데이터 로드 실패:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 가상 DOM에 통계 데이터를 채워 넣습니다.
|
||||
* @param {Document} doc - 파싱된 HTML 문서 객체
|
||||
* @param {object} stats - 통계 데이터
|
||||
*/
|
||||
function populateStatsData(doc, stats) {
|
||||
if (!stats) return;
|
||||
|
||||
const todayStatsEl = doc.getElementById('today-stats');
|
||||
if (todayStatsEl) {
|
||||
todayStatsEl.innerHTML = `
|
||||
<p>📝 오늘 등록된 작업: ${stats.today_reports_count}건</p>
|
||||
<p>👥 참여 작업자: ${stats.today_workers_count}명</p>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메인 로직: 페이지에 역할별 섹션을 로드하고 내용을 채웁니다.
|
||||
*/
|
||||
async function initializeSections() {
|
||||
const mainContainer = document.querySelector('main[id$="-sections"]');
|
||||
if (!mainContainer) {
|
||||
console.error('섹션을 담을 메인 컨테이너를 찾을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
mainContainer.innerHTML = '<div class="loading">콘텐츠를 불러오는 중...</div>';
|
||||
|
||||
const currentUser = getUser();
|
||||
if (!currentUser) {
|
||||
mainContainer.innerHTML = '<div class="error-state">사용자 정보를 찾을 수 없습니다.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const sectionFile = SECTION_MAP[currentUser.role] || SECTION_MAP.default;
|
||||
|
||||
try {
|
||||
// 1. 역할에 맞는 HTML 템플릿과 동적 데이터를 동시에 로드 (Promise.all 활용)
|
||||
const [htmlResponse, statsData] = await Promise.all([
|
||||
fetch(sectionFile),
|
||||
fetchDashboardStats()
|
||||
]);
|
||||
|
||||
if (!htmlResponse.ok) {
|
||||
throw new Error(`섹션 파일(${sectionFile})을 불러오는 데 실패했습니다.`);
|
||||
}
|
||||
const htmlText = await htmlResponse.text();
|
||||
|
||||
// 2. 텍스트를 가상 DOM으로 파싱
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(htmlText, 'text/html');
|
||||
|
||||
// 3. (필요 시) 역할 기반으로 가상 DOM 필터링 - 현재는 파일 자체가 역할별로 나뉘어 불필요
|
||||
// filterByRole(doc, currentUser.role);
|
||||
|
||||
// 4. 가상 DOM에 동적 데이터 채우기
|
||||
populateStatsData(doc, statsData);
|
||||
|
||||
// 5. 모든 수정이 완료된 HTML을 실제 DOM에 한 번에 삽입
|
||||
mainContainer.innerHTML = doc.body.innerHTML;
|
||||
|
||||
console.log(`✅ ${currentUser.role} 역할의 섹션 로딩 완료.`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('섹션 로딩 중 오류 발생:', error);
|
||||
mainContainer.innerHTML = `<div class="error-state">콘텐츠 로딩에 실패했습니다: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// DOM이 로드되면 섹션 초기화를 시작합니다.
|
||||
document.addEventListener('DOMContentLoaded', initializeSections);
|
||||
67
synology_deployment/web-ui/js/load-sidebar.js
Normal file
67
synology_deployment/web-ui/js/load-sidebar.js
Normal file
@@ -0,0 +1,67 @@
|
||||
// /js/load-sidebar.js
|
||||
import { getUser } from './auth.js';
|
||||
|
||||
/**
|
||||
* 사용자 역할에 따라 사이드바 메뉴 항목을 필터링합니다.
|
||||
* @param {Document} doc - 파싱된 HTML 문서 객체
|
||||
* @param {string} userRole - 현재 사용자의 역할
|
||||
*/
|
||||
function filterSidebarByRole(doc, userRole) {
|
||||
// 'system' 역할은 모든 메뉴를 볼 수 있으므로 필터링하지 않음
|
||||
if (userRole === 'system') {
|
||||
return;
|
||||
}
|
||||
|
||||
// 역할과 그에 해당하는 클래스 선택자 매핑
|
||||
const roleClassMap = {
|
||||
admin: '.admin-only',
|
||||
leader: '.leader-only',
|
||||
user: '.user-only', // 또는 'worker-only' 등, sidebar.html에 정의된 클래스에 맞춰야 함
|
||||
support: '.support-only'
|
||||
};
|
||||
|
||||
// 모든 역할 기반 선택자를 가져옴
|
||||
const allRoleSelectors = Object.values(roleClassMap).join(', ');
|
||||
const allRoleElements = doc.querySelectorAll(allRoleSelectors);
|
||||
|
||||
allRoleElements.forEach(el => {
|
||||
// 요소가 현재 사용자 역할에 해당하는 클래스를 가지고 있는지 확인
|
||||
const userRoleSelector = roleClassMap[userRole];
|
||||
if (!userRoleSelector || !el.matches(userRoleSelector)) {
|
||||
el.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const sidebarContainer = document.getElementById('sidebar-container');
|
||||
if (!sidebarContainer) return;
|
||||
|
||||
const currentUser = getUser();
|
||||
if (!currentUser) return; // 비로그인 상태면 사이드바를 로드하지 않음
|
||||
|
||||
try {
|
||||
const response = await fetch('/components/sidebar.html');
|
||||
if (!response.ok) {
|
||||
throw new Error(`사이드바 파일을 불러올 수 없습니다: ${response.statusText}`);
|
||||
}
|
||||
const htmlText = await response.text();
|
||||
|
||||
// 1. 텍스트를 가상 DOM으로 파싱
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(htmlText, 'text/html');
|
||||
|
||||
// 2. DOM에 삽입하기 *전*에 역할에 따라 메뉴 필터링
|
||||
filterSidebarByRole(doc, currentUser.role);
|
||||
|
||||
// 3. 수정 완료된 HTML을 실제 DOM에 삽입
|
||||
sidebarContainer.innerHTML = doc.body.innerHTML;
|
||||
|
||||
console.log('✅ 사이드바 로딩 및 필터링 완료');
|
||||
|
||||
} catch (error) {
|
||||
console.error('🔴 사이드바 로딩 실패:', error);
|
||||
sidebarContainer.innerHTML = '<p>메뉴 로딩 실패</p>';
|
||||
}
|
||||
});
|
||||
67
synology_deployment/web-ui/js/login.js
Normal file
67
synology_deployment/web-ui/js/login.js
Normal file
@@ -0,0 +1,67 @@
|
||||
// /js/login.js
|
||||
// ES6 모듈 의존성 제거 - 브라우저 호환성 개선
|
||||
|
||||
// 인증 데이터 저장 함수 (직접 구현)
|
||||
function saveAuthData(token, user) {
|
||||
localStorage.setItem('token', token);
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
}
|
||||
|
||||
function clearAuthData() {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
}
|
||||
|
||||
document.getElementById('loginForm').addEventListener('submit', async function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
const username = document.getElementById('username').value;
|
||||
const password = document.getElementById('password').value;
|
||||
const errorDiv = document.getElementById('error');
|
||||
|
||||
const submitBtn = e.target.querySelector('button[type="submit"]');
|
||||
const originalText = submitBtn.textContent;
|
||||
|
||||
// 로딩 상태 시작
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = '로그인 중...';
|
||||
errorDiv.style.display = 'none';
|
||||
|
||||
try {
|
||||
// API 헬퍼를 통해 로그인 요청 (window 객체에서 가져오기)
|
||||
const result = await window.login(username, password);
|
||||
|
||||
if (result.success && result.data && result.data.token) {
|
||||
// 인증 정보 저장
|
||||
saveAuthData(result.data.token, result.data.user);
|
||||
|
||||
// 백엔드가 지정한 URL로 리디렉션
|
||||
const redirectUrl = result.data.redirectUrl || '/pages/dashboard/user.html'; // 혹시 모를 예외처리
|
||||
|
||||
// 부드러운 화면 전환 효과
|
||||
document.body.style.transition = 'opacity 0.3s ease-out';
|
||||
document.body.style.opacity = '0';
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.href = redirectUrl;
|
||||
}, 300);
|
||||
|
||||
} else {
|
||||
// 이 케이스는 api-helper에서 throw new Error()로 처리되어 catch 블록으로 바로 이동합니다.
|
||||
// 하지만, 만약의 경우를 대비해 방어 코드를 남겨둡니다.
|
||||
clearAuthData();
|
||||
errorDiv.textContent = result.error || '로그인에 실패했습니다.';
|
||||
errorDiv.style.display = 'block';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('로그인 오류:', err);
|
||||
clearAuthData();
|
||||
// api-helper에서 보낸 에러 메시지를 표시
|
||||
errorDiv.textContent = err.message || '서버 연결에 실패했습니다.';
|
||||
errorDiv.style.display = 'block';
|
||||
} finally {
|
||||
// 로딩 상태 해제
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = originalText;
|
||||
}
|
||||
});
|
||||
86
synology_deployment/web-ui/js/manage-issue.js
Normal file
86
synology_deployment/web-ui/js/manage-issue.js
Normal file
@@ -0,0 +1,86 @@
|
||||
import { API, getAuthHeaders } from '/js/api-config.js';
|
||||
|
||||
function createRow(item, cols, delHandler) {
|
||||
const tr = document.createElement('tr');
|
||||
cols.forEach(key => {
|
||||
const td = document.createElement('td');
|
||||
td.textContent = item[key];
|
||||
tr.appendChild(td);
|
||||
});
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.textContent = '삭제';
|
||||
delBtn.className = 'btn-delete';
|
||||
delBtn.onclick = () => delHandler(item);
|
||||
const td = document.createElement('td');
|
||||
td.appendChild(delBtn);
|
||||
tr.appendChild(td);
|
||||
return tr;
|
||||
}
|
||||
|
||||
const form = document.getElementById('issueTypeForm');
|
||||
form?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const body = {
|
||||
category: document.getElementById('category').value,
|
||||
subcategory: document.getElementById('subcategory').value
|
||||
};
|
||||
try {
|
||||
const res = await fetch(`${API}/issue-types`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const result = await res.json();
|
||||
if (res.ok && result.success) {
|
||||
alert('✅ 등록 완료');
|
||||
form.reset();
|
||||
loadIssueTypes();
|
||||
} else {
|
||||
alert('❌ 실패: ' + (result.error || '알 수 없는 오류'));
|
||||
}
|
||||
} catch (err) {
|
||||
alert('🚨 서버 오류: ' + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
async function loadIssueTypes() {
|
||||
const tbody = document.getElementById('issueTypeTableBody');
|
||||
tbody.innerHTML = '<tr><td colspan="4">불러오는 중...</td></tr>';
|
||||
try {
|
||||
const res = await fetch(`${API}/issue-types`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const list = await res.json();
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (Array.isArray(list)) {
|
||||
list.forEach(item => {
|
||||
const row = createRow(item, ['issue_type_id', 'category', 'subcategory'], async t => {
|
||||
if (!confirm('삭제하시겠습니까?')) return;
|
||||
try {
|
||||
const delRes = await fetch(`${API}/issue-types/${t.issue_type_id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
if (delRes.ok) {
|
||||
loadIssueTypes();
|
||||
} else {
|
||||
alert('삭제 실패');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('삭제 중 오류: ' + err.message);
|
||||
}
|
||||
});
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
} else {
|
||||
tbody.innerHTML = '<tr><td colspan="4">데이터 형식 오류</td></tr>';
|
||||
}
|
||||
} catch (err) {
|
||||
tbody.innerHTML = '<tr><td colspan="4">로드 실패: ' + err.message + '</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadIssueTypes();
|
||||
});
|
||||
93
synology_deployment/web-ui/js/manage-pipespec.js
Normal file
93
synology_deployment/web-ui/js/manage-pipespec.js
Normal file
@@ -0,0 +1,93 @@
|
||||
import { API, getAuthHeaders, ensureAuthenticated } from '/js/api-config.js';
|
||||
|
||||
// 인증 확인
|
||||
ensureAuthenticated();
|
||||
|
||||
// 행 생성
|
||||
function createRow(item, delHandler) {
|
||||
const tr = document.createElement('tr');
|
||||
const label = `${item.material} / ${item.diameter_in} / ${item.schedule}`;
|
||||
tr.innerHTML = `
|
||||
<td>${item.spec_id}</td>
|
||||
<td>${label}</td>
|
||||
<td><button class="btn-delete">삭제</button></td>
|
||||
`;
|
||||
tr.querySelector('.btn-delete').onclick = () => delHandler(item);
|
||||
return tr;
|
||||
}
|
||||
|
||||
// 등록
|
||||
document.getElementById('specForm')?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const material = document.getElementById('material').value.trim();
|
||||
const diameter = document.getElementById('diameter_in').value.trim();
|
||||
const schedule = document.getElementById('schedule').value.trim();
|
||||
|
||||
if (!material || !diameter || !schedule) {
|
||||
return alert('모든 항목을 입력하세요.');
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/pipespecs`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ material, diameter_in: diameter, schedule })
|
||||
});
|
||||
const result = await res.json();
|
||||
if (res.ok && result.success) {
|
||||
alert('✅ 등록 완료');
|
||||
e.target.reset();
|
||||
loadSpecs();
|
||||
} else {
|
||||
alert('❌ 실패: ' + (result.error || '등록 실패'));
|
||||
}
|
||||
} catch (err) {
|
||||
alert('🚨 서버 오류: ' + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
// 불러오기
|
||||
async function loadSpecs() {
|
||||
const tbody = document.getElementById('specTableBody');
|
||||
tbody.innerHTML = '<tr><td colspan="3">불러오는 중...</td></tr>';
|
||||
try {
|
||||
const res = await fetch(`${API}/pipespecs`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error! status: ${res.status}`);
|
||||
}
|
||||
|
||||
const list = await res.json();
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (Array.isArray(list)) {
|
||||
list.forEach(item => {
|
||||
const row = createRow(item, async (spec) => {
|
||||
if (!confirm('삭제하시겠습니까?')) return;
|
||||
try {
|
||||
const delRes = await fetch(`${API}/pipespecs/${spec.spec_id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
if (delRes.ok) {
|
||||
loadSpecs();
|
||||
} else {
|
||||
alert('삭제 실패');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('삭제 중 오류: ' + err.message);
|
||||
}
|
||||
});
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
} else {
|
||||
tbody.innerHTML = '<tr><td colspan="3">데이터 형식 오류</td></tr>';
|
||||
}
|
||||
} catch (err) {
|
||||
tbody.innerHTML = '<tr><td colspan="3">로드 실패: ' + err.message + '</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('DOMContentLoaded', loadSpecs);
|
||||
108
synology_deployment/web-ui/js/manage-project.js
Normal file
108
synology_deployment/web-ui/js/manage-project.js
Normal file
@@ -0,0 +1,108 @@
|
||||
// /js/manage-project.js
|
||||
|
||||
import { API, getAuthHeaders, ensureAuthenticated } from '/js/api-config.js';
|
||||
|
||||
// 인증 확인
|
||||
ensureAuthenticated();
|
||||
|
||||
function createRow(item, cols, delHandler) {
|
||||
const tr = document.createElement('tr');
|
||||
cols.forEach(key => {
|
||||
const td = document.createElement('td');
|
||||
td.textContent = item[key];
|
||||
tr.appendChild(td);
|
||||
});
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.textContent = '삭제';
|
||||
delBtn.className = 'btn-delete';
|
||||
delBtn.onclick = () => delHandler(item);
|
||||
const td = document.createElement('td');
|
||||
td.appendChild(delBtn);
|
||||
tr.appendChild(td);
|
||||
return tr;
|
||||
}
|
||||
|
||||
const projectForm = document.getElementById('projectForm');
|
||||
projectForm?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const body = {
|
||||
job_no: document.getElementById('job_no').value.trim(),
|
||||
project_name: document.getElementById('project_name').value.trim(),
|
||||
contract_date: document.getElementById('contract_date').value,
|
||||
due_date: document.getElementById('due_date').value,
|
||||
delivery_method: document.getElementById('delivery_method').value.trim(),
|
||||
site: document.getElementById('site').value.trim(),
|
||||
pm: document.getElementById('pm').value.trim()
|
||||
};
|
||||
|
||||
if (!body.project_name || !body.job_no) {
|
||||
return alert('필수 항목을 입력하세요.');
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/projects`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const result = await res.json();
|
||||
if (res.ok && result.success) {
|
||||
alert('✅ 등록 완료');
|
||||
projectForm.reset();
|
||||
loadProjects();
|
||||
} else {
|
||||
alert('❌ 실패: ' + (result.error || '알 수 없는 오류'));
|
||||
}
|
||||
} catch (err) {
|
||||
alert('🚨 서버 오류: ' + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
async function loadProjects() {
|
||||
const tbody = document.getElementById('projectTableBody');
|
||||
tbody.innerHTML = '<tr><td colspan="9">불러오는 중...</td></tr>';
|
||||
try {
|
||||
const res = await fetch(`${API}/projects`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error! status: ${res.status}`);
|
||||
}
|
||||
|
||||
const list = await res.json();
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (Array.isArray(list)) {
|
||||
list.forEach(item => {
|
||||
const row = createRow(item, [
|
||||
'project_id', 'job_no', 'project_name', 'contract_date',
|
||||
'due_date', 'delivery_method', 'site', 'pm'
|
||||
], async p => {
|
||||
if (!confirm('삭제하시겠습니까?')) return;
|
||||
try {
|
||||
const delRes = await fetch(`${API}/projects/${p.project_id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
if (delRes.ok) {
|
||||
alert('✅ 삭제 완료');
|
||||
loadProjects();
|
||||
} else {
|
||||
alert('❌ 삭제 실패');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('🚨 삭제 중 오류: ' + err.message);
|
||||
}
|
||||
});
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
} else {
|
||||
tbody.innerHTML = '<tr><td colspan="9">데이터 형식 오류</td></tr>';
|
||||
}
|
||||
} catch (err) {
|
||||
tbody.innerHTML = '<tr><td colspan="9">로드 실패: ' + err.message + '</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('DOMContentLoaded', loadProjects);
|
||||
288
synology_deployment/web-ui/js/manage-user.js
Normal file
288
synology_deployment/web-ui/js/manage-user.js
Normal file
@@ -0,0 +1,288 @@
|
||||
import { API, getAuthHeaders, ensureAuthenticated } from '/js/api-config.js';
|
||||
|
||||
// 인증 확인
|
||||
const token = ensureAuthenticated();
|
||||
|
||||
const accessLabels = {
|
||||
worker: '작업자',
|
||||
group_leader: '그룹장',
|
||||
support_team: '지원팀',
|
||||
admin: '관리자',
|
||||
system: '시스템'
|
||||
};
|
||||
|
||||
// 현재 사용자 정보 가져오기
|
||||
const currentUser = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
const isSystemUser = currentUser.access_level === 'system';
|
||||
|
||||
function createRow(item, cols, delHandler) {
|
||||
const tr = document.createElement('tr');
|
||||
cols.forEach(key => {
|
||||
const td = document.createElement('td');
|
||||
td.textContent = item[key] || '-';
|
||||
tr.appendChild(td);
|
||||
});
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.textContent = '삭제';
|
||||
delBtn.className = 'btn-delete';
|
||||
delBtn.onclick = () => delHandler(item);
|
||||
const td = document.createElement('td');
|
||||
td.appendChild(delBtn);
|
||||
tr.appendChild(td);
|
||||
return tr;
|
||||
}
|
||||
|
||||
// 내 비밀번호 변경
|
||||
const myPasswordForm = document.getElementById('myPasswordForm');
|
||||
myPasswordForm?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
|
||||
const currentPassword = document.getElementById('currentPassword').value;
|
||||
const newPassword = document.getElementById('newPassword').value;
|
||||
const confirmPassword = document.getElementById('confirmPassword').value;
|
||||
|
||||
// 새 비밀번호 확인
|
||||
if (newPassword !== confirmPassword) {
|
||||
alert('❌ 새 비밀번호가 일치하지 않습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 비밀번호 강도 검사
|
||||
if (newPassword.length < 6) {
|
||||
alert('❌ 비밀번호는 최소 6자 이상이어야 합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/auth/change-password`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
currentPassword,
|
||||
newPassword
|
||||
})
|
||||
});
|
||||
|
||||
const result = await res.json();
|
||||
|
||||
if (res.ok && result.success) {
|
||||
showToast('✅ 비밀번호가 변경되었습니다.');
|
||||
myPasswordForm.reset();
|
||||
|
||||
// 3초 후 로그인 페이지로 이동
|
||||
setTimeout(() => {
|
||||
alert('비밀번호가 변경되어 다시 로그인해주세요.');
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/index.html';
|
||||
}, 2000);
|
||||
} else {
|
||||
alert('❌ 비밀번호 변경 실패: ' + (result.error || '현재 비밀번호가 올바르지 않습니다.'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Password change error:', error);
|
||||
alert('🚨 서버 오류: ' + error.message);
|
||||
}
|
||||
});
|
||||
|
||||
// 시스템 권한자만 볼 수 있는 사용자 비밀번호 변경 섹션
|
||||
if (isSystemUser) {
|
||||
const systemCard = document.getElementById('systemPasswordChangeCard');
|
||||
if (systemCard) {
|
||||
systemCard.style.display = 'block';
|
||||
}
|
||||
|
||||
// 사용자 비밀번호 변경 (시스템 권한자)
|
||||
const userPasswordForm = document.getElementById('userPasswordForm');
|
||||
userPasswordForm?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
|
||||
const targetUserId = document.getElementById('targetUserId').value;
|
||||
const newPassword = document.getElementById('targetNewPassword').value;
|
||||
|
||||
if (!targetUserId) {
|
||||
alert('❌ 사용자를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
alert('❌ 비밀번호는 최소 6자 이상이어야 합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm('정말로 이 사용자의 비밀번호를 변경하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/auth/admin/change-password`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
userId: targetUserId,
|
||||
newPassword
|
||||
})
|
||||
});
|
||||
|
||||
const result = await res.json();
|
||||
|
||||
if (res.ok && result.success) {
|
||||
showToast('✅ 사용자 비밀번호가 변경되었습니다.');
|
||||
userPasswordForm.reset();
|
||||
} else {
|
||||
alert('❌ 비밀번호 변경 실패: ' + (result.error || '권한이 없습니다.'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Admin password change error:', error);
|
||||
alert('🚨 서버 오류: ' + error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 사용자 등록
|
||||
const userForm = document.getElementById('userForm');
|
||||
userForm?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const body = {
|
||||
username: document.getElementById('username').value.trim(),
|
||||
password: document.getElementById('password').value.trim(),
|
||||
name: document.getElementById('name').value.trim(),
|
||||
access_level: document.getElementById('access_level').value,
|
||||
worker_id: document.getElementById('worker_id').value || null
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const result = await res.json();
|
||||
if (res.ok && result.success) {
|
||||
showToast('✅ 등록 완료');
|
||||
userForm.reset();
|
||||
loadUsers();
|
||||
} else {
|
||||
alert('❌ 실패: ' + (result.error || '알 수 없는 오류'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
alert('🚨 서버 오류: ' + error.message);
|
||||
}
|
||||
});
|
||||
|
||||
async function loadUsers() {
|
||||
const tbody = document.getElementById('userTableBody');
|
||||
tbody.innerHTML = '<tr><td colspan="6">불러오는 중...</td></tr>';
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/auth/users`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error! status: ${res.status}`);
|
||||
}
|
||||
|
||||
const list = await res.json();
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (Array.isArray(list)) {
|
||||
// 시스템 권한자용 사용자 선택 옵션도 업데이트
|
||||
if (isSystemUser) {
|
||||
const targetUserSelect = document.getElementById('targetUserId');
|
||||
if (targetUserSelect) {
|
||||
targetUserSelect.innerHTML = '<option value="">사용자 선택</option>';
|
||||
list.forEach(user => {
|
||||
// 본인은 제외
|
||||
if (user.user_id !== currentUser.user_id) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = user.user_id;
|
||||
opt.textContent = `${user.name} (${user.username})`;
|
||||
targetUserSelect.appendChild(opt);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
list.forEach(item => {
|
||||
item.access_level = accessLabels[item.access_level] || item.access_level;
|
||||
item.worker_id = item.worker_id || '-';
|
||||
const row = createRow(item, [
|
||||
'user_id', 'username', 'name', 'access_level', 'worker_id'
|
||||
], async u => {
|
||||
if (!confirm('삭제하시겠습니까?')) return;
|
||||
try {
|
||||
const delRes = await fetch(`${API}/auth/users/${u.user_id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
if (delRes.ok) {
|
||||
showToast('✅ 삭제 완료');
|
||||
loadUsers();
|
||||
} else {
|
||||
alert('❌ 삭제 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('🚨 삭제 중 오류 발생');
|
||||
}
|
||||
});
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
} else {
|
||||
tbody.innerHTML = '<tr><td colspan="6">데이터 형식 오류</td></tr>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load users error:', error);
|
||||
tbody.innerHTML = '<tr><td colspan="6">로드 실패: ' + error.message + '</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorkerOptions() {
|
||||
const select = document.getElementById('worker_id');
|
||||
if (!select) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/workers`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error! status: ${res.status}`);
|
||||
}
|
||||
|
||||
const workers = await res.json();
|
||||
if (Array.isArray(workers)) {
|
||||
workers.forEach(w => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = w.worker_id;
|
||||
opt.textContent = `${w.worker_name} (${w.worker_id})`;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('작업자 목록 불러오기 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(message) {
|
||||
const toast = document.createElement('div');
|
||||
toast.textContent = message;
|
||||
toast.style.position = 'fixed';
|
||||
toast.style.bottom = '30px';
|
||||
toast.style.left = '50%';
|
||||
toast.style.transform = 'translateX(-50%)';
|
||||
toast.style.background = '#323232';
|
||||
toast.style.color = '#fff';
|
||||
toast.style.padding = '10px 20px';
|
||||
toast.style.borderRadius = '6px';
|
||||
toast.style.fontSize = '14px';
|
||||
toast.style.zIndex = 9999;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 2000);
|
||||
}
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
loadUsers();
|
||||
loadWorkerOptions();
|
||||
});
|
||||
111
synology_deployment/web-ui/js/manage-worker.js
Normal file
111
synology_deployment/web-ui/js/manage-worker.js
Normal file
@@ -0,0 +1,111 @@
|
||||
// /js/manage-worker.js
|
||||
|
||||
import { API, getAuthHeaders, ensureAuthenticated } from '/js/api-config.js';
|
||||
|
||||
// 인증 확인
|
||||
ensureAuthenticated();
|
||||
|
||||
// ✅ 테이블 행 생성
|
||||
function createRow(item, cols, delHandler) {
|
||||
const tr = document.createElement('tr');
|
||||
cols.forEach(key => {
|
||||
const td = document.createElement('td');
|
||||
td.textContent = item[key] || '-';
|
||||
tr.appendChild(td);
|
||||
});
|
||||
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.textContent = '삭제';
|
||||
delBtn.className = 'btn-delete';
|
||||
delBtn.onclick = () => delHandler(item);
|
||||
|
||||
const td = document.createElement('td');
|
||||
td.appendChild(delBtn);
|
||||
tr.appendChild(td);
|
||||
|
||||
return tr;
|
||||
}
|
||||
|
||||
// ✅ 작업자 등록
|
||||
const workerForm = document.getElementById('workerForm');
|
||||
workerForm?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
|
||||
const body = {
|
||||
worker_name: document.getElementById('workerName').value.trim(),
|
||||
position: document.getElementById('position').value.trim()
|
||||
};
|
||||
|
||||
if (!body.worker_name || !body.position) {
|
||||
return alert('모든 필드를 입력해주세요.');
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/workers`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const result = await res.json();
|
||||
|
||||
if (res.ok && result.success) {
|
||||
alert('✅ 등록 완료');
|
||||
workerForm.reset();
|
||||
loadWorkers();
|
||||
} else {
|
||||
alert('❌ 실패: ' + (result.error || '알 수 없는 오류'));
|
||||
}
|
||||
} catch (err) {
|
||||
alert('🚨 서버 오류: ' + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
// ✅ 작업자 목록 불러오기
|
||||
async function loadWorkers() {
|
||||
const tbody = document.getElementById('workerTableBody');
|
||||
tbody.innerHTML = '<tr><td colspan="4">불러오는 중...</td></tr>';
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/workers`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error! status: ${res.status}`);
|
||||
}
|
||||
|
||||
const response = await res.json();
|
||||
const list = response.data || response; // 새로운 API 응답 구조 지원
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (Array.isArray(list)) {
|
||||
list.forEach(item => {
|
||||
const row = createRow(item, ['worker_id', 'worker_name', 'position'], async w => {
|
||||
if (!confirm('삭제하시겠습니까?')) return;
|
||||
try {
|
||||
const delRes = await fetch(`${API}/workers/${w.worker_id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
if (delRes.ok) {
|
||||
alert('✅ 삭제 완료');
|
||||
loadWorkers();
|
||||
} else {
|
||||
alert('❌ 삭제 실패');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('🚨 삭제 중 오류: ' + err.message);
|
||||
}
|
||||
});
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
} else {
|
||||
tbody.innerHTML = '<tr><td colspan="4">데이터 형식 오류</td></tr>';
|
||||
}
|
||||
} catch (err) {
|
||||
tbody.innerHTML = '<tr><td colspan="4">로드 실패: ' + err.message + '</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 초기 로딩
|
||||
window.addEventListener('DOMContentLoaded', loadWorkers);
|
||||
954
synology_deployment/web-ui/js/management-dashboard.js
Normal file
954
synology_deployment/web-ui/js/management-dashboard.js
Normal file
@@ -0,0 +1,954 @@
|
||||
// management-dashboard.js - 관리자 대시보드 전용 스크립트
|
||||
|
||||
// =================================================================
|
||||
// 🌐 통합 API 설정 import
|
||||
// =================================================================
|
||||
import { API, getAuthHeaders, apiCall } from '/js/api-config.js';
|
||||
|
||||
// 전역 변수
|
||||
let workers = [];
|
||||
let workData = [];
|
||||
let filteredWorkData = [];
|
||||
let currentDate = '';
|
||||
let currentUser = null;
|
||||
|
||||
// 권한 레벨 매핑
|
||||
const ACCESS_LEVELS = {
|
||||
worker: 1,
|
||||
group_leader: 2,
|
||||
support_team: 3,
|
||||
admin: 4,
|
||||
system: 5
|
||||
};
|
||||
|
||||
// 한국 시간 기준 오늘 날짜 가져오기
|
||||
function getKoreaToday() {
|
||||
const today = new Date();
|
||||
const year = today.getFullYear();
|
||||
const month = String(today.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(today.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// 현재 로그인한 사용자 정보 가져오기
|
||||
function getCurrentUser() {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return null;
|
||||
|
||||
const payloadBase64 = token.split('.')[1];
|
||||
if (payloadBase64) {
|
||||
const payload = JSON.parse(atob(payloadBase64));
|
||||
console.log('토큰에서 추출한 사용자 정보:', payload);
|
||||
return payload;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('토큰에서 사용자 정보 추출 실패:', error);
|
||||
}
|
||||
|
||||
try {
|
||||
const userInfo = localStorage.getItem('user') || localStorage.getItem('userInfo') || localStorage.getItem('currentUser');
|
||||
if (userInfo) {
|
||||
const parsed = JSON.parse(userInfo);
|
||||
console.log('localStorage에서 가져온 사용자 정보:', parsed);
|
||||
return parsed;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('localStorage에서 사용자 정보 가져오기 실패:', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// 권한 체크 함수
|
||||
function checkPermission() {
|
||||
currentUser = getCurrentUser();
|
||||
|
||||
if (!currentUser) {
|
||||
showMessage('로그인이 필요합니다.', 'error');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 2000);
|
||||
return false;
|
||||
}
|
||||
|
||||
const userAccessLevel = currentUser.access_level;
|
||||
const accessLevelValue = ACCESS_LEVELS[userAccessLevel] || 0;
|
||||
|
||||
console.log('사용자 권한 체크:', {
|
||||
username: currentUser.username || currentUser.name,
|
||||
access_level: userAccessLevel,
|
||||
level_value: accessLevelValue,
|
||||
required_level: ACCESS_LEVELS.group_leader
|
||||
});
|
||||
|
||||
if (accessLevelValue < ACCESS_LEVELS.group_leader) {
|
||||
showMessage('그룹장 이상의 권한이 필요합니다. 현재 권한: ' + userAccessLevel, 'error');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 3000);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// 메시지 표시
|
||||
function showMessage(message, type = 'info') {
|
||||
const container = document.getElementById('message-container');
|
||||
container.innerHTML = `<div class="message ${type}">${message}</div>`;
|
||||
|
||||
if (type === 'success') {
|
||||
setTimeout(() => {
|
||||
hideMessage();
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function hideMessage() {
|
||||
document.getElementById('message-container').innerHTML = '';
|
||||
}
|
||||
|
||||
// 로딩 표시
|
||||
function showLoading() {
|
||||
document.getElementById('loadingSpinner').style.display = 'flex';
|
||||
document.getElementById('summarySection').style.display = 'none';
|
||||
document.getElementById('actionBar').style.display = 'none';
|
||||
document.getElementById('workersSection').style.display = 'none';
|
||||
document.getElementById('noDataMessage').style.display = 'none';
|
||||
}
|
||||
|
||||
function hideLoading() {
|
||||
document.getElementById('loadingSpinner').style.display = 'none';
|
||||
}
|
||||
|
||||
// 작업자 데이터 로드
|
||||
async function loadWorkers() {
|
||||
try {
|
||||
console.log('작업자 데이터 로딩 중... (통합 API)');
|
||||
const data = await apiCall(`${API}/workers`);
|
||||
workers = Array.isArray(data) ? data : (data.data || data.workers || []);
|
||||
console.log('✅ 작업자 로드 성공:', workers.length);
|
||||
} catch (error) {
|
||||
console.error('작업자 로딩 오류:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 특정 날짜의 작업 데이터 로드 (개선된 버전)
|
||||
async function loadWorkData(date) {
|
||||
try {
|
||||
console.log(`${date} 날짜의 작업 데이터 로딩 중... (통합 API)`);
|
||||
|
||||
// 1차: view_all=true로 전체 데이터 시도
|
||||
let queryParams = `date=${date}&view_all=true`;
|
||||
console.log(`🔍 1차 시도: ${API}/daily-work-reports?${queryParams}`);
|
||||
|
||||
let data = await apiCall(`${API}/daily-work-reports?${queryParams}`);
|
||||
workData = Array.isArray(data) ? data : (data.data || []);
|
||||
|
||||
// 데이터가 없으면 다른 방법들 시도
|
||||
if (workData.length === 0) {
|
||||
console.log('⚠️ view_all로 데이터 없음, 다른 방법 시도...');
|
||||
|
||||
// 2차: admin=true로 시도
|
||||
queryParams = `date=${date}&admin=true`;
|
||||
console.log(`🔍 2차 시도: ${API}/daily-work-reports?${queryParams}`);
|
||||
|
||||
data = await apiCall(`${API}/daily-work-reports?${queryParams}`);
|
||||
workData = Array.isArray(data) ? data : (data.data || []);
|
||||
|
||||
if (workData.length === 0) {
|
||||
// 3차: 날짜 경로 파라미터로 시도
|
||||
console.log(`🔍 3차 시도: ${API}/daily-work-reports/date/${date}`);
|
||||
|
||||
data = await apiCall(`${API}/daily-work-reports/date/${date}`);
|
||||
workData = Array.isArray(data) ? data : (data.data || []);
|
||||
|
||||
if (workData.length === 0) {
|
||||
// 4차: 기본 파라미터만으로 시도
|
||||
console.log(`🔍 4차 시도: ${API}/daily-work-reports?date=${date}`);
|
||||
|
||||
data = await apiCall(`${API}/daily-work-reports?date=${date}`);
|
||||
workData = Array.isArray(data) ? data : (data.data || []);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ 최종 작업 데이터 로드 결과: ${workData.length}개`);
|
||||
|
||||
// 디버깅을 위한 상세 로그
|
||||
if (workData.length > 0) {
|
||||
console.log('📊 로드된 데이터 샘플:', workData.slice(0, 3));
|
||||
const uniqueWorkers = [...new Set(workData.map(w => w.worker_name))];
|
||||
console.log('👥 데이터에 포함된 작업자들:', uniqueWorkers);
|
||||
} else {
|
||||
console.log('❌ 해당 날짜에 작업 데이터가 없거나 접근 권한이 없습니다.');
|
||||
}
|
||||
|
||||
return workData;
|
||||
} catch (error) {
|
||||
console.error('작업 데이터 로딩 오류:', error);
|
||||
|
||||
// 에러 시에도 빈 배열 반환하여 앱이 중단되지 않도록
|
||||
workData = [];
|
||||
|
||||
// 구체적인 에러 정보 표시
|
||||
if (error.message.includes('403')) {
|
||||
console.log('🔒 권한 부족으로 인한 접근 제한');
|
||||
throw new Error('해당 날짜의 데이터에 접근할 권한이 없습니다.');
|
||||
} else if (error.message.includes('404')) {
|
||||
console.log('📭 해당 날짜에 데이터 없음');
|
||||
throw new Error('해당 날짜에 입력된 작업 데이터가 없습니다.');
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 대시보드 데이터 로드
|
||||
async function loadDashboardData() {
|
||||
const selectedDate = document.getElementById('selectedDate').value;
|
||||
|
||||
if (!selectedDate) {
|
||||
showMessage('날짜를 선택해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
currentDate = selectedDate;
|
||||
showLoading();
|
||||
hideMessage();
|
||||
|
||||
try {
|
||||
// 병렬로 데이터 로드
|
||||
await Promise.all([
|
||||
loadWorkers(),
|
||||
loadWorkData(selectedDate)
|
||||
]);
|
||||
|
||||
// 데이터 분석 및 표시
|
||||
const dashboardData = analyzeDashboardData();
|
||||
displayDashboard(dashboardData);
|
||||
|
||||
hideLoading();
|
||||
|
||||
} catch (error) {
|
||||
console.error('대시보드 데이터 로드 실패:', error);
|
||||
hideLoading();
|
||||
showMessage('데이터를 불러오는 중 오류가 발생했습니다: ' + error.message, 'error');
|
||||
|
||||
// 에러 시 데이터 없음 메시지 표시
|
||||
document.getElementById('noDataMessage').style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// 대시보드 데이터 분석 (개선된 버전)
|
||||
function analyzeDashboardData() {
|
||||
console.log('대시보드 데이터 분석 시작');
|
||||
|
||||
// 작업자별 데이터 그룹화
|
||||
const workerWorkData = {};
|
||||
workData.forEach(work => {
|
||||
const workerId = work.worker_id;
|
||||
if (!workerWorkData[workerId]) {
|
||||
workerWorkData[workerId] = [];
|
||||
}
|
||||
workerWorkData[workerId].push(work);
|
||||
});
|
||||
|
||||
// 전체 통계 계산
|
||||
const totalWorkers = workers.length;
|
||||
const workersWithData = Object.keys(workerWorkData).length;
|
||||
const workersWithoutData = totalWorkers - workersWithData;
|
||||
const totalHours = workData.reduce((sum, work) => sum + parseFloat(work.work_hours || 0), 0);
|
||||
const totalEntries = workData.length;
|
||||
const errorCount = workData.filter(work => work.work_status_id === 2).length;
|
||||
|
||||
// 작업자별 상세 분석 (개선된 버전)
|
||||
const workerAnalysis = workers.map(worker => {
|
||||
const workerWorks = workerWorkData[worker.worker_id] || [];
|
||||
const workerHours = workerWorks.reduce((sum, work) => sum + parseFloat(work.work_hours || 0), 0);
|
||||
|
||||
// 작업 유형 분석 (실제 이름으로)
|
||||
const workTypes = [...new Set(workerWorks.map(work => work.work_type_name).filter(Boolean))];
|
||||
|
||||
// 프로젝트 분석
|
||||
const workerProjects = [...new Set(workerWorks.map(work => work.project_name).filter(Boolean))];
|
||||
|
||||
// 기여자 분석
|
||||
const workerContributors = [...new Set(workerWorks.map(work => work.created_by_name).filter(Boolean))];
|
||||
|
||||
// 상태 결정 (더 세밀한 기준)
|
||||
let status = 'missing';
|
||||
if (workerWorks.length > 0) {
|
||||
if (workerHours >= 6) {
|
||||
status = 'completed'; // 6시간 이상을 완료로 간주
|
||||
} else {
|
||||
status = 'partial'; // 1시간 이상이지만 6시간 미만은 부분입력
|
||||
}
|
||||
}
|
||||
|
||||
// 최근 업데이트 시간
|
||||
const lastUpdate = workerWorks.length > 0
|
||||
? new Date(Math.max(...workerWorks.map(work => new Date(work.created_at))))
|
||||
: null;
|
||||
|
||||
return {
|
||||
...worker,
|
||||
status,
|
||||
totalHours: Math.round(workerHours * 10) / 10, // 소수점 1자리로 반올림
|
||||
entryCount: workerWorks.length,
|
||||
workTypes, // 작업 유형 배열 (실제 이름)
|
||||
projects: workerProjects,
|
||||
contributors: workerContributors,
|
||||
lastUpdate,
|
||||
works: workerWorks
|
||||
};
|
||||
});
|
||||
|
||||
const summary = {
|
||||
totalWorkers,
|
||||
completedWorkers: workerAnalysis.filter(w => w.status === 'completed').length,
|
||||
missingWorkers: workerAnalysis.filter(w => w.status === 'missing').length,
|
||||
partialWorkers: workerAnalysis.filter(w => w.status === 'partial').length,
|
||||
totalHours: Math.round(totalHours * 10) / 10,
|
||||
totalEntries,
|
||||
errorCount
|
||||
};
|
||||
|
||||
console.log('대시보드 분석 결과:', { summary, workerAnalysis });
|
||||
|
||||
return {
|
||||
summary,
|
||||
workers: workerAnalysis,
|
||||
date: currentDate
|
||||
};
|
||||
}
|
||||
|
||||
// 대시보드 표시
|
||||
function displayDashboard(data) {
|
||||
displaySummary(data.summary);
|
||||
displayWorkers(data.workers);
|
||||
|
||||
// 섹션 표시
|
||||
document.getElementById('summarySection').style.display = 'block';
|
||||
document.getElementById('actionBar').style.display = 'flex';
|
||||
document.getElementById('workersSection').style.display = 'block';
|
||||
|
||||
// 필터링 설정
|
||||
filteredWorkData = data.workers;
|
||||
setupFiltering();
|
||||
|
||||
console.log('✅ 대시보드 표시 완료');
|
||||
}
|
||||
|
||||
// 요약 섹션 표시
|
||||
function displaySummary(summary) {
|
||||
document.getElementById('totalWorkers').textContent = summary.totalWorkers;
|
||||
document.getElementById('completedWorkers').textContent = summary.completedWorkers;
|
||||
document.getElementById('missingWorkers').textContent = summary.missingWorkers;
|
||||
document.getElementById('totalHours').textContent = summary.totalHours + 'h';
|
||||
document.getElementById('totalEntries').textContent = summary.totalEntries;
|
||||
document.getElementById('errorCount').textContent = summary.errorCount;
|
||||
}
|
||||
|
||||
// 작업자 목록 표시 (테이블 형태로 개선)
|
||||
function displayWorkers(workersData) {
|
||||
const tableBody = document.getElementById('workersTableBody');
|
||||
tableBody.innerHTML = '';
|
||||
|
||||
if (workersData.length === 0) {
|
||||
tableBody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="9" class="no-data-row">표시할 작업자가 없습니다.</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
workersData.forEach(worker => {
|
||||
const row = createWorkerRow(worker);
|
||||
tableBody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
// 작업자 테이블 행 생성 (개선된 버전)
|
||||
function createWorkerRow(worker) {
|
||||
const row = document.createElement('tr');
|
||||
|
||||
const statusText = {
|
||||
completed: '✅ 완료',
|
||||
missing: '❌ 미입력',
|
||||
partial: '⚠️ 부분입력'
|
||||
};
|
||||
|
||||
const statusClass = {
|
||||
completed: 'completed',
|
||||
missing: 'missing',
|
||||
partial: 'partial'
|
||||
};
|
||||
|
||||
// 작업 유형 태그 생성 (실제 이름으로)
|
||||
const workTypeTags = worker.workTypes && worker.workTypes.length > 0
|
||||
? worker.workTypes.map(type => `<span class="work-type-tag">${type}</span>`).join('')
|
||||
: '<span class="work-type-tag" style="background: #f8f9fa; color: #6c757d;">없음</span>';
|
||||
|
||||
// 프로젝트 태그 생성
|
||||
const projectTags = worker.projects && worker.projects.length > 0
|
||||
? worker.projects.map(project => `<span class="project-tag">${project}</span>`).join('')
|
||||
: '<span class="project-tag" style="background: #f8f9fa; color: #6c757d;">없음</span>';
|
||||
|
||||
// 기여자 태그 생성
|
||||
const contributorTags = worker.contributors && worker.contributors.length > 0
|
||||
? worker.contributors.map(contributor => `<span class="contributor-tag">${contributor}</span>`).join('')
|
||||
: '<span class="contributor-tag" style="background: #f8f9fa; color: #6c757d;">없음</span>';
|
||||
|
||||
// 시간에 따른 스타일 클래스
|
||||
let hoursClass = 'zero';
|
||||
if (worker.totalHours > 0) {
|
||||
hoursClass = worker.totalHours >= 6 ? 'full' : 'partial';
|
||||
}
|
||||
|
||||
// 업데이트 시간 포맷팅 및 스타일
|
||||
let updateTimeText = '없음';
|
||||
let updateClass = '';
|
||||
if (worker.lastUpdate) {
|
||||
const now = new Date();
|
||||
const diff = now - worker.lastUpdate;
|
||||
const hours = diff / (1000 * 60 * 60);
|
||||
|
||||
updateTimeText = formatDateTime(worker.lastUpdate);
|
||||
updateClass = hours < 1 ? 'recent' : hours > 24 ? 'old' : '';
|
||||
}
|
||||
|
||||
row.innerHTML = `
|
||||
<td>
|
||||
<div class="worker-name-cell">
|
||||
👤 ${worker.worker_name}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="status-badge ${statusClass[worker.status]}">${statusText[worker.status]}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="hours-cell ${hoursClass}">${worker.totalHours}h</div>
|
||||
</td>
|
||||
<td>
|
||||
<strong>${worker.entryCount}</strong>개
|
||||
</td>
|
||||
<td>
|
||||
<div class="work-types-container">${workTypeTags}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="projects-container">${projectTags}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="contributors-container">${contributorTags}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="update-time ${updateClass}">${updateTimeText}</div>
|
||||
</td>
|
||||
<td>
|
||||
<button class="detail-btn" onclick="showWorkerDetailSafe('${worker.worker_id}')">
|
||||
📋 상세
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
// 날짜/시간 포맷팅
|
||||
function formatDateTime(date) {
|
||||
const d = new Date(date);
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
const hours = String(d.getHours()).padStart(2, '0');
|
||||
const minutes = String(d.getMinutes()).padStart(2, '0');
|
||||
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
// 작업자 상세 모달 표시 (안전한 버전)
|
||||
function showWorkerDetailSafe(workerId) {
|
||||
// 현재 분석된 데이터에서 해당 작업자 찾기
|
||||
const worker = filteredWorkData.find(w => w.worker_id == workerId);
|
||||
if (!worker) {
|
||||
showMessage('작업자 정보를 찾을 수 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
showWorkerDetail(worker);
|
||||
}
|
||||
|
||||
// 작업자 상세 모달 표시 (개선된 버전)
|
||||
function showWorkerDetail(worker) {
|
||||
const modal = document.getElementById('workerDetailModal');
|
||||
const modalTitle = document.getElementById('modalWorkerName');
|
||||
const modalBody = document.getElementById('modalWorkerDetails');
|
||||
|
||||
modalTitle.textContent = `👤 ${worker.worker_name} 상세 현황`;
|
||||
|
||||
let detailHtml = `
|
||||
<div style="margin-bottom: 20px;">
|
||||
<h4>📊 기본 정보</h4>
|
||||
<p><strong>작업자명:</strong> ${worker.worker_name}</p>
|
||||
<p><strong>총 작업시간:</strong> ${worker.totalHours}시간</p>
|
||||
<p><strong>작업 항목 수:</strong> ${worker.entryCount}개</p>
|
||||
<p><strong>상태:</strong> ${worker.status === 'completed' ? '✅ 완료' : worker.status === 'missing' ? '❌ 미입력' : '⚠️ 부분입력'}</p>
|
||||
<p><strong>작업 유형:</strong> ${worker.workTypes && worker.workTypes.length > 0 ? worker.workTypes.join(', ') : '없음'}</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (worker.works && worker.works.length > 0) {
|
||||
detailHtml += `
|
||||
<div style="margin-bottom: 20px;">
|
||||
<h4>🔧 작업 내역</h4>
|
||||
<div style="max-height: 400px; overflow-y: auto;">
|
||||
`;
|
||||
|
||||
worker.works.forEach((work, index) => {
|
||||
detailHtml += `
|
||||
<div style="border: 1px solid #e9ecef; padding: 15px; margin-bottom: 10px; border-radius: 8px; background: #f8f9fa; position: relative;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 10px;">
|
||||
<p><strong>작업 ${index + 1}</strong></p>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button onclick="editWorkItem('${work.id}')" style="background: #007bff; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 12px;">
|
||||
✏️ 수정
|
||||
</button>
|
||||
<button onclick="deleteWorkItem('${work.id}')" style="background: #dc3545; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 12px;">
|
||||
🗑️ 삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p><strong>프로젝트:</strong> ${work.project_name || '미지정'}</p>
|
||||
<p><strong>작업 유형:</strong> ${work.work_type_name || '미지정'}</p>
|
||||
<p><strong>작업 시간:</strong> ${work.work_hours}시간</p>
|
||||
<p><strong>상태:</strong> ${work.work_status_name || '미지정'}</p>
|
||||
${work.error_type_name ? `<p><strong>에러 유형:</strong> ${work.error_type_name}</p>` : ''}
|
||||
<p><strong>입력자:</strong> ${work.created_by_name || '미지정'}</p>
|
||||
<p><strong>입력 시간:</strong> ${formatDateTime(work.created_at)}</p>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
detailHtml += `
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
detailHtml += `
|
||||
<div style="margin-bottom: 20px;">
|
||||
<h4>📭 작업 내역</h4>
|
||||
<p style="text-align: center; color: #666; padding: 20px;">입력된 작업이 없습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (worker.contributors && worker.contributors.length > 0) {
|
||||
detailHtml += `
|
||||
<div>
|
||||
<h4>👥 기여자</h4>
|
||||
<p>${worker.contributors.join(', ')}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
modalBody.innerHTML = detailHtml;
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
// 작업 항목 수정 함수 (통합 API 사용)
|
||||
async function editWorkItem(workId) {
|
||||
try {
|
||||
console.log('수정할 작업 ID:', workId);
|
||||
|
||||
// 현재 작업 데이터에서 해당 작업 찾기
|
||||
let workData = null;
|
||||
for (const worker of filteredWorkData) {
|
||||
if (worker.works) {
|
||||
workData = worker.works.find(work => work.id == workId);
|
||||
if (workData) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!workData) {
|
||||
showMessage('수정할 작업을 찾을 수 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 필요한 마스터 데이터 로드
|
||||
await loadMasterDataForEdit();
|
||||
|
||||
// 수정 모달 표시
|
||||
showEditModal(workData);
|
||||
|
||||
} catch (error) {
|
||||
console.error('작업 정보 조회 오류:', error);
|
||||
showMessage('작업 정보를 불러올 수 없습니다: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 수정용 마스터 데이터 로드
|
||||
async function loadMasterDataForEdit() {
|
||||
try {
|
||||
if (!window.projects || window.projects.length === 0) {
|
||||
const projectData = await apiCall(`${API}/projects`);
|
||||
window.projects = Array.isArray(projectData) ? projectData : (projectData.projects || []);
|
||||
}
|
||||
|
||||
if (!window.workTypes || window.workTypes.length === 0) {
|
||||
const workTypeData = await apiCall(`${API}/daily-work-reports/work-types`);
|
||||
window.workTypes = Array.isArray(workTypeData) ? workTypeData : [];
|
||||
}
|
||||
|
||||
if (!window.workStatusTypes || window.workStatusTypes.length === 0) {
|
||||
const statusData = await apiCall(`${API}/daily-work-reports/work-status-types`);
|
||||
window.workStatusTypes = Array.isArray(statusData) ? statusData : [];
|
||||
}
|
||||
|
||||
if (!window.errorTypes || window.errorTypes.length === 0) {
|
||||
const errorData = await apiCall(`${API}/daily-work-reports/error-types`);
|
||||
window.errorTypes = Array.isArray(errorData) ? errorData : [];
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('마스터 데이터 로드 오류:', error);
|
||||
|
||||
// 기본값 설정
|
||||
window.projects = window.projects || [];
|
||||
window.workTypes = window.workTypes || [
|
||||
{id: 1, name: 'Base'},
|
||||
{id: 2, name: 'Vessel'},
|
||||
{id: 3, name: 'Piping'}
|
||||
];
|
||||
window.workStatusTypes = window.workStatusTypes || [
|
||||
{id: 1, name: '정규'},
|
||||
{id: 2, name: '에러'}
|
||||
];
|
||||
window.errorTypes = window.errorTypes || [
|
||||
{id: 1, name: '설계미스'},
|
||||
{id: 2, name: '외주작업 불량'},
|
||||
{id: 3, name: '입고지연'},
|
||||
{id: 4, name: '작업 불량'}
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 수정 모달 표시
|
||||
function showEditModal(workData) {
|
||||
// 기존 상세 모달 닫기
|
||||
closeWorkerDetailModal();
|
||||
|
||||
const modalHtml = `
|
||||
<div class="edit-modal" id="editModal">
|
||||
<div class="edit-modal-content">
|
||||
<div class="edit-modal-header">
|
||||
<h3>✏️ 작업 수정</h3>
|
||||
<button class="close-modal-btn" onclick="closeEditModal()">×</button>
|
||||
</div>
|
||||
<div class="edit-modal-body">
|
||||
<div class="edit-form-group">
|
||||
<label>🏗️ 프로젝트</label>
|
||||
<select class="edit-select" id="editProject">
|
||||
<option value="">프로젝트 선택</option>
|
||||
${(window.projects || []).map(p => `
|
||||
<option value="${p.project_id}" ${p.project_id == workData.project_id ? 'selected' : ''}>
|
||||
${p.project_name}
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="edit-form-group">
|
||||
<label>⚙️ 작업 유형</label>
|
||||
<select class="edit-select" id="editWorkType">
|
||||
<option value="">작업 유형 선택</option>
|
||||
${(window.workTypes || []).map(wt => `
|
||||
<option value="${wt.id}" ${wt.id == workData.work_type_id ? 'selected' : ''}>
|
||||
${wt.name}
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="edit-form-group">
|
||||
<label>📊 업무 상태</label>
|
||||
<select class="edit-select" id="editWorkStatus">
|
||||
<option value="">업무 상태 선택</option>
|
||||
${(window.workStatusTypes || []).map(ws => `
|
||||
<option value="${ws.id}" ${ws.id == workData.work_status_id ? 'selected' : ''}>
|
||||
${ws.name}
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="edit-form-group" id="editErrorTypeGroup" style="${workData.work_status_id == 2 ? '' : 'display: none;'}">
|
||||
<label>❌ 에러 유형</label>
|
||||
<select class="edit-select" id="editErrorType">
|
||||
<option value="">에러 유형 선택</option>
|
||||
${(window.errorTypes || []).map(et => `
|
||||
<option value="${et.id}" ${et.id == workData.error_type_id ? 'selected' : ''}>
|
||||
${et.name}
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="edit-form-group">
|
||||
<label>⏰ 작업 시간</label>
|
||||
<input type="number" class="edit-input" id="editWorkHours"
|
||||
value="${workData.work_hours}"
|
||||
min="0" max="24" step="0.5">
|
||||
</div>
|
||||
</div>
|
||||
<div class="edit-modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeEditModal()">취소</button>
|
||||
<button class="btn btn-success" onclick="saveEditedWork('${workData.id}')">💾 저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
|
||||
// 업무 상태 변경 이벤트
|
||||
document.getElementById('editWorkStatus').addEventListener('change', (e) => {
|
||||
const errorTypeGroup = document.getElementById('editErrorTypeGroup');
|
||||
if (e.target.value === '2') {
|
||||
errorTypeGroup.style.display = 'block';
|
||||
} else {
|
||||
errorTypeGroup.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 수정 모달 닫기
|
||||
function closeEditModal() {
|
||||
const modal = document.getElementById('editModal');
|
||||
if (modal) {
|
||||
modal.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// 수정된 작업 저장 (통합 API 사용)
|
||||
async function saveEditedWork(workId) {
|
||||
try {
|
||||
const projectId = document.getElementById('editProject').value;
|
||||
const workTypeId = document.getElementById('editWorkType').value;
|
||||
const workStatusId = document.getElementById('editWorkStatus').value;
|
||||
const errorTypeId = document.getElementById('editErrorType').value;
|
||||
const workHours = document.getElementById('editWorkHours').value;
|
||||
|
||||
if (!projectId || !workTypeId || !workStatusId || !workHours) {
|
||||
showMessage('모든 필수 항목을 입력해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (workStatusId === '2' && !errorTypeId) {
|
||||
showMessage('에러 상태인 경우 에러 유형을 선택해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const updateData = {
|
||||
project_id: parseInt(projectId),
|
||||
work_type_id: parseInt(workTypeId),
|
||||
work_status_id: parseInt(workStatusId),
|
||||
error_type_id: errorTypeId ? parseInt(errorTypeId) : null,
|
||||
work_hours: parseFloat(workHours)
|
||||
};
|
||||
|
||||
showMessage('작업을 수정하는 중... (통합 API)', 'loading');
|
||||
|
||||
const result = await apiCall(`${API}/daily-work-reports/${workId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updateData)
|
||||
});
|
||||
|
||||
console.log('✅ 수정 성공 (통합 API):', result);
|
||||
showMessage('✅ 작업이 성공적으로 수정되었습니다!', 'success');
|
||||
|
||||
closeEditModal();
|
||||
closeWorkerDetailModal();
|
||||
|
||||
// 데이터 새로고침
|
||||
await loadDashboardData();
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 수정 실패:', error);
|
||||
showMessage('수정 중 오류가 발생했습니다: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 작업 항목 삭제 함수 (통합 API 사용)
|
||||
async function deleteWorkItem(workId) {
|
||||
if (!confirm('정말로 이 작업을 삭제하시겠습니까?\n삭제된 작업은 복구할 수 없습니다.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('삭제할 작업 ID:', workId);
|
||||
|
||||
showMessage('작업을 삭제하는 중... (통합 API)', 'loading');
|
||||
|
||||
// 개별 항목 삭제 API 호출 - 통합 API 사용
|
||||
const result = await apiCall(`${API}/daily-work-reports/${workId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
console.log('✅ 삭제 성공 (통합 API):', result);
|
||||
showMessage('✅ 작업이 성공적으로 삭제되었습니다!', 'success');
|
||||
|
||||
closeWorkerDetailModal();
|
||||
|
||||
// 데이터 새로고침
|
||||
await loadDashboardData();
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 삭제 실패:', error);
|
||||
showMessage('삭제 중 오류가 발생했습니다: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 작업자 상세 모달 닫기
|
||||
function closeWorkerDetailModal() {
|
||||
document.getElementById('workerDetailModal').style.display = 'none';
|
||||
}
|
||||
|
||||
// 필터링 설정
|
||||
function setupFiltering() {
|
||||
const showOnlyMissingCheckbox = document.getElementById('showOnlyMissing');
|
||||
|
||||
showOnlyMissingCheckbox.addEventListener('change', (e) => {
|
||||
if (e.target.checked) {
|
||||
// 미입력자만 필터링
|
||||
const missingWorkers = filteredWorkData.filter(worker => worker.status === 'missing');
|
||||
displayWorkers(missingWorkers);
|
||||
} else {
|
||||
// 전체 표시
|
||||
displayWorkers(filteredWorkData);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 엑셀 다운로드 (개선된 버전)
|
||||
function exportToExcel() {
|
||||
try {
|
||||
// CSV 형태로 데이터 구성 (개선된 버전)
|
||||
let csvContent = "작업자명,상태,총시간,작업항목수,작업유형,프로젝트,기여자,최근업데이트\n";
|
||||
|
||||
filteredWorkData.forEach(worker => {
|
||||
const statusText = {
|
||||
completed: '완료',
|
||||
missing: '미입력',
|
||||
partial: '부분입력'
|
||||
};
|
||||
|
||||
const workTypes = worker.workTypes && worker.workTypes.length > 0 ? worker.workTypes.join('; ') : '없음';
|
||||
const projects = worker.projects && worker.projects.length > 0 ? worker.projects.join('; ') : '없음';
|
||||
const contributors = worker.contributors && worker.contributors.length > 0 ? worker.contributors.join('; ') : '없음';
|
||||
const lastUpdate = worker.lastUpdate ? formatDateTime(worker.lastUpdate) : '없음';
|
||||
|
||||
csvContent += `"${worker.worker_name}","${statusText[worker.status]}","${worker.totalHours}","${worker.entryCount}","${workTypes}","${projects}","${contributors}","${lastUpdate}"\n`;
|
||||
});
|
||||
|
||||
// UTF-8 BOM 추가 (한글 깨짐 방지)
|
||||
const BOM = '\uFEFF';
|
||||
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', `작업현황_${currentDate}.csv`);
|
||||
link.style.visibility = 'hidden';
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
showMessage('✅ 엑셀 파일이 다운로드되었습니다!', 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('엑셀 다운로드 오류:', error);
|
||||
showMessage('엑셀 다운로드 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 새로고침
|
||||
function refreshData() {
|
||||
loadDashboardData();
|
||||
}
|
||||
|
||||
// 이벤트 리스너 설정
|
||||
function setupEventListeners() {
|
||||
document.getElementById('loadDataBtn').addEventListener('click', loadDashboardData);
|
||||
document.getElementById('refreshBtn').addEventListener('click', refreshData);
|
||||
document.getElementById('exportBtn').addEventListener('click', exportToExcel);
|
||||
|
||||
// 엔터키로 조회
|
||||
document.getElementById('selectedDate').addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
loadDashboardData();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 초기화
|
||||
async function init() {
|
||||
try {
|
||||
// 권한 체크
|
||||
if (!checkPermission()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 권한 체크 메시지 숨기기
|
||||
document.getElementById('permission-check-message').style.display = 'none';
|
||||
|
||||
// 오늘 날짜 설정
|
||||
document.getElementById('selectedDate').value = getKoreaToday();
|
||||
|
||||
// 이벤트 리스너 설정
|
||||
setupEventListeners();
|
||||
|
||||
console.log('✅ 관리자 대시보드 초기화 완료 (통합 API 설정 적용)');
|
||||
|
||||
// 자동으로 오늘 데이터 로드
|
||||
loadDashboardData();
|
||||
|
||||
} catch (error) {
|
||||
console.error('초기화 오류:', error);
|
||||
showMessage('초기화 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// 권한 체크 메시지 표시
|
||||
document.getElementById('permission-check-message').style.display = 'block';
|
||||
|
||||
// 토큰 확인
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token || token === 'undefined') {
|
||||
showMessage('로그인이 필요합니다.', 'error');
|
||||
localStorage.removeItem('token');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
// 초기화 실행
|
||||
init();
|
||||
});
|
||||
|
||||
// 전역 함수로 노출
|
||||
window.closeWorkerDetailModal = closeWorkerDetailModal;
|
||||
window.refreshData = refreshData;
|
||||
window.showWorkerDetailSafe = showWorkerDetailSafe;
|
||||
window.showWorkerDetail = showWorkerDetail;
|
||||
window.editWorkItem = editWorkItem;
|
||||
window.deleteWorkItem = deleteWorkItem;
|
||||
window.closeEditModal = closeEditModal;
|
||||
window.saveEditedWork = saveEditedWork;
|
||||
1293
synology_deployment/web-ui/js/modern-dashboard.js
Normal file
1293
synology_deployment/web-ui/js/modern-dashboard.js
Normal file
File diff suppressed because it is too large
Load Diff
122
synology_deployment/web-ui/js/my-profile.js
Normal file
122
synology_deployment/web-ui/js/my-profile.js
Normal file
@@ -0,0 +1,122 @@
|
||||
// js/my-profile.js
|
||||
// 내 프로필 페이지 JavaScript
|
||||
|
||||
import { API, getAuthHeaders, ensureAuthenticated } from '/js/api-config.js';
|
||||
|
||||
// 인증 확인
|
||||
const token = ensureAuthenticated();
|
||||
|
||||
// 권한 레벨 한글 매핑
|
||||
const accessLevelMap = {
|
||||
worker: '작업자',
|
||||
group_leader: '그룹장',
|
||||
support_team: '지원팀',
|
||||
admin: '관리자',
|
||||
system: '시스템 관리자'
|
||||
};
|
||||
|
||||
// 프로필 데이터 로드
|
||||
async function loadProfile() {
|
||||
try {
|
||||
// 먼저 로컬 스토리지에서 기본 정보 표시
|
||||
const storedUser = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
if (storedUser) {
|
||||
updateProfileUI(storedUser);
|
||||
}
|
||||
|
||||
// API에서 최신 정보 가져오기
|
||||
const res = await fetch(`${API}/auth/me`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error! status: ${res.status}`);
|
||||
}
|
||||
|
||||
const userData = await res.json();
|
||||
|
||||
// 로컬 스토리지 업데이트
|
||||
const updatedUser = {
|
||||
...storedUser,
|
||||
...userData
|
||||
};
|
||||
localStorage.setItem('user', JSON.stringify(updatedUser));
|
||||
|
||||
// UI 업데이트
|
||||
updateProfileUI(userData);
|
||||
|
||||
} catch (error) {
|
||||
console.error('프로필 로딩 실패:', error);
|
||||
showError('프로필 정보를 불러오는데 실패했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
// 프로필 UI 업데이트
|
||||
function updateProfileUI(user) {
|
||||
// 헤더 정보
|
||||
const avatar = document.getElementById('profileAvatar');
|
||||
if (avatar && user.name) {
|
||||
// 이름의 첫 글자를 아바타로 사용
|
||||
const initial = user.name.charAt(0).toUpperCase();
|
||||
if (initial.match(/[A-Z가-힣]/)) {
|
||||
avatar.textContent = initial;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('profileName').textContent = user.name || user.username || '사용자';
|
||||
document.getElementById('profileRole').textContent = accessLevelMap[user.access_level] || user.access_level || '역할 미지정';
|
||||
|
||||
// 기본 정보
|
||||
document.getElementById('userId').textContent = user.user_id || '-';
|
||||
document.getElementById('username').textContent = user.username || '-';
|
||||
document.getElementById('fullName').textContent = user.name || '-';
|
||||
document.getElementById('accessLevel').textContent = accessLevelMap[user.access_level] || user.access_level || '-';
|
||||
document.getElementById('workerId').textContent = user.worker_id || '연결되지 않음';
|
||||
|
||||
// 날짜 포맷팅
|
||||
if (user.created_at) {
|
||||
const createdDate = new Date(user.created_at);
|
||||
document.getElementById('createdAt').textContent = formatDate(createdDate);
|
||||
}
|
||||
|
||||
if (user.last_login_at) {
|
||||
const lastLoginDate = new Date(user.last_login_at);
|
||||
document.getElementById('lastLogin').textContent = formatDateTime(lastLoginDate);
|
||||
} else {
|
||||
document.getElementById('lastLogin').textContent = '첫 로그인';
|
||||
}
|
||||
|
||||
// 이메일
|
||||
document.getElementById('email').textContent = user.email || '등록되지 않음';
|
||||
}
|
||||
|
||||
// 날짜 포맷팅 함수
|
||||
function formatDate(date) {
|
||||
return date.toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
function formatDateTime(date) {
|
||||
return date.toLocaleString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
// 에러 표시
|
||||
function showError(message) {
|
||||
// 간단한 알림으로 처리
|
||||
alert('❌ ' + message);
|
||||
}
|
||||
|
||||
// 페이지 로드 시 실행
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
console.log('👤 프로필 페이지 로드됨');
|
||||
loadProfile();
|
||||
});
|
||||
37
synology_deployment/web-ui/js/project-analysis-api.js
Normal file
37
synology_deployment/web-ui/js/project-analysis-api.js
Normal file
@@ -0,0 +1,37 @@
|
||||
// /js/project-analysis-api.js
|
||||
import { apiGet } from './api-helper.js';
|
||||
|
||||
/**
|
||||
* 분석 페이지에 필요한 모든 초기 데이터(마스터 데이터)를 병렬로 가져옵니다.
|
||||
* 이 데이터는 필터 옵션을 채우는 데 사용됩니다.
|
||||
* @returns {Promise<{workers: Array, projects: Array, tasks: Array}>}
|
||||
*/
|
||||
export async function getMasterData() {
|
||||
try {
|
||||
const [workers, projects, tasks] = await Promise.all([
|
||||
apiGet('/workers'),
|
||||
apiGet('/projects'),
|
||||
apiGet('/tasks')
|
||||
]);
|
||||
return { workers, projects, tasks };
|
||||
} catch (error) {
|
||||
console.error('마스터 데이터 로딩 실패:', error);
|
||||
throw new Error('필터링에 필요한 데이터를 불러오는 데 실패했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정된 기간의 모든 분석 데이터를 백엔드에서 직접 가져옵니다.
|
||||
* @param {string} startDate - 시작일 (YYYY-MM-DD)
|
||||
* @param {string} endDate - 종료일 (YYYY-MM-DD)
|
||||
* @returns {Promise<object>} - 요약, 집계, 상세 데이터가 모두 포함된 분석 결과 객체
|
||||
*/
|
||||
export async function getAnalysisReport(startDate, endDate) {
|
||||
try {
|
||||
const analysisData = await apiGet(`/analysis?startDate=${startDate}&endDate=${endDate}`);
|
||||
return analysisData;
|
||||
} catch (error) {
|
||||
console.error('분석 보고서 데이터 로딩 실패:', error);
|
||||
throw new Error(`분석 데이터를 불러오는 데 실패했습니다: ${error.message}`);
|
||||
}
|
||||
}
|
||||
170
synology_deployment/web-ui/js/project-analysis-ui.js
Normal file
170
synology_deployment/web-ui/js/project-analysis-ui.js
Normal file
@@ -0,0 +1,170 @@
|
||||
// /js/project-analysis-ui.js
|
||||
|
||||
const DOM = {
|
||||
// 기간 설정
|
||||
startDate: document.getElementById('startDate'),
|
||||
endDate: document.getElementById('endDate'),
|
||||
// 카드 및 필터
|
||||
analysisCard: document.getElementById('analysisCard'),
|
||||
summaryCards: document.getElementById('summaryCards'),
|
||||
projectFilter: document.getElementById('projectFilter'),
|
||||
workerFilter: document.getElementById('workerFilter'),
|
||||
taskFilter: document.getElementById('taskFilter'),
|
||||
// 탭
|
||||
tabButtons: document.querySelectorAll('.tab-button'),
|
||||
tabContents: document.querySelectorAll('.analysis-content'),
|
||||
// 테이블 본문
|
||||
projectTableBody: document.getElementById('projectTableBody'),
|
||||
workerTableBody: document.getElementById('workerTableBody'),
|
||||
taskTableBody: document.getElementById('taskTableBody'),
|
||||
detailTableBody: document.getElementById('detailTableBody'),
|
||||
};
|
||||
|
||||
/**
|
||||
* 날짜 input 값을 YYYY-MM-DD 형식의 문자열로 반환
|
||||
* @param {Date} date - 날짜 객체
|
||||
* @returns {string} - 포맷된 날짜 문자열
|
||||
*/
|
||||
const formatDate = (date) => date.toISOString().split('T')[0];
|
||||
|
||||
/**
|
||||
* UI상의 날짜 선택기를 기본값(이번 달)으로 설정합니다.
|
||||
*/
|
||||
export function setDefaultDates() {
|
||||
const now = new Date();
|
||||
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
DOM.startDate.value = formatDate(firstDay);
|
||||
DOM.endDate.value = formatDate(lastDay);
|
||||
}
|
||||
|
||||
/**
|
||||
* 분석 실행 전후의 UI 상태를 관리합니다 (로딩 표시 등)
|
||||
* @param {'loading' | 'data' | 'no-data' | 'error'} state - UI 상태
|
||||
*/
|
||||
export function setUIState(state) {
|
||||
const projectCols = 5;
|
||||
const detailCols = 8;
|
||||
const messages = {
|
||||
loading: '📊 데이터 분석 중...',
|
||||
'no-data': '해당 기간에 분석할 데이터가 없습니다.',
|
||||
error: '오류가 발생했습니다. 다시 시도해주세요.',
|
||||
};
|
||||
|
||||
if (state === 'data') {
|
||||
DOM.analysisCard.style.display = 'block';
|
||||
} else {
|
||||
const message = messages[state];
|
||||
const html = `<tr><td colspan="${projectCols}" class="${state}">${message}</td></tr>`;
|
||||
const detailHtml = `<tr><td colspan="${detailCols}" class="${state}">${message}</td></tr>`;
|
||||
DOM.projectTableBody.innerHTML = html;
|
||||
DOM.workerTableBody.innerHTML = html;
|
||||
DOM.taskTableBody.innerHTML = html;
|
||||
DOM.detailTableBody.innerHTML = detailHtml;
|
||||
DOM.summaryCards.innerHTML = '';
|
||||
DOM.analysisCard.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 마스터 데이터를 기반으로 필터 옵션을 채웁니다.
|
||||
* @param {{workers: Array, projects: Array, tasks: Array}} masterData - 마스터 데이터
|
||||
*/
|
||||
export function updateFilterOptions(masterData) {
|
||||
const createOptions = (items, key, value) => {
|
||||
let html = '<option value="">전체</option>';
|
||||
items.forEach(item => {
|
||||
html += `<option value="${item[key]}">${item[value]}</option>`;
|
||||
});
|
||||
return html;
|
||||
};
|
||||
DOM.projectFilter.innerHTML = createOptions(masterData.projects, 'project_id', 'project_name');
|
||||
DOM.workerFilter.innerHTML = createOptions(masterData.workers, 'worker_id', 'worker_name');
|
||||
DOM.taskFilter.innerHTML = createOptions(masterData.tasks, 'task_id', 'category');
|
||||
}
|
||||
|
||||
/**
|
||||
* 요약 카드 데이터를 렌더링합니다.
|
||||
* @param {object} summary - 요약 데이터
|
||||
*/
|
||||
export function renderSummary(summary) {
|
||||
DOM.summaryCards.innerHTML = `
|
||||
<div class="summary-card"><h4>총 투입 시간</h4><div class="value">${(summary.totalHours || 0).toFixed(1)}h</div></div>
|
||||
<div class="summary-card"><h4>참여 프로젝트</h4><div class="value">${summary.totalProjects || 0}개</div></div>
|
||||
<div class="summary-card"><h4>참여 인원</h4><div class="value">${summary.totalWorkers || 0}명</div></div>
|
||||
<div class="summary-card"><h4>작업 분류</h4><div class="value">${summary.totalTasks || 0}개</div></div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 집계된 데이터를 받아 테이블을 렌더링하는 범용 함수
|
||||
* @param {HTMLElement} tableBodyEl - 렌더링할 테이블의 tbody 요소
|
||||
* @param {Array} data - 집계된 데이터 배열
|
||||
* @param {function} rowRenderer - 각 행을 렌더링하는 함수
|
||||
*/
|
||||
function renderTable(tableBodyEl, data, rowRenderer) {
|
||||
if (!data || data.length === 0) {
|
||||
tableBodyEl.innerHTML = '<tr><td colspan="5" class="no-data">데이터가 없습니다</td></tr>';
|
||||
return;
|
||||
}
|
||||
tableBodyEl.innerHTML = data.map(rowRenderer).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 집계된 데이터를 기반으로 모든 분석 테이블을 렌더링합니다.
|
||||
* @param {object} analysis - 프로젝트/작업자/작업별 집계 데이터
|
||||
*/
|
||||
export function renderAnalysisTables(analysis) {
|
||||
renderTable(DOM.projectTableBody, analysis.byProject, (p, i) => `
|
||||
<tr><td>${i + 1}</td><td class="project-col" title="${p.name}">${p.name}</td><td class="hours-col">${p.hours}h</td>
|
||||
<td>${p.percentage}%</td><td>${p.participants}명</td></tr>`);
|
||||
|
||||
renderTable(DOM.workerTableBody, analysis.byWorker, (w, i) => `
|
||||
<tr><td>${i + 1}</td><td class="worker-col">${w.name}</td><td class="hours-col">${w.hours}h</td>
|
||||
<td>${w.percentage}%</td><td>${w.participants}개</td></tr>`);
|
||||
|
||||
renderTable(DOM.taskTableBody, analysis.byTask, (t, i) => `
|
||||
<tr><td>${i + 1}</td><td class="task-col" title="${t.name}">${t.name}</td><td class="hours-col">${t.hours}h</td>
|
||||
<td>${t.percentage}%</td><td>${t.participants}명</td></tr>`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상세 내역 테이블을 렌더링합니다.
|
||||
* @param {Array} detailData - 필터링된 상세 데이터
|
||||
*/
|
||||
export function renderDetailTable(detailData) {
|
||||
if (!detailData || detailData.length === 0) {
|
||||
DOM.detailTableBody.innerHTML = '<tr><td colspan="8" class="no-data">데이터가 없습니다</td></tr>';
|
||||
return;
|
||||
}
|
||||
DOM.detailTableBody.innerHTML = detailData.map((item, index) => `
|
||||
<tr><td>${index + 1}</td><td>${formatDate(new Date(item.date))}</td>
|
||||
<td class="project-col" title="${item.project_name}">${item.project_name}</td>
|
||||
<td class="worker-col">${item.worker_name}</td><td class="task-col" title="${item.task_category}">${item.task_category}</td>
|
||||
<td>${item.work_details || '정상근무'}</td>
|
||||
<td class="hours-col">${item.work_hours}h</td>
|
||||
<td title="${item.memo || '-'}">${(item.memo || '-').substring(0, 20)}</td></tr>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 탭 UI를 제어합니다.
|
||||
* @param {string} tabName - 활성화할 탭의 이름
|
||||
*/
|
||||
export function switchTab(tabName) {
|
||||
DOM.tabButtons.forEach(btn => btn.classList.toggle('active', btn.dataset.tab === tabName));
|
||||
DOM.tabContents.forEach(content => content.classList.toggle('active', content.id === `${tabName}Tab`));
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자로부터 현재 필터 값을 가져옵니다.
|
||||
* @returns {{project: string, worker: string, task: string}}
|
||||
*/
|
||||
export function getCurrentFilters() {
|
||||
return {
|
||||
project: DOM.projectFilter.value,
|
||||
worker: DOM.workerFilter.value,
|
||||
task: DOM.taskFilter.value,
|
||||
};
|
||||
}
|
||||
106
synology_deployment/web-ui/js/project-analysis.js
Normal file
106
synology_deployment/web-ui/js/project-analysis.js
Normal file
@@ -0,0 +1,106 @@
|
||||
// /js/project-analysis.js
|
||||
import { getMasterData, getAnalysisReport } from './project-analysis-api.js';
|
||||
import {
|
||||
setDefaultDates,
|
||||
setUIState,
|
||||
updateFilterOptions,
|
||||
renderSummary,
|
||||
renderAnalysisTables,
|
||||
renderDetailTable,
|
||||
switchTab,
|
||||
} from './project-analysis-ui.js';
|
||||
|
||||
// DOM 요소 참조 (이벤트 리스너 설정용)
|
||||
const DOM = {
|
||||
startDate: document.getElementById('startDate'),
|
||||
endDate: document.getElementById('endDate'),
|
||||
analyzeBtn: document.getElementById('analyzeBtn'),
|
||||
quickMonthBtn: document.getElementById('quickMonth'),
|
||||
quickLastMonthBtn: document.getElementById('quickLastMonth'),
|
||||
// 필터 버튼은 현재 아무 기능도 하지 않으므로 주석 처리 또는 제거 가능
|
||||
// applyFilterBtn: document.getElementById('applyFilter'),
|
||||
tabButtons: document.querySelectorAll('.tab-button'),
|
||||
};
|
||||
|
||||
/**
|
||||
* 분석 실행 버튼 클릭 이벤트 핸들러
|
||||
*/
|
||||
async function handleAnalysis() {
|
||||
const startDate = DOM.startDate.value;
|
||||
const endDate = DOM.endDate.value;
|
||||
|
||||
if (!startDate || !endDate || startDate > endDate) {
|
||||
alert('올바른 분석 기간을 설정해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setUIState('loading');
|
||||
try {
|
||||
const analysisResult = await getAnalysisReport(startDate, endDate);
|
||||
|
||||
if (!analysisResult.summary.totalHours) {
|
||||
setUIState('no-data');
|
||||
return;
|
||||
}
|
||||
|
||||
renderSummary(analysisResult.summary);
|
||||
renderAnalysisTables(analysisResult);
|
||||
renderDetailTable(analysisResult.details);
|
||||
setUIState('data');
|
||||
|
||||
} catch (error) {
|
||||
console.error('분석 처리 중 오류:', error);
|
||||
setUIState('error');
|
||||
alert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 빠른 날짜 설정 버튼 핸들러
|
||||
*/
|
||||
function handleQuickDate(monthType) {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth();
|
||||
const firstDay = monthType === 'this' ? new Date(year, month, 1) : new Date(year, month - 1, 1);
|
||||
const lastDay = monthType === 'this' ? new Date(year, month + 1, 0) : new Date(year, month, 0);
|
||||
|
||||
DOM.startDate.value = firstDay.toISOString().split('T')[0];
|
||||
DOM.endDate.value = lastDay.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 리스너 설정
|
||||
*/
|
||||
function setupEventListeners() {
|
||||
DOM.analyzeBtn.addEventListener('click', handleAnalysis);
|
||||
DOM.quickMonthBtn.addEventListener('click', () => handleQuickDate('this'));
|
||||
DOM.quickLastMonthBtn.addEventListener('click', () => handleQuickDate('last'));
|
||||
|
||||
DOM.tabButtons.forEach(btn => {
|
||||
btn.addEventListener('click', () => switchTab(btn.dataset.tab));
|
||||
});
|
||||
|
||||
// 프론트엔드 필터링은 제거되었으므로 관련 이벤트 리스너는 주석 처리합니다.
|
||||
// DOM.applyFilterBtn.addEventListener('click', ...);
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 초기화 함수
|
||||
*/
|
||||
async function initialize() {
|
||||
setDefaultDates();
|
||||
setupEventListeners();
|
||||
|
||||
try {
|
||||
const masterData = await getMasterData();
|
||||
updateFilterOptions(masterData);
|
||||
await handleAnalysis();
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
setUIState('error');
|
||||
}
|
||||
}
|
||||
|
||||
// 초기화 실행
|
||||
document.addEventListener('DOMContentLoaded', initialize);
|
||||
634
synology_deployment/web-ui/js/project-management.js
Normal file
634
synology_deployment/web-ui/js/project-management.js
Normal file
@@ -0,0 +1,634 @@
|
||||
// 프로젝트 관리 페이지 JavaScript
|
||||
|
||||
// 전역 변수
|
||||
let allProjects = [];
|
||||
let filteredProjects = [];
|
||||
let currentEditingProject = null;
|
||||
let currentStatusFilter = 'all'; // 'all', 'active', 'inactive'
|
||||
|
||||
// 페이지 초기화
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('📁 프로젝트 관리 페이지 초기화 시작');
|
||||
|
||||
initializePage();
|
||||
loadProjects();
|
||||
});
|
||||
|
||||
// 페이지 초기화
|
||||
function initializePage() {
|
||||
// 시간 업데이트 시작
|
||||
updateCurrentTime();
|
||||
setInterval(updateCurrentTime, 1000);
|
||||
|
||||
// 사용자 정보 업데이트
|
||||
updateUserInfo();
|
||||
|
||||
// 프로필 메뉴 토글
|
||||
setupProfileMenu();
|
||||
|
||||
// 로그아웃 버튼
|
||||
setupLogoutButton();
|
||||
|
||||
// 검색 입력 이벤트
|
||||
setupSearchInput();
|
||||
}
|
||||
|
||||
// 현재 시간 업데이트
|
||||
function updateCurrentTime() {
|
||||
const now = new Date();
|
||||
const timeString = now.toLocaleTimeString('ko-KR', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
|
||||
const timeElement = document.getElementById('timeValue');
|
||||
if (timeElement) {
|
||||
timeElement.textContent = timeString;
|
||||
}
|
||||
}
|
||||
|
||||
// 사용자 정보 업데이트
|
||||
function updateUserInfo() {
|
||||
let userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}');
|
||||
let authUser = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
|
||||
const finalUserInfo = {
|
||||
worker_name: userInfo.worker_name || authUser.username || authUser.worker_name,
|
||||
job_type: userInfo.job_type || authUser.role || authUser.job_type,
|
||||
username: authUser.username || userInfo.username
|
||||
};
|
||||
|
||||
const userNameElement = document.getElementById('userName');
|
||||
const userRoleElement = document.getElementById('userRole');
|
||||
const userInitialElement = document.getElementById('userInitial');
|
||||
|
||||
if (userNameElement) {
|
||||
userNameElement.textContent = finalUserInfo.worker_name || '사용자';
|
||||
}
|
||||
|
||||
if (userRoleElement) {
|
||||
const roleMap = {
|
||||
'leader': '그룹장',
|
||||
'worker': '작업자',
|
||||
'admin': '관리자',
|
||||
'system': '시스템 관리자'
|
||||
};
|
||||
userRoleElement.textContent = roleMap[finalUserInfo.job_type] || finalUserInfo.job_type || '작업자';
|
||||
}
|
||||
|
||||
if (userInitialElement) {
|
||||
const name = finalUserInfo.worker_name || '사용자';
|
||||
userInitialElement.textContent = name.charAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
// 프로필 메뉴 설정
|
||||
function setupProfileMenu() {
|
||||
const userProfile = document.getElementById('userProfile');
|
||||
const profileMenu = document.getElementById('profileMenu');
|
||||
|
||||
if (userProfile && profileMenu) {
|
||||
userProfile.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
const isVisible = profileMenu.style.display === 'block';
|
||||
profileMenu.style.display = isVisible ? 'none' : 'block';
|
||||
});
|
||||
|
||||
// 외부 클릭 시 메뉴 닫기
|
||||
document.addEventListener('click', function() {
|
||||
profileMenu.style.display = 'none';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 로그아웃 버튼 설정
|
||||
function setupLogoutButton() {
|
||||
const logoutBtn = document.getElementById('logoutBtn');
|
||||
if (logoutBtn) {
|
||||
logoutBtn.addEventListener('click', function() {
|
||||
if (confirm('로그아웃 하시겠습니까?')) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('userInfo');
|
||||
window.location.href = '/index.html';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 검색 입력 설정
|
||||
function setupSearchInput() {
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', function() {
|
||||
searchProjects();
|
||||
});
|
||||
|
||||
searchInput.addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
searchProjects();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 프로젝트 목록 로드
|
||||
async function loadProjects() {
|
||||
try {
|
||||
console.log('📊 프로젝트 목록 로딩 시작');
|
||||
|
||||
const response = await apiCall('/projects', 'GET');
|
||||
|
||||
console.log('📊 API 응답 구조:', response);
|
||||
|
||||
// API 응답이 { success: true, data: [...] } 형태인 경우 처리
|
||||
let projectData = [];
|
||||
if (response && response.success && Array.isArray(response.data)) {
|
||||
projectData = response.data;
|
||||
} else if (Array.isArray(response)) {
|
||||
projectData = response;
|
||||
} else {
|
||||
console.warn('프로젝트 데이터가 배열이 아닙니다:', response);
|
||||
projectData = [];
|
||||
}
|
||||
|
||||
allProjects = projectData;
|
||||
|
||||
console.log(`✅ 프로젝트 ${allProjects.length}개 로드 완료`);
|
||||
|
||||
// 초기 필터 적용
|
||||
applyAllFilters();
|
||||
updateStatCardActiveState();
|
||||
|
||||
} catch (error) {
|
||||
console.error('프로젝트 로딩 오류:', error);
|
||||
showToast('프로젝트 목록을 불러오는데 실패했습니다.', 'error');
|
||||
allProjects = [];
|
||||
filteredProjects = [];
|
||||
renderProjects();
|
||||
}
|
||||
}
|
||||
|
||||
// 프로젝트 목록 렌더링
|
||||
function renderProjects() {
|
||||
const projectsGrid = document.getElementById('projectsGrid');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
|
||||
if (!projectsGrid || !emptyState) return;
|
||||
|
||||
if (filteredProjects.length === 0) {
|
||||
projectsGrid.style.display = 'none';
|
||||
emptyState.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
projectsGrid.style.display = 'grid';
|
||||
emptyState.style.display = 'none';
|
||||
|
||||
const projectsHtml = filteredProjects.map(project => {
|
||||
// 프로젝트 상태 아이콘 및 텍스트
|
||||
const statusMap = {
|
||||
'planning': { icon: '📋', text: '계획', color: '#6b7280' },
|
||||
'active': { icon: '🚀', text: '진행중', color: '#10b981' },
|
||||
'completed': { icon: '✅', text: '완료', color: '#3b82f6' },
|
||||
'cancelled': { icon: '❌', text: '취소', color: '#ef4444' }
|
||||
};
|
||||
|
||||
const status = statusMap[project.project_status] || statusMap['active'];
|
||||
// is_active 값 처리 (DB에서 0/1로 오는 경우 대비)
|
||||
const isInactive = project.is_active === 0 || project.is_active === false || project.is_active === 'false';
|
||||
|
||||
console.log('🎨 카드 렌더링:', {
|
||||
project_id: project.project_id,
|
||||
project_name: project.project_name,
|
||||
is_active_raw: project.is_active,
|
||||
isInactive: isInactive
|
||||
});
|
||||
|
||||
return `
|
||||
<div class="project-card ${isInactive ? 'inactive' : ''}" onclick="editProject(${project.project_id})">
|
||||
${isInactive ? '<div class="inactive-overlay"><span class="inactive-badge">🚫 비활성화됨</span></div>' : ''}
|
||||
<div class="project-header">
|
||||
<div class="project-info">
|
||||
<div class="project-job-no">${project.job_no || 'Job No. 없음'}</div>
|
||||
<h3 class="project-name">
|
||||
${project.project_name}
|
||||
${isInactive ? '<span class="inactive-label">(비활성)</span>' : ''}
|
||||
</h3>
|
||||
<div class="project-meta">
|
||||
<span style="color: ${status.color}; font-weight: 500;">${status.icon} ${status.text}</span>
|
||||
${project.contract_date ? `<span>📅 계약일: ${formatDate(project.contract_date)}</span>` : ''}
|
||||
${project.due_date ? `<span>⏰ 납기일: ${formatDate(project.due_date)}</span>` : ''}
|
||||
${project.completed_date ? `<span>🎯 완료일: ${formatDate(project.completed_date)}</span>` : ''}
|
||||
${project.pm ? `<span>👤 PM: ${project.pm}</span>` : ''}
|
||||
${project.site ? `<span>📍 현장: ${project.site}</span>` : ''}
|
||||
${isInactive ? '<span class="inactive-notice">⚠️ 작업보고서에서 숨김</span>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="project-actions">
|
||||
<button class="btn-edit" onclick="event.stopPropagation(); editProject(${project.project_id})" title="수정">
|
||||
✏️
|
||||
</button>
|
||||
<button class="btn-delete" onclick="event.stopPropagation(); confirmDeleteProject(${project.project_id})" title="삭제">
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
projectsGrid.innerHTML = projectsHtml;
|
||||
}
|
||||
|
||||
// 프로젝트 통계 업데이트
|
||||
function updateProjectStats() {
|
||||
const activeProjects = filteredProjects.filter(p => p.is_active === 1 || p.is_active === true);
|
||||
const inactiveProjects = filteredProjects.filter(p => p.is_active === 0 || p.is_active === false);
|
||||
|
||||
const activeProjectsElement = document.getElementById('activeProjects');
|
||||
const inactiveProjectsElement = document.getElementById('inactiveProjects');
|
||||
const totalProjectsElement = document.getElementById('totalProjects');
|
||||
|
||||
if (activeProjectsElement) {
|
||||
activeProjectsElement.textContent = activeProjects.length;
|
||||
}
|
||||
|
||||
if (inactiveProjectsElement) {
|
||||
inactiveProjectsElement.textContent = inactiveProjects.length;
|
||||
}
|
||||
|
||||
if (totalProjectsElement) {
|
||||
totalProjectsElement.textContent = filteredProjects.length;
|
||||
}
|
||||
|
||||
console.log('📊 프로젝트 통계:', {
|
||||
전체: filteredProjects.length,
|
||||
활성: activeProjects.length,
|
||||
비활성: inactiveProjects.length
|
||||
});
|
||||
}
|
||||
|
||||
// 날짜 포맷팅
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return '';
|
||||
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
// 상태별 필터링
|
||||
function filterByStatus(status) {
|
||||
currentStatusFilter = status;
|
||||
|
||||
// 통계 카드 활성화 상태 업데이트
|
||||
updateStatCardActiveState();
|
||||
|
||||
// 필터링 적용
|
||||
applyAllFilters();
|
||||
|
||||
console.log(`🔍 상태 필터 적용: ${status}`);
|
||||
}
|
||||
|
||||
// 통계 카드 활성화 상태 업데이트
|
||||
function updateStatCardActiveState() {
|
||||
// 모든 통계 카드에서 active 클래스 제거
|
||||
document.querySelectorAll('.stat-item').forEach(item => {
|
||||
item.classList.remove('active');
|
||||
});
|
||||
|
||||
// 현재 선택된 필터에 active 클래스 추가
|
||||
const activeCard = document.querySelector(`.${currentStatusFilter === 'active' ? 'active-stat' :
|
||||
currentStatusFilter === 'inactive' ? 'inactive-stat' : 'total-stat'}`);
|
||||
if (activeCard) {
|
||||
activeCard.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
// 모든 필터 적용 (검색 + 상태)
|
||||
function applyAllFilters() {
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
const searchTerm = searchInput ? searchInput.value.toLowerCase().trim() : '';
|
||||
|
||||
// 1단계: 상태 필터링
|
||||
let statusFiltered = [...allProjects];
|
||||
if (currentStatusFilter === 'active') {
|
||||
statusFiltered = allProjects.filter(p => p.is_active === 1 || p.is_active === true);
|
||||
} else if (currentStatusFilter === 'inactive') {
|
||||
statusFiltered = allProjects.filter(p => p.is_active === 0 || p.is_active === false);
|
||||
}
|
||||
|
||||
// 2단계: 검색 필터링
|
||||
if (!searchTerm) {
|
||||
filteredProjects = statusFiltered;
|
||||
} else {
|
||||
filteredProjects = statusFiltered.filter(project =>
|
||||
project.project_name.toLowerCase().includes(searchTerm) ||
|
||||
(project.job_no && project.job_no.toLowerCase().includes(searchTerm)) ||
|
||||
(project.pm && project.pm.toLowerCase().includes(searchTerm)) ||
|
||||
(project.site && project.site.toLowerCase().includes(searchTerm))
|
||||
);
|
||||
}
|
||||
|
||||
renderProjects();
|
||||
updateProjectStats();
|
||||
}
|
||||
|
||||
// 프로젝트 검색 (기존 함수 수정)
|
||||
function searchProjects() {
|
||||
applyAllFilters();
|
||||
}
|
||||
|
||||
// 프로젝트 필터링
|
||||
function filterProjects() {
|
||||
const statusFilter = document.getElementById('statusFilter');
|
||||
const selectedStatus = statusFilter ? statusFilter.value : '';
|
||||
|
||||
// 현재는 상태 필드가 없으므로 기본 필터링만 적용
|
||||
searchProjects();
|
||||
}
|
||||
|
||||
// 프로젝트 정렬
|
||||
function sortProjects() {
|
||||
const sortBy = document.getElementById('sortBy');
|
||||
const sortField = sortBy ? 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);
|
||||
case 'created_at':
|
||||
default:
|
||||
return new Date(b.created_at || 0) - new Date(a.created_at || 0);
|
||||
}
|
||||
});
|
||||
|
||||
renderProjects();
|
||||
}
|
||||
|
||||
// 프로젝트 목록 새로고침
|
||||
async function refreshProjectList() {
|
||||
const refreshBtn = document.querySelector('.btn-secondary');
|
||||
if (refreshBtn) {
|
||||
const originalText = refreshBtn.innerHTML;
|
||||
refreshBtn.innerHTML = '<span class="btn-icon">⏳</span>새로고침 중...';
|
||||
refreshBtn.disabled = true;
|
||||
|
||||
await loadProjects();
|
||||
|
||||
refreshBtn.innerHTML = originalText;
|
||||
refreshBtn.disabled = false;
|
||||
} else {
|
||||
await loadProjects();
|
||||
}
|
||||
|
||||
showToast('프로젝트 목록이 새로고침되었습니다.', 'success');
|
||||
}
|
||||
|
||||
// 프로젝트 모달 열기
|
||||
function openProjectModal(project = null) {
|
||||
const modal = document.getElementById('projectModal');
|
||||
const modalTitle = document.getElementById('modalTitle');
|
||||
const deleteBtn = document.getElementById('deleteProjectBtn');
|
||||
|
||||
if (!modal) return;
|
||||
|
||||
currentEditingProject = project;
|
||||
|
||||
if (project) {
|
||||
// 수정 모드
|
||||
modalTitle.textContent = '프로젝트 수정';
|
||||
deleteBtn.style.display = 'inline-flex';
|
||||
|
||||
// 폼에 데이터 채우기
|
||||
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 || '';
|
||||
// is_active 값 처리 (DB에서 0/1로 오는 경우 대비)
|
||||
const isActiveValue = project.is_active === 1 || project.is_active === true || project.is_active === 'true';
|
||||
document.getElementById('isActive').checked = isActiveValue;
|
||||
|
||||
console.log('🔧 프로젝트 로드:', {
|
||||
project_id: project.project_id,
|
||||
project_name: project.project_name,
|
||||
is_active_raw: project.is_active,
|
||||
is_active_processed: isActiveValue
|
||||
});
|
||||
} else {
|
||||
// 신규 등록 모드
|
||||
modalTitle.textContent = '새 프로젝트 등록';
|
||||
deleteBtn.style.display = 'none';
|
||||
|
||||
// 폼 초기화
|
||||
document.getElementById('projectForm').reset();
|
||||
document.getElementById('projectId').value = '';
|
||||
}
|
||||
|
||||
modal.style.display = 'flex';
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// 첫 번째 입력 필드에 포커스
|
||||
setTimeout(() => {
|
||||
const firstInput = document.getElementById('jobNo');
|
||||
if (firstInput) firstInput.focus();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// 프로젝트 모달 닫기
|
||||
function closeProjectModal() {
|
||||
const modal = document.getElementById('projectModal');
|
||||
if (modal) {
|
||||
modal.style.display = 'none';
|
||||
document.body.style.overflow = '';
|
||||
currentEditingProject = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 프로젝트 편집
|
||||
function editProject(projectId) {
|
||||
const project = allProjects.find(p => p.project_id === projectId);
|
||||
if (project) {
|
||||
openProjectModal(project);
|
||||
} else {
|
||||
showToast('프로젝트를 찾을 수 없습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 프로젝트 저장
|
||||
async function saveProject() {
|
||||
try {
|
||||
const form = document.getElementById('projectForm');
|
||||
const formData = new FormData(form);
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
console.log('💾 저장할 프로젝트 데이터:', projectData);
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!projectData.job_no || !projectData.project_name) {
|
||||
showToast('Job No.와 프로젝트명은 필수 입력 항목입니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
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)) {
|
||||
const action = projectId ? '수정' : '등록';
|
||||
showToast(`프로젝트가 성공적으로 ${action}되었습니다.`, 'success');
|
||||
|
||||
closeProjectModal();
|
||||
await loadProjects();
|
||||
} else {
|
||||
throw new Error(response?.message || '저장에 실패했습니다.');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('프로젝트 저장 오류:', error);
|
||||
showToast(error.message || '프로젝트 저장 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 프로젝트 삭제 확인
|
||||
function confirmDeleteProject(projectId) {
|
||||
const project = allProjects.find(p => p.project_id === projectId);
|
||||
if (!project) {
|
||||
showToast('프로젝트를 찾을 수 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (confirm(`"${project.project_name}" 프로젝트를 정말 삭제하시겠습니까?\n\n⚠️ 삭제된 프로젝트는 복구할 수 없습니다.`)) {
|
||||
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('프로젝트가 성공적으로 삭제되었습니다.', 'success');
|
||||
|
||||
closeProjectModal();
|
||||
await loadProjects();
|
||||
} else {
|
||||
throw new Error(response?.message || '삭제에 실패했습니다.');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('프로젝트 삭제 오류:', error);
|
||||
showToast(error.message || '프로젝트 삭제 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 토스트 메시지 표시
|
||||
function showToast(message, type = 'info') {
|
||||
// 기존 토스트 제거
|
||||
const existingToast = document.querySelector('.toast');
|
||||
if (existingToast) {
|
||||
existingToast.remove();
|
||||
}
|
||||
|
||||
// 새 토스트 생성
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.textContent = message;
|
||||
|
||||
// 스타일 적용
|
||||
Object.assign(toast.style, {
|
||||
position: 'fixed',
|
||||
top: '20px',
|
||||
right: '20px',
|
||||
padding: '12px 24px',
|
||||
borderRadius: '8px',
|
||||
color: 'white',
|
||||
fontWeight: '500',
|
||||
zIndex: '1000',
|
||||
transform: 'translateX(100%)',
|
||||
transition: 'transform 0.3s ease'
|
||||
});
|
||||
|
||||
// 타입별 배경색
|
||||
const colors = {
|
||||
success: '#10b981',
|
||||
error: '#ef4444',
|
||||
warning: '#f59e0b',
|
||||
info: '#3b82f6'
|
||||
};
|
||||
toast.style.backgroundColor = colors[type] || colors.info;
|
||||
|
||||
document.body.appendChild(toast);
|
||||
|
||||
// 애니메이션
|
||||
setTimeout(() => {
|
||||
toast.style.transform = 'translateX(0)';
|
||||
}, 100);
|
||||
|
||||
// 자동 제거
|
||||
setTimeout(() => {
|
||||
toast.style.transform = 'translateX(100%)';
|
||||
setTimeout(() => {
|
||||
if (toast.parentNode) {
|
||||
toast.remove();
|
||||
}
|
||||
}, 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// 전역 함수로 노출
|
||||
window.openProjectModal = openProjectModal;
|
||||
window.closeProjectModal = closeProjectModal;
|
||||
window.editProject = editProject;
|
||||
window.saveProject = saveProject;
|
||||
window.deleteProject = deleteProject;
|
||||
window.confirmDeleteProject = confirmDeleteProject;
|
||||
window.searchProjects = searchProjects;
|
||||
window.filterProjects = filterProjects;
|
||||
window.sortProjects = sortProjects;
|
||||
window.refreshProjectList = refreshProjectList;
|
||||
window.filterByStatus = filterByStatus;
|
||||
91
synology_deployment/web-ui/js/report-viewer-api.js
Normal file
91
synology_deployment/web-ui/js/report-viewer-api.js
Normal file
@@ -0,0 +1,91 @@
|
||||
// /js/report-viewer-api.js
|
||||
import { apiGet } from './api-helper.js';
|
||||
import { getUser } from './auth.js';
|
||||
|
||||
/**
|
||||
* 보고서 조회를 위한 마스터 데이터를 로드합니다. (작업 유형, 상태 등)
|
||||
* 실패 시 기본값을 반환할 수 있도록 개별적으로 처리합니다.
|
||||
* @returns {Promise<object>} - 각 마스터 데이터 배열을 포함하는 객체
|
||||
*/
|
||||
export async function loadMasterData() {
|
||||
const masterData = {
|
||||
workTypes: [],
|
||||
workStatusTypes: [],
|
||||
errorTypes: []
|
||||
};
|
||||
try {
|
||||
// Promise.allSettled를 사용해 일부 API가 실패해도 전체가 중단되지 않도록 함
|
||||
const results = await Promise.allSettled([
|
||||
apiGet('/daily-work-reports/work-types'),
|
||||
apiGet('/daily-work-reports/work-status-types'),
|
||||
apiGet('/daily-work-reports/error-types')
|
||||
]);
|
||||
|
||||
if (results[0].status === 'fulfilled') masterData.workTypes = results[0].value;
|
||||
if (results[1].status === 'fulfilled') masterData.workStatusTypes = results[1].value;
|
||||
if (results[2].status === 'fulfilled') masterData.errorTypes = results[2].value;
|
||||
|
||||
return masterData;
|
||||
} catch (error) {
|
||||
console.error('마스터 데이터 로딩 중 심각한 오류 발생:', error);
|
||||
// 최소한의 기본값이라도 반환
|
||||
return masterData;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 사용자의 권한을 확인하여 적절한 API 엔드포인트와 파라미터를 결정합니다.
|
||||
* @param {string} selectedDate - 조회할 날짜
|
||||
* @returns {string} - 호출할 API URL
|
||||
*/
|
||||
function getReportApiUrl(selectedDate) {
|
||||
const user = getUser();
|
||||
|
||||
// 관리자(admin, system)는 모든 데이터를 조회
|
||||
if (user && (user.role === 'admin' || user.role === 'system')) {
|
||||
// 백엔드에서 GET /daily-work-reports?date=YYYY-MM-DD 요청 시
|
||||
// 권한을 확인하고 모든 데이터를 내려준다고 가정
|
||||
return `/daily-work-reports?date=${selectedDate}`;
|
||||
}
|
||||
|
||||
// 그 외 사용자(leader, user)는 본인이 생성한 데이터만 조회
|
||||
// 백엔드에서 동일한 엔드포인트로 요청 시, 권한을 확인하고
|
||||
// 본인 데이터만 필터링해서 내려준다고 가정
|
||||
// (만약 엔드포인트가 다르다면 이 부분을 수정해야 함)
|
||||
return `/daily-work-reports?date=${selectedDate}`;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 특정 날짜의 작업 보고서 데이터를 서버에서 가져옵니다.
|
||||
* @param {string} selectedDate - 조회할 날짜 (YYYY-MM-DD)
|
||||
* @returns {Promise<Array>} - 작업 보고서 데이터 배열
|
||||
*/
|
||||
export async function fetchReportData(selectedDate) {
|
||||
if (!selectedDate) {
|
||||
throw new Error('조회할 날짜가 선택되지 않았습니다.');
|
||||
}
|
||||
|
||||
const apiUrl = getReportApiUrl(selectedDate);
|
||||
|
||||
try {
|
||||
const rawData = await apiGet(apiUrl);
|
||||
|
||||
// 서버 응답이 { success: true, data: [...] } 형태일 경우와 [...] 형태일 경우 모두 처리
|
||||
if (rawData && rawData.success && Array.isArray(rawData.data)) {
|
||||
return rawData.data;
|
||||
}
|
||||
if (Array.isArray(rawData)) {
|
||||
return rawData;
|
||||
}
|
||||
|
||||
// 예상치 못한 형식의 응답
|
||||
console.warn('예상치 못한 형식의 API 응답:', rawData);
|
||||
return [];
|
||||
|
||||
} catch (error) {
|
||||
console.error(`${selectedDate}의 작업 보고서 조회 실패:`, error);
|
||||
throw new Error('서버에서 데이터를 가져오는 데 실패했습니다.');
|
||||
}
|
||||
}
|
||||
72
synology_deployment/web-ui/js/report-viewer-export.js
Normal file
72
synology_deployment/web-ui/js/report-viewer-export.js
Normal file
@@ -0,0 +1,72 @@
|
||||
// /js/report-viewer-export.js
|
||||
|
||||
/**
|
||||
* 주어진 데이터를 CSV 형식의 문자열로 변환합니다.
|
||||
* @param {object} reportData - 요약 및 작업자별 데이터
|
||||
* @returns {string} - CSV 형식의 문자열
|
||||
*/
|
||||
function convertToCsv(reportData) {
|
||||
let csvContent = "\uFEFF"; // UTF-8 BOM
|
||||
csvContent += "작업자명,프로젝트명,작업유형,작업상태,에러유형,작업시간,입력자\n";
|
||||
|
||||
reportData.workers.forEach(worker => {
|
||||
worker.entries.forEach(entry => {
|
||||
const row = [
|
||||
worker.worker_name,
|
||||
entry.project_name,
|
||||
entry.work_type_name,
|
||||
entry.work_status_name,
|
||||
entry.error_type_name,
|
||||
entry.work_hours,
|
||||
entry.created_by_name
|
||||
].map(field => `"${String(field || '').replace(/"/g, '""')}"`).join(',');
|
||||
csvContent += row + "\n";
|
||||
});
|
||||
});
|
||||
return csvContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* 가공된 보고서 데이터를 CSV 파일로 다운로드합니다.
|
||||
* @param {object|null} reportData - UI에 표시된 가공된 데이터
|
||||
*/
|
||||
export function exportToExcel(reportData) {
|
||||
if (!reportData || !reportData.workers || reportData.workers.length === 0) {
|
||||
alert('내보낼 데이터가 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const csv = convertToCsv(reportData);
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const fileName = `작업보고서_${reportData.summary.date}.csv`;
|
||||
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', fileName);
|
||||
link.style.visibility = 'hidden';
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Excel 내보내기 실패:', error);
|
||||
alert('Excel 파일을 생성하는 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 페이지의 인쇄 기능을 호출합니다.
|
||||
*/
|
||||
export function printReport() {
|
||||
try {
|
||||
window.print();
|
||||
} catch (error) {
|
||||
console.error('인쇄 실패:', error);
|
||||
alert('인쇄 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
144
synology_deployment/web-ui/js/report-viewer-ui.js
Normal file
144
synology_deployment/web-ui/js/report-viewer-ui.js
Normal file
@@ -0,0 +1,144 @@
|
||||
// /js/report-viewer-ui.js
|
||||
|
||||
/**
|
||||
* 데이터를 가공하여 UI에 표시하기 좋은 요약 형태로 변환합니다.
|
||||
* @param {Array} rawData - 서버에서 받은 원시 데이터 배열
|
||||
* @param {string} selectedDate - 선택된 날짜
|
||||
* @returns {object} - 요약 정보와 작업자별로 그룹화된 데이터를 포함하는 객체
|
||||
*/
|
||||
export function processReportData(rawData, selectedDate) {
|
||||
if (!Array.isArray(rawData) || rawData.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const workerGroups = {};
|
||||
let totalHours = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
rawData.forEach(item => {
|
||||
const workerName = item.worker_name || '미지정';
|
||||
const workHours = parseFloat(item.work_hours || 0);
|
||||
totalHours += workHours;
|
||||
if (item.work_status_id === 2) errorCount++; // '에러' 상태 ID가 2라고 가정
|
||||
|
||||
if (!workerGroups[workerName]) {
|
||||
workerGroups[workerName] = {
|
||||
worker_name: workerName,
|
||||
total_hours: 0,
|
||||
entries: []
|
||||
};
|
||||
}
|
||||
workerGroups[workerName].total_hours += workHours;
|
||||
workerGroups[workerName].entries.push(item);
|
||||
});
|
||||
|
||||
return {
|
||||
summary: {
|
||||
date: selectedDate,
|
||||
total_workers: Object.keys(workerGroups).length,
|
||||
total_hours: totalHours,
|
||||
total_entries: rawData.length,
|
||||
error_count: errorCount
|
||||
},
|
||||
workers: Object.values(workerGroups)
|
||||
};
|
||||
}
|
||||
|
||||
function displaySummary(summary) {
|
||||
const elements = {
|
||||
totalWorkers: summary.total_workers,
|
||||
totalHours: `${summary.total_hours}시간`,
|
||||
totalEntries: `${summary.total_entries}개`,
|
||||
errorCount: `${summary.error_count}개`
|
||||
};
|
||||
Object.entries(elements).forEach(([id, value]) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = value;
|
||||
});
|
||||
document.getElementById('reportSummary').style.display = 'block';
|
||||
}
|
||||
|
||||
function createWorkEntryElement(entry) {
|
||||
const entryDiv = document.createElement('div');
|
||||
entryDiv.className = `work-entry ${entry.work_status_id === 2 ? 'error-entry' : ''}`;
|
||||
entryDiv.innerHTML = `
|
||||
<div class="entry-header">
|
||||
<div class="project-name">${entry.project_name || '프로젝트 미지정'}</div>
|
||||
<div class="work-hours">${entry.work_hours || 0}시간</div>
|
||||
</div>
|
||||
<div class="entry-details">
|
||||
<div class="entry-detail">
|
||||
<span class="detail-label">작업 유형:</span>
|
||||
<span class="detail-value">${entry.work_type_name || '-'}</span>
|
||||
</div>
|
||||
${entry.work_status_id === 2 ? `
|
||||
<div class="entry-detail">
|
||||
<span class="detail-label">에러 유형:</span>
|
||||
<span class="detail-value error-type">${entry.error_type_name || '에러'}</span>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
return entryDiv;
|
||||
}
|
||||
|
||||
function displayWorkersDetails(workers) {
|
||||
const workersListEl = document.getElementById('workersList');
|
||||
workersListEl.innerHTML = '';
|
||||
workers.forEach(worker => {
|
||||
const workerCard = document.createElement('div');
|
||||
workerCard.className = 'worker-card';
|
||||
workerCard.innerHTML = `
|
||||
<div class="worker-header">
|
||||
<div class="worker-name">👤 ${worker.worker_name}</div>
|
||||
<div class="worker-total-hours">총 ${worker.total_hours}시간</div>
|
||||
</div>
|
||||
`;
|
||||
const entriesContainer = document.createElement('div');
|
||||
entriesContainer.className = 'work-entries';
|
||||
worker.entries.forEach(entry => entriesContainer.appendChild(createWorkEntryElement(entry)));
|
||||
workerCard.appendChild(entriesContainer);
|
||||
workersListEl.appendChild(workerCard);
|
||||
});
|
||||
document.getElementById('workersReport').style.display = 'block';
|
||||
}
|
||||
|
||||
const hideElement = (id) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.style.display = 'none';
|
||||
};
|
||||
|
||||
/**
|
||||
* 가공된 데이터를 받아 화면 전체를 렌더링합니다.
|
||||
* @param {object|null} processedData - 가공된 데이터 또는 데이터가 없을 경우 null
|
||||
*/
|
||||
export function renderReport(processedData) {
|
||||
hideElement('loadingSpinner');
|
||||
hideElement('errorMessage');
|
||||
hideElement('noDataMessage');
|
||||
hideElement('reportSummary');
|
||||
hideElement('workersReport');
|
||||
hideElement('exportSection');
|
||||
|
||||
if (!processedData) {
|
||||
document.getElementById('noDataMessage').style.display = 'block';
|
||||
return;
|
||||
}
|
||||
displaySummary(processedData.summary);
|
||||
displayWorkersDetails(processedData.workers);
|
||||
document.getElementById('exportSection').style.display = 'block';
|
||||
}
|
||||
|
||||
export function showLoading(isLoading) {
|
||||
document.getElementById('loadingSpinner').style.display = isLoading ? 'flex' : 'none';
|
||||
if(isLoading) {
|
||||
hideElement('errorMessage');
|
||||
hideElement('noDataMessage');
|
||||
}
|
||||
}
|
||||
|
||||
export function showError(message) {
|
||||
const errorEl = document.getElementById('errorMessage');
|
||||
errorEl.querySelector('.error-text').textContent = message;
|
||||
errorEl.style.display = 'block';
|
||||
hideElement('loadingSpinner');
|
||||
}
|
||||
907
synology_deployment/web-ui/js/system-dashboard.js
Normal file
907
synology_deployment/web-ui/js/system-dashboard.js
Normal file
@@ -0,0 +1,907 @@
|
||||
// System Dashboard JavaScript
|
||||
console.log('🚀 system-dashboard.js loaded');
|
||||
|
||||
import { apiRequest } from './api-helper.js';
|
||||
import { getCurrentUser } from './auth.js';
|
||||
|
||||
console.log('📦 modules imported successfully');
|
||||
|
||||
// 전역 변수
|
||||
let systemData = {
|
||||
users: [],
|
||||
logs: [],
|
||||
systemStatus: {}
|
||||
};
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('📄 DOM loaded, starting initialization');
|
||||
initializeSystemDashboard();
|
||||
setupEventListeners();
|
||||
});
|
||||
|
||||
// Setup event listeners
|
||||
function setupEventListeners() {
|
||||
// Add event listeners to all data-action buttons
|
||||
const actionButtons = document.querySelectorAll('[data-action]');
|
||||
|
||||
actionButtons.forEach(button => {
|
||||
const action = button.getAttribute('data-action');
|
||||
|
||||
switch(action) {
|
||||
case 'account-management':
|
||||
button.addEventListener('click', openAccountManagement);
|
||||
console.log('✅ Account management button listener added');
|
||||
break;
|
||||
case 'system-logs':
|
||||
button.addEventListener('click', openSystemLogs);
|
||||
console.log('✅ System logs button listener added');
|
||||
break;
|
||||
case 'database-management':
|
||||
button.addEventListener('click', openDatabaseManagement);
|
||||
console.log('✅ Database management button listener added');
|
||||
break;
|
||||
case 'system-settings':
|
||||
button.addEventListener('click', openSystemSettings);
|
||||
console.log('✅ System settings button listener added');
|
||||
break;
|
||||
case 'backup-management':
|
||||
button.addEventListener('click', openBackupManagement);
|
||||
console.log('✅ Backup management button listener added');
|
||||
break;
|
||||
case 'monitoring':
|
||||
button.addEventListener('click', openMonitoring);
|
||||
console.log('✅ Monitoring button listener added');
|
||||
break;
|
||||
case 'close-modal':
|
||||
button.addEventListener('click', () => closeModal('account-modal'));
|
||||
console.log('✅ Modal close button listener added');
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`🎯 Total ${actionButtons.length} event listeners setup completed`);
|
||||
}
|
||||
|
||||
// Initialize system dashboard
|
||||
async function initializeSystemDashboard() {
|
||||
try {
|
||||
console.log('🚀 Starting system dashboard initialization...');
|
||||
|
||||
// Load user info
|
||||
await loadUserInfo();
|
||||
console.log('✅ User info loaded');
|
||||
|
||||
// Load system status
|
||||
await loadSystemStatus();
|
||||
console.log('✅ System status loaded');
|
||||
|
||||
// Load user statistics
|
||||
await loadUserStats();
|
||||
console.log('✅ User statistics loaded');
|
||||
|
||||
// Load recent activities
|
||||
await loadRecentActivities();
|
||||
console.log('✅ Recent activities loaded');
|
||||
|
||||
// Setup auto-refresh (every 30 seconds)
|
||||
setInterval(refreshSystemStatus, 30000);
|
||||
|
||||
console.log('🎉 System dashboard initialization completed');
|
||||
} catch (error) {
|
||||
console.error('❌ System dashboard initialization error:', error);
|
||||
showNotification('Error loading system dashboard', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 사용자 정보 로드
|
||||
async function loadUserInfo() {
|
||||
try {
|
||||
const user = getCurrentUser();
|
||||
if (user && user.name) {
|
||||
document.getElementById('user-name').textContent = user.name;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('사용자 정보 로드 오류:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 시스템 상태 로드
|
||||
async function loadSystemStatus() {
|
||||
try {
|
||||
// 서버 상태 확인
|
||||
const serverStatus = await checkServerStatus();
|
||||
updateServerStatus(serverStatus);
|
||||
|
||||
// 데이터베이스 상태 확인
|
||||
const dbStatus = await checkDatabaseStatus();
|
||||
updateDatabaseStatus(dbStatus);
|
||||
|
||||
// 시스템 알림 확인
|
||||
const alerts = await getSystemAlerts();
|
||||
updateSystemAlerts(alerts);
|
||||
|
||||
} catch (error) {
|
||||
console.error('시스템 상태 로드 오류:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 서버 상태 확인
|
||||
async function checkServerStatus() {
|
||||
try {
|
||||
const response = await apiRequest('/api/system/status', 'GET');
|
||||
return response.success ? 'online' : 'offline';
|
||||
} catch (error) {
|
||||
return 'offline';
|
||||
}
|
||||
}
|
||||
|
||||
// 데이터베이스 상태 확인
|
||||
async function checkDatabaseStatus() {
|
||||
try {
|
||||
const response = await apiRequest('/api/system/db-status', 'GET');
|
||||
return response;
|
||||
} catch (error) {
|
||||
return { status: 'error', connections: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// 시스템 알림 가져오기
|
||||
async function getSystemAlerts() {
|
||||
try {
|
||||
const response = await apiRequest('/api/system/alerts', 'GET');
|
||||
return response.alerts || [];
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 서버 상태 업데이트
|
||||
function updateServerStatus(status) {
|
||||
const serverCheckTime = document.getElementById('server-check-time');
|
||||
const statusElements = document.querySelectorAll('.status-value');
|
||||
|
||||
if (serverCheckTime) {
|
||||
serverCheckTime.textContent = new Date().toLocaleTimeString('ko-KR');
|
||||
}
|
||||
|
||||
// 서버 상태 표시 업데이트 로직 추가
|
||||
}
|
||||
|
||||
// 데이터베이스 상태 업데이트
|
||||
function updateDatabaseStatus(dbStatus) {
|
||||
const dbConnections = document.getElementById('db-connections');
|
||||
if (dbConnections && dbStatus.connections !== undefined) {
|
||||
dbConnections.textContent = dbStatus.connections;
|
||||
}
|
||||
}
|
||||
|
||||
// 시스템 알림 업데이트
|
||||
function updateSystemAlerts(alerts) {
|
||||
const systemAlerts = document.getElementById('system-alerts');
|
||||
if (systemAlerts) {
|
||||
systemAlerts.textContent = alerts.length;
|
||||
systemAlerts.className = `status-value ${alerts.length > 0 ? 'warning' : 'online'}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 사용자 통계 로드
|
||||
async function loadUserStats() {
|
||||
try {
|
||||
const response = await apiRequest('/api/system/users/stats', 'GET');
|
||||
|
||||
if (response.success) {
|
||||
const activeUsers = document.getElementById('active-users');
|
||||
const totalUsers = document.getElementById('total-users');
|
||||
|
||||
if (activeUsers) activeUsers.textContent = response.data.active || 0;
|
||||
if (totalUsers) totalUsers.textContent = response.data.total || 0;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('사용자 통계 로드 오류:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 최근 활동 로드
|
||||
async function loadRecentActivities() {
|
||||
try {
|
||||
const response = await apiRequest('/api/system/recent-activities', 'GET');
|
||||
|
||||
if (response.success && response.data) {
|
||||
displayRecentActivities(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('최근 활동 로드 오류:', error);
|
||||
displayDefaultActivities();
|
||||
}
|
||||
}
|
||||
|
||||
// 최근 활동 표시
|
||||
function displayRecentActivities(activities) {
|
||||
const container = document.getElementById('recent-activities');
|
||||
|
||||
if (!container) return;
|
||||
|
||||
if (!activities || activities.length === 0) {
|
||||
container.innerHTML = '<p style="text-align: center; color: #7f8c8d; padding: 2rem;">최근 활동이 없습니다.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const html = activities.map(activity => `
|
||||
<div class="activity-item">
|
||||
<div class="activity-icon">
|
||||
<i class="fas ${getActivityIcon(activity.type)}"></i>
|
||||
</div>
|
||||
<div class="activity-info">
|
||||
<h4>${activity.title}</h4>
|
||||
<p>${activity.description}</p>
|
||||
</div>
|
||||
<div class="activity-time">
|
||||
${formatTimeAgo(activity.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// 기본 활동 표시 (데이터 로드 실패 시)
|
||||
function displayDefaultActivities() {
|
||||
const container = document.getElementById('recent-activities');
|
||||
if (!container) return;
|
||||
|
||||
const defaultActivities = [
|
||||
{
|
||||
type: 'system',
|
||||
title: '시스템 시작',
|
||||
description: '시스템이 정상적으로 시작되었습니다.',
|
||||
created_at: new Date().toISOString()
|
||||
}
|
||||
];
|
||||
|
||||
displayRecentActivities(defaultActivities);
|
||||
}
|
||||
|
||||
// 활동 타입에 따른 아이콘 반환
|
||||
function getActivityIcon(type) {
|
||||
const icons = {
|
||||
'login': 'fa-sign-in-alt',
|
||||
'user_create': 'fa-user-plus',
|
||||
'user_update': 'fa-user-edit',
|
||||
'user_delete': 'fa-user-minus',
|
||||
'system': 'fa-cog',
|
||||
'database': 'fa-database',
|
||||
'backup': 'fa-download',
|
||||
'error': 'fa-exclamation-triangle'
|
||||
};
|
||||
|
||||
return icons[type] || 'fa-info-circle';
|
||||
}
|
||||
|
||||
// 시간 포맷팅 (몇 분 전, 몇 시간 전 등)
|
||||
function formatTimeAgo(dateString) {
|
||||
const now = new Date();
|
||||
const date = new Date(dateString);
|
||||
const diffInSeconds = Math.floor((now - date) / 1000);
|
||||
|
||||
if (diffInSeconds < 60) {
|
||||
return '방금 전';
|
||||
} else if (diffInSeconds < 3600) {
|
||||
return `${Math.floor(diffInSeconds / 60)}분 전`;
|
||||
} else if (diffInSeconds < 86400) {
|
||||
return `${Math.floor(diffInSeconds / 3600)}시간 전`;
|
||||
} else {
|
||||
return `${Math.floor(diffInSeconds / 86400)}일 전`;
|
||||
}
|
||||
}
|
||||
|
||||
// 시스템 상태 새로고침
|
||||
async function refreshSystemStatus() {
|
||||
try {
|
||||
await loadSystemStatus();
|
||||
await loadUserStats();
|
||||
} catch (error) {
|
||||
console.error('시스템 상태 새로고침 오류:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Open account management
|
||||
function openAccountManagement() {
|
||||
console.log('🎯 Account management button clicked');
|
||||
const modal = document.getElementById('account-modal');
|
||||
const content = document.getElementById('account-management-content');
|
||||
|
||||
console.log('Modal element:', modal);
|
||||
console.log('Content element:', content);
|
||||
|
||||
if (modal && content) {
|
||||
console.log('✅ Modal and content elements found, loading content...');
|
||||
// Load account management content
|
||||
loadAccountManagementContent(content);
|
||||
modal.style.display = 'block';
|
||||
console.log('✅ Modal displayed');
|
||||
} else {
|
||||
console.error('❌ Modal or content element not found');
|
||||
}
|
||||
}
|
||||
|
||||
// 계정 관리 컨텐츠 로드
|
||||
async function loadAccountManagementContent(container) {
|
||||
try {
|
||||
container.innerHTML = `
|
||||
<div class="loading-spinner">
|
||||
<i class="fas fa-spinner fa-spin"></i> 로딩 중...
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 사용자 목록 로드
|
||||
const response = await apiRequest('/api/system/users', 'GET');
|
||||
|
||||
if (response.success) {
|
||||
displayAccountManagement(container, response.data);
|
||||
} else {
|
||||
throw new Error(response.error || '사용자 목록을 불러올 수 없습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('계정 관리 컨텐츠 로드 오류:', error);
|
||||
container.innerHTML = `
|
||||
<div class="error-message">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<p>계정 정보를 불러오는 중 오류가 발생했습니다.</p>
|
||||
<button class="btn btn-primary" onclick="loadAccountManagementContent(document.getElementById('account-management-content'))">
|
||||
다시 시도
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// 계정 관리 화면 표시
|
||||
function displayAccountManagement(container, users) {
|
||||
const html = `
|
||||
<div class="account-management">
|
||||
<div class="account-header">
|
||||
<h4><i class="fas fa-users"></i> 사용자 계정 관리</h4>
|
||||
<button class="btn btn-primary" onclick="openCreateUserForm()">
|
||||
<i class="fas fa-plus"></i> 새 사용자
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="account-filters">
|
||||
<input type="text" id="user-search" placeholder="사용자 검색..." onkeyup="filterUsers()">
|
||||
<select id="role-filter" onchange="filterUsers()">
|
||||
<option value="">모든 권한</option>
|
||||
<option value="system">시스템</option>
|
||||
<option value="admin">관리자</option>
|
||||
<option value="leader">그룹장</option>
|
||||
<option value="user">사용자</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="users-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>사용자명</th>
|
||||
<th>이름</th>
|
||||
<th>권한</th>
|
||||
<th>상태</th>
|
||||
<th>마지막 로그인</th>
|
||||
<th>작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="users-tbody">
|
||||
${generateUsersTableRows(users)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.innerHTML = html;
|
||||
systemData.users = users;
|
||||
}
|
||||
|
||||
// 사용자 테이블 행 생성
|
||||
function generateUsersTableRows(users) {
|
||||
if (!users || users.length === 0) {
|
||||
return '<tr><td colspan="7" style="text-align: center; padding: 2rem;">등록된 사용자가 없습니다.</td></tr>';
|
||||
}
|
||||
|
||||
return users.map(user => `
|
||||
<tr data-user-id="${user.user_id}">
|
||||
<td>${user.user_id}</td>
|
||||
<td>${user.username}</td>
|
||||
<td>${user.name || '-'}</td>
|
||||
<td>
|
||||
<span class="role-badge role-${user.role}">
|
||||
${getRoleDisplayName(user.role)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="status-badge ${user.is_active ? 'active' : 'inactive'}">
|
||||
${user.is_active ? '활성' : '비활성'}
|
||||
</span>
|
||||
</td>
|
||||
<td>${user.last_login_at ? formatDate(user.last_login_at) : '없음'}</td>
|
||||
<td class="action-buttons">
|
||||
<button class="btn-small btn-edit" onclick="editUser(${user.user_id})" title="수정">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button class="btn-small btn-delete" onclick="deleteUser(${user.user_id})" title="삭제">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 권한 표시명 반환
|
||||
function getRoleDisplayName(role) {
|
||||
const roleNames = {
|
||||
'system': '시스템',
|
||||
'admin': '관리자',
|
||||
'leader': '그룹장',
|
||||
'user': '사용자'
|
||||
};
|
||||
return roleNames[role] || role;
|
||||
}
|
||||
|
||||
// 날짜 포맷팅
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return '-';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('ko-KR');
|
||||
}
|
||||
|
||||
// 시스템 로그 열기
|
||||
function openSystemLogs() {
|
||||
console.log('시스템 로그 버튼 클릭됨');
|
||||
const modal = document.getElementById('account-modal');
|
||||
const content = document.getElementById('account-management-content');
|
||||
|
||||
if (modal && content) {
|
||||
loadSystemLogsContent(content);
|
||||
modal.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// 시스템 로그 컨텐츠 로드
|
||||
async function loadSystemLogsContent(container) {
|
||||
try {
|
||||
container.innerHTML = `
|
||||
<div class="system-logs">
|
||||
<h4><i class="fas fa-file-alt"></i> 시스템 로그</h4>
|
||||
<div class="log-filters">
|
||||
<select id="log-type-filter">
|
||||
<option value="">모든 로그</option>
|
||||
<option value="login">로그인</option>
|
||||
<option value="activity">활동</option>
|
||||
<option value="error">오류</option>
|
||||
</select>
|
||||
<input type="date" id="log-date-filter">
|
||||
<button class="btn btn-primary" onclick="filterLogs()">
|
||||
<i class="fas fa-search"></i> 검색
|
||||
</button>
|
||||
</div>
|
||||
<div class="logs-container">
|
||||
<div class="loading-spinner">
|
||||
<i class="fas fa-spinner fa-spin"></i> 로그 로딩 중...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 로그 데이터 로드
|
||||
await loadLogsData();
|
||||
|
||||
} catch (error) {
|
||||
console.error('시스템 로그 로드 오류:', error);
|
||||
container.innerHTML = `
|
||||
<div class="error-message">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<p>시스템 로그를 불러오는 중 오류가 발생했습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// 로그 데이터 로드
|
||||
async function loadLogsData() {
|
||||
try {
|
||||
const response = await apiRequest('/api/system/logs/activity', 'GET');
|
||||
const logsContainer = document.querySelector('.logs-container');
|
||||
|
||||
if (response.success && response.data) {
|
||||
displayLogs(response.data, logsContainer);
|
||||
} else {
|
||||
logsContainer.innerHTML = '<p>로그 데이터가 없습니다.</p>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('로그 데이터 로드 오류:', error);
|
||||
document.querySelector('.logs-container').innerHTML = '<p>로그 데이터를 불러올 수 없습니다.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// 로그 표시
|
||||
function displayLogs(logs, container) {
|
||||
if (!logs || logs.length === 0) {
|
||||
container.innerHTML = '<p>표시할 로그가 없습니다.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const html = `
|
||||
<table class="logs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>시간</th>
|
||||
<th>유형</th>
|
||||
<th>사용자</th>
|
||||
<th>내용</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${logs.map(log => `
|
||||
<tr>
|
||||
<td>${formatDate(log.created_at)}</td>
|
||||
<td><span class="log-type ${log.type}">${log.type}</span></td>
|
||||
<td>${log.username || '-'}</td>
|
||||
<td>${log.description}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// 로그 필터링
|
||||
function filterLogs() {
|
||||
console.log('로그 필터링 실행');
|
||||
// 실제 구현은 추후 추가
|
||||
showNotification('로그 필터링 기능은 개발 중입니다.', 'info');
|
||||
}
|
||||
|
||||
// 데이터베이스 관리 열기
|
||||
function openDatabaseManagement() {
|
||||
console.log('데이터베이스 관리 버튼 클릭됨');
|
||||
showNotification('데이터베이스 관리 기능은 개발 중입니다.', 'info');
|
||||
}
|
||||
|
||||
// 시스템 설정 열기
|
||||
function openSystemSettings() {
|
||||
console.log('시스템 설정 버튼 클릭됨');
|
||||
showNotification('시스템 설정 기능은 개발 중입니다.', 'info');
|
||||
}
|
||||
|
||||
// 백업 관리 열기
|
||||
function openBackupManagement() {
|
||||
console.log('백업 관리 버튼 클릭됨');
|
||||
showNotification('백업 관리 기능은 개발 중입니다.', 'info');
|
||||
}
|
||||
|
||||
// 모니터링 열기
|
||||
function openMonitoring() {
|
||||
console.log('모니터링 버튼 클릭됨');
|
||||
showNotification('모니터링 기능은 개발 중입니다.', 'info');
|
||||
}
|
||||
|
||||
// 모달 닫기
|
||||
function closeModal(modalId) {
|
||||
const modal = document.getElementById(modalId);
|
||||
if (modal) {
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// 로그아웃
|
||||
function logout() {
|
||||
if (confirm('로그아웃 하시겠습니까?')) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/';
|
||||
}
|
||||
}
|
||||
|
||||
// 알림 표시
|
||||
function showNotification(message, type = 'info') {
|
||||
// 간단한 알림 표시 (나중에 토스트 라이브러리로 교체 가능)
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `notification notification-${type}`;
|
||||
notification.textContent = message;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
setTimeout(() => {
|
||||
notification.remove();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// 사용자 편집
|
||||
async function editUser(userId) {
|
||||
try {
|
||||
// 사용자 정보 가져오기
|
||||
const response = await apiRequest(`/api/system/users`, 'GET');
|
||||
if (!response.success) {
|
||||
throw new Error('사용자 정보를 가져올 수 없습니다.');
|
||||
}
|
||||
|
||||
const user = response.data.find(u => u.user_id === userId);
|
||||
if (!user) {
|
||||
throw new Error('해당 사용자를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
// 편집 폼 표시
|
||||
showUserEditForm(user);
|
||||
|
||||
} catch (error) {
|
||||
console.error('사용자 편집 오류:', error);
|
||||
showNotification('사용자 정보를 불러오는 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 사용자 편집 폼 표시
|
||||
function showUserEditForm(user) {
|
||||
const formHtml = `
|
||||
<div class="user-edit-form">
|
||||
<h4><i class="fas fa-user-edit"></i> 사용자 정보 수정</h4>
|
||||
<form id="edit-user-form">
|
||||
<div class="form-group">
|
||||
<label for="edit-username">사용자명</label>
|
||||
<input type="text" id="edit-username" value="${user.username}" disabled>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-name">이름</label>
|
||||
<input type="text" id="edit-name" value="${user.name || ''}" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-email">이메일</label>
|
||||
<input type="email" id="edit-email" value="${user.email || ''}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-role">권한</label>
|
||||
<select id="edit-role" required>
|
||||
<option value="system" ${user.role === 'system' ? 'selected' : ''}>시스템</option>
|
||||
<option value="admin" ${user.role === 'admin' ? 'selected' : ''}>관리자</option>
|
||||
<option value="leader" ${user.role === 'leader' ? 'selected' : ''}>그룹장</option>
|
||||
<option value="user" ${user.role === 'user' ? 'selected' : ''}>사용자</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-is-active">상태</label>
|
||||
<select id="edit-is-active" required>
|
||||
<option value="1" ${user.is_active ? 'selected' : ''}>활성</option>
|
||||
<option value="0" ${!user.is_active ? 'selected' : ''}>비활성</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-worker-id">작업자 ID</label>
|
||||
<input type="number" id="edit-worker-id" value="${user.worker_id || ''}">
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> 저장
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="closeModal('account-modal')">
|
||||
<i class="fas fa-times"></i> 취소
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const container = document.getElementById('account-management-content');
|
||||
container.innerHTML = formHtml;
|
||||
|
||||
// 폼 제출 이벤트 리스너
|
||||
document.getElementById('edit-user-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
await updateUser(user.user_id);
|
||||
});
|
||||
}
|
||||
|
||||
// 사용자 정보 업데이트
|
||||
async function updateUser(userId) {
|
||||
try {
|
||||
const formData = {
|
||||
name: document.getElementById('edit-name').value,
|
||||
email: document.getElementById('edit-email').value || null,
|
||||
role: document.getElementById('edit-role').value,
|
||||
access_level: document.getElementById('edit-role').value,
|
||||
is_active: parseInt(document.getElementById('edit-is-active').value),
|
||||
worker_id: document.getElementById('edit-worker-id').value || null
|
||||
};
|
||||
|
||||
const response = await apiRequest(`/api/system/users/${userId}`, 'PUT', formData);
|
||||
|
||||
if (response.success) {
|
||||
showNotification('사용자 정보가 성공적으로 업데이트되었습니다.', 'success');
|
||||
closeModal('account-modal');
|
||||
// 계정 관리 다시 로드
|
||||
setTimeout(() => openAccountManagement(), 500);
|
||||
} else {
|
||||
throw new Error(response.error || '업데이트에 실패했습니다.');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('사용자 업데이트 오류:', error);
|
||||
showNotification('사용자 정보 업데이트 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 사용자 삭제
|
||||
async function deleteUser(userId) {
|
||||
try {
|
||||
// 사용자 정보 가져오기
|
||||
const response = await apiRequest(`/api/system/users`, 'GET');
|
||||
if (!response.success) {
|
||||
throw new Error('사용자 정보를 가져올 수 없습니다.');
|
||||
}
|
||||
|
||||
const user = response.data.find(u => u.user_id === userId);
|
||||
if (!user) {
|
||||
throw new Error('해당 사용자를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
// 삭제 확인
|
||||
if (!confirm(`정말로 사용자 '${user.username}'를 삭제하시겠습니까?\n\n이 작업은 되돌릴 수 없습니다.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 사용자 삭제 요청
|
||||
const deleteResponse = await apiRequest(`/api/system/users/${userId}`, 'DELETE');
|
||||
|
||||
if (deleteResponse.success) {
|
||||
showNotification('사용자가 성공적으로 삭제되었습니다.', 'success');
|
||||
// 계정 관리 다시 로드
|
||||
setTimeout(() => {
|
||||
const container = document.getElementById('account-management-content');
|
||||
if (container) {
|
||||
loadAccountManagementContent(container);
|
||||
}
|
||||
}, 500);
|
||||
} else {
|
||||
throw new Error(deleteResponse.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('사용자 삭제 오류:', error);
|
||||
showNotification('사용자 삭제 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 새 사용자 생성 폼 열기
|
||||
function openCreateUserForm() {
|
||||
const formHtml = `
|
||||
<div class="user-create-form">
|
||||
<h4><i class="fas fa-user-plus"></i> 새 사용자 생성</h4>
|
||||
<form id="create-user-form">
|
||||
<div class="form-group">
|
||||
<label for="create-username">사용자명 *</label>
|
||||
<input type="text" id="create-username" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="create-password">비밀번호 *</label>
|
||||
<input type="password" id="create-password" required minlength="6">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="create-name">이름 *</label>
|
||||
<input type="text" id="create-name" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="create-email">이메일</label>
|
||||
<input type="email" id="create-email">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="create-role">권한 *</label>
|
||||
<select id="create-role" required>
|
||||
<option value="">권한 선택</option>
|
||||
<option value="system">시스템</option>
|
||||
<option value="admin">관리자</option>
|
||||
<option value="leader">그룹장</option>
|
||||
<option value="user">사용자</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="create-worker-id">작업자 ID</label>
|
||||
<input type="number" id="create-worker-id">
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> 생성
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="loadAccountManagementContent(document.getElementById('account-management-content'))">
|
||||
<i class="fas fa-arrow-left"></i> 돌아가기
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const container = document.getElementById('account-management-content');
|
||||
container.innerHTML = formHtml;
|
||||
|
||||
// 폼 제출 이벤트 리스너
|
||||
document.getElementById('create-user-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
await createUser();
|
||||
});
|
||||
}
|
||||
|
||||
// 새 사용자 생성
|
||||
async function createUser() {
|
||||
try {
|
||||
const formData = {
|
||||
username: document.getElementById('create-username').value,
|
||||
password: document.getElementById('create-password').value,
|
||||
name: document.getElementById('create-name').value,
|
||||
email: document.getElementById('create-email').value || null,
|
||||
role: document.getElementById('create-role').value,
|
||||
access_level: document.getElementById('create-role').value,
|
||||
worker_id: document.getElementById('create-worker-id').value || null
|
||||
};
|
||||
|
||||
const response = await apiRequest('/api/system/users', 'POST', formData);
|
||||
|
||||
if (response.success) {
|
||||
showNotification('새 사용자가 성공적으로 생성되었습니다.', 'success');
|
||||
// 계정 관리 목록으로 돌아가기
|
||||
setTimeout(() => {
|
||||
const container = document.getElementById('account-management-content');
|
||||
loadAccountManagementContent(container);
|
||||
}, 500);
|
||||
} else {
|
||||
throw new Error(response.error || '사용자 생성에 실패했습니다.');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('사용자 생성 오류:', error);
|
||||
showNotification('사용자 생성 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 사용자 필터링
|
||||
function filterUsers() {
|
||||
const searchTerm = document.getElementById('user-search').value.toLowerCase();
|
||||
const roleFilter = document.getElementById('role-filter').value;
|
||||
const rows = document.querySelectorAll('#users-tbody tr');
|
||||
|
||||
rows.forEach(row => {
|
||||
const username = row.cells[1].textContent.toLowerCase();
|
||||
const name = row.cells[2].textContent.toLowerCase();
|
||||
const role = row.querySelector('.role-badge').textContent.toLowerCase();
|
||||
|
||||
const matchesSearch = username.includes(searchTerm) || name.includes(searchTerm);
|
||||
const matchesRole = !roleFilter || role.includes(roleFilter);
|
||||
|
||||
row.style.display = matchesSearch && matchesRole ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// 모달 관련 함수들만 전역으로 노출 (동적으로 생성되는 HTML에서 사용)
|
||||
window.closeModal = closeModal;
|
||||
window.editUser = editUser;
|
||||
window.deleteUser = deleteUser;
|
||||
window.openCreateUserForm = openCreateUserForm;
|
||||
window.filterUsers = filterUsers;
|
||||
window.filterLogs = filterLogs;
|
||||
|
||||
// 테스트용 전역 함수
|
||||
window.testFunction = function() {
|
||||
console.log('🧪 테스트 함수 호출됨!');
|
||||
alert('테스트 함수가 정상적으로 작동합니다!');
|
||||
};
|
||||
|
||||
console.log('🌐 전역 함수들 노출 완료');
|
||||
|
||||
// 모달 외부 클릭 시 닫기
|
||||
window.onclick = function(event) {
|
||||
const modals = document.querySelectorAll('.modal');
|
||||
modals.forEach(modal => {
|
||||
if (event.target === modal) {
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
});
|
||||
};
|
||||
93
synology_deployment/web-ui/js/user-dashboard.js
Normal file
93
synology_deployment/web-ui/js/user-dashboard.js
Normal file
@@ -0,0 +1,93 @@
|
||||
// /js/user-dashboard.js
|
||||
import { getUser } from './auth.js';
|
||||
import { apiGet } from './api-helper.js'; // 개선된 api-helper를 사용합니다.
|
||||
|
||||
/**
|
||||
* API를 호출하여 오늘의 작업 일정을 불러와 화면에 표시합니다.
|
||||
*/
|
||||
async function loadTodaySchedule() {
|
||||
const scheduleContainer = document.getElementById('today-schedule');
|
||||
scheduleContainer.innerHTML = '<p>📅 오늘의 작업 일정을 불러오는 중...</p>';
|
||||
|
||||
try {
|
||||
// 예시: /api/dashboard/today-schedule 엔드포인트에서 데이터를 가져옵니다.
|
||||
// 실제 엔드포인트는 백엔드 구현에 따라 달라질 수 있습니다.
|
||||
const scheduleData = await apiGet('/dashboard/today-schedule');
|
||||
|
||||
if (scheduleData && scheduleData.length > 0) {
|
||||
const scheduleHtml = scheduleData.map(item => `
|
||||
<div class="schedule-item">
|
||||
<span class="time">${item.time}</span>
|
||||
<span class="task">${item.task_name}</span>
|
||||
<span class="status ${item.status}">${item.status_kor}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
scheduleContainer.innerHTML = scheduleHtml;
|
||||
} else {
|
||||
scheduleContainer.innerHTML = '<p>오늘 예정된 작업이 없습니다.</p>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('오늘의 작업 일정 로드 실패:', error);
|
||||
scheduleContainer.innerHTML = '<p class="error">일정 정보를 불러오는 데 실패했습니다.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API를 호출하여 현재 사용자의 작업 통계를 불러와 화면에 표시합니다.
|
||||
*/
|
||||
async function loadWorkStats() {
|
||||
const statsContainer = document.getElementById('work-stats');
|
||||
statsContainer.innerHTML = '<p>📈 내 작업 현황을 불러오는 중...</p>';
|
||||
|
||||
try {
|
||||
// 예시: /api/dashboard/my-stats 엔드포인트에서 데이터를 가져옵니다.
|
||||
const statsData = await apiGet('/dashboard/my-stats');
|
||||
|
||||
if (statsData) {
|
||||
const statsHtml = `
|
||||
<div class="stat-item">
|
||||
<span>이번 주 작업 시간:</span>
|
||||
<strong>${statsData.weekly_hours || 0} 시간</strong>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span>이번 달 작업 시간:</span>
|
||||
<strong>${statsData.monthly_hours || 0} 시간</strong>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span>완료한 작업 수:</span>
|
||||
<strong>${statsData.completed_tasks || 0} 건</strong>
|
||||
</div>
|
||||
`;
|
||||
statsContainer.innerHTML = statsHtml;
|
||||
} else {
|
||||
statsContainer.innerHTML = '<p>표시할 통계 정보가 없습니다.</p>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('작업 통계 로드 실패:', error);
|
||||
statsContainer.innerHTML = '<p class="error">통계 정보를 불러오는 데 실패했습니다.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 환영 메시지를 사용자 이름으로 개인화합니다.
|
||||
*/
|
||||
function personalizeWelcome() {
|
||||
// 전역 변수 대신 auth.js 모듈을 통해 사용자 정보를 가져옵니다.
|
||||
const user = getUser();
|
||||
if (user) {
|
||||
const welcomeEl = document.getElementById('welcome-message');
|
||||
if (welcomeEl) {
|
||||
welcomeEl.textContent = `${user.name || user.username}님, 환영합니다! 오늘 하루도 안전하게 작업하세요.`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 페이지 초기화 함수
|
||||
function initializeDashboard() {
|
||||
personalizeWelcome();
|
||||
loadTodaySchedule();
|
||||
loadWorkStats();
|
||||
}
|
||||
|
||||
// DOM이 로드되면 대시보드 초기화를 시작합니다.
|
||||
document.addEventListener('DOMContentLoaded', initializeDashboard);
|
||||
830
synology_deployment/web-ui/js/work-analysis.js
Normal file
830
synology_deployment/web-ui/js/work-analysis.js
Normal file
@@ -0,0 +1,830 @@
|
||||
// 작업 분석 페이지 JavaScript
|
||||
|
||||
// 전역 변수
|
||||
let currentMode = 'period';
|
||||
let currentTab = 'worker';
|
||||
let analysisData = null;
|
||||
let projectChart = null;
|
||||
let errorByProjectChart = null;
|
||||
let errorTimelineChart = null;
|
||||
|
||||
// 페이지 초기화
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('📈 작업 분석 페이지 초기화 시작');
|
||||
|
||||
initializePage();
|
||||
loadInitialData();
|
||||
});
|
||||
|
||||
// 페이지 초기화
|
||||
function initializePage() {
|
||||
// 시간 업데이트 시작
|
||||
updateCurrentTime();
|
||||
setInterval(updateCurrentTime, 1000);
|
||||
|
||||
// 사용자 정보 업데이트
|
||||
updateUserInfo();
|
||||
|
||||
// 프로필 메뉴 토글
|
||||
setupProfileMenu();
|
||||
|
||||
// 로그아웃 버튼
|
||||
setupLogoutButton();
|
||||
|
||||
// 기본 날짜 설정은 HTML에서 처리됨 (새로운 UI)
|
||||
console.log('✅ 작업 분석 페이지 초기화 완료');
|
||||
}
|
||||
|
||||
// 현재 시간 업데이트
|
||||
function updateCurrentTime() {
|
||||
const now = new Date();
|
||||
const timeString = now.toLocaleTimeString('ko-KR', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
|
||||
const timeElement = document.getElementById('timeValue');
|
||||
if (timeElement) {
|
||||
timeElement.textContent = timeString;
|
||||
}
|
||||
}
|
||||
|
||||
// 사용자 정보 업데이트
|
||||
function updateUserInfo() {
|
||||
let userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}');
|
||||
let authUser = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
|
||||
const finalUserInfo = {
|
||||
worker_name: userInfo.worker_name || authUser.username || authUser.worker_name,
|
||||
job_type: userInfo.job_type || authUser.role || authUser.job_type,
|
||||
username: authUser.username || userInfo.username
|
||||
};
|
||||
|
||||
const userNameElement = document.getElementById('userName');
|
||||
const userRoleElement = document.getElementById('userRole');
|
||||
const userInitialElement = document.getElementById('userInitial');
|
||||
|
||||
if (userNameElement) {
|
||||
userNameElement.textContent = finalUserInfo.worker_name || '사용자';
|
||||
}
|
||||
|
||||
if (userRoleElement) {
|
||||
const roleMap = {
|
||||
'leader': '그룹장',
|
||||
'worker': '작업자',
|
||||
'admin': '관리자',
|
||||
'system': '시스템 관리자'
|
||||
};
|
||||
userRoleElement.textContent = roleMap[finalUserInfo.job_type] || finalUserInfo.job_type || '작업자';
|
||||
}
|
||||
|
||||
if (userInitialElement) {
|
||||
const name = finalUserInfo.worker_name || '사용자';
|
||||
userInitialElement.textContent = name.charAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
// 프로필 메뉴 설정
|
||||
function setupProfileMenu() {
|
||||
const userProfile = document.getElementById('userProfile');
|
||||
const profileMenu = document.getElementById('profileMenu');
|
||||
|
||||
if (userProfile && profileMenu) {
|
||||
userProfile.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
const isVisible = profileMenu.style.display === 'block';
|
||||
profileMenu.style.display = isVisible ? 'none' : 'block';
|
||||
});
|
||||
|
||||
// 외부 클릭 시 메뉴 닫기
|
||||
document.addEventListener('click', function() {
|
||||
profileMenu.style.display = 'none';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 로그아웃 버튼 설정
|
||||
function setupLogoutButton() {
|
||||
const logoutBtn = document.getElementById('logoutBtn');
|
||||
if (logoutBtn) {
|
||||
logoutBtn.addEventListener('click', function() {
|
||||
if (confirm('로그아웃 하시겠습니까?')) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('userInfo');
|
||||
window.location.href = '/index.html';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 초기 데이터 로드
|
||||
async function loadInitialData() {
|
||||
try {
|
||||
console.log('📊 초기 데이터 로딩 시작');
|
||||
|
||||
// 프로젝트 목록 로드
|
||||
const projects = await apiCall('/projects/active/list', 'GET');
|
||||
const projectData = Array.isArray(projects) ? projects : (projects.data || []);
|
||||
|
||||
// 프로젝트 필터 옵션 업데이트
|
||||
updateProjectFilters(projectData);
|
||||
|
||||
console.log('✅ 초기 데이터 로딩 완료');
|
||||
|
||||
} catch (error) {
|
||||
console.error('초기 데이터 로딩 오류:', error);
|
||||
showToast('초기 데이터를 불러오는데 실패했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 프로젝트 필터 업데이트
|
||||
function updateProjectFilters(projects) {
|
||||
const projectFilter = document.getElementById('projectFilter');
|
||||
const projectModeSelect = document.getElementById('projectModeSelect');
|
||||
|
||||
if (projectFilter) {
|
||||
projectFilter.innerHTML = '<option value="">전체 프로젝트</option>';
|
||||
projects.forEach(project => {
|
||||
projectFilter.innerHTML += `<option value="${project.project_id}">${project.project_name}</option>`;
|
||||
});
|
||||
}
|
||||
|
||||
if (projectModeSelect) {
|
||||
projectModeSelect.innerHTML = '<option value="">프로젝트를 선택하세요</option>';
|
||||
projects.forEach(project => {
|
||||
projectModeSelect.innerHTML += `<option value="${project.project_id}">${project.project_name}</option>`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 분석 모드 전환
|
||||
function switchAnalysisMode(mode) {
|
||||
currentMode = mode;
|
||||
|
||||
// 탭 버튼 활성화 상태 변경
|
||||
document.querySelectorAll('.mode-tab').forEach(tab => {
|
||||
tab.classList.remove('active');
|
||||
});
|
||||
document.querySelector(`[data-mode="${mode}"]`).classList.add('active');
|
||||
|
||||
// 모드 콘텐츠 표시/숨김
|
||||
document.querySelectorAll('.analysis-mode').forEach(content => {
|
||||
content.classList.remove('active');
|
||||
});
|
||||
document.getElementById(`${mode}-mode`).classList.add('active');
|
||||
|
||||
console.log(`🔄 분석 모드 전환: ${mode}`);
|
||||
}
|
||||
|
||||
// 분석 탭 전환
|
||||
function switchAnalysisTab(tab) {
|
||||
currentTab = tab;
|
||||
|
||||
// 탭 버튼 활성화 상태 변경
|
||||
document.querySelectorAll('.analysis-tab').forEach(tabBtn => {
|
||||
tabBtn.classList.remove('active');
|
||||
});
|
||||
document.querySelector(`[data-tab="${tab}"]`).classList.add('active');
|
||||
|
||||
// 탭 콘텐츠 표시/숨김
|
||||
document.querySelectorAll('.analysis-content').forEach(content => {
|
||||
content.classList.remove('active');
|
||||
});
|
||||
document.getElementById(`${tab}-analysis`).classList.add('active');
|
||||
|
||||
console.log(`🔄 분석 탭 전환: ${tab}`);
|
||||
}
|
||||
|
||||
// 기간별 분석 로드
|
||||
async function loadPeriodAnalysis() {
|
||||
const startDate = document.getElementById('startDate').value;
|
||||
const endDate = document.getElementById('endDate').value;
|
||||
const projectId = document.getElementById('projectFilter').value;
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
showToast('시작일과 종료일을 모두 선택해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (new Date(startDate) > new Date(endDate)) {
|
||||
showToast('시작일이 종료일보다 늦을 수 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
showLoading(true);
|
||||
|
||||
try {
|
||||
console.log('📊 기간별 분석 데이터 로딩 시작');
|
||||
|
||||
// API 호출 파라미터 구성
|
||||
const params = new URLSearchParams({
|
||||
start: startDate,
|
||||
end: endDate
|
||||
});
|
||||
|
||||
if (projectId) {
|
||||
params.append('project_id', projectId);
|
||||
}
|
||||
|
||||
// 여러 API를 병렬로 호출하여 종합 분석 데이터 구성
|
||||
console.log('📡 API 파라미터:', params.toString());
|
||||
|
||||
const [statsRes, workerStatsRes, projectStatsRes, errorAnalysisRes] = await Promise.all([
|
||||
apiCall(`/work-analysis/stats?${params}`, 'GET').catch(err => {
|
||||
console.error('❌ stats API 오류:', err);
|
||||
return { data: null };
|
||||
}),
|
||||
apiCall(`/work-analysis/worker-stats?${params}`, 'GET').catch(err => {
|
||||
console.error('❌ worker-stats API 오류:', err);
|
||||
return { data: [] };
|
||||
}),
|
||||
apiCall(`/work-analysis/project-stats?${params}`, 'GET').catch(err => {
|
||||
console.error('❌ project-stats API 오류:', err);
|
||||
return { data: [] };
|
||||
}),
|
||||
apiCall(`/work-analysis/error-analysis?${params}`, 'GET').catch(err => {
|
||||
console.error('❌ error-analysis API 오류:', err);
|
||||
return { data: {} };
|
||||
})
|
||||
]);
|
||||
|
||||
console.log('📊 개별 API 응답:');
|
||||
console.log(' - stats:', statsRes);
|
||||
console.log(' - worker-stats:', workerStatsRes);
|
||||
console.log(' - project-stats:', projectStatsRes);
|
||||
console.log(' - error-analysis:', errorAnalysisRes);
|
||||
|
||||
// 종합 분석 데이터 구성
|
||||
analysisData = {
|
||||
summary: statsRes.data || statsRes,
|
||||
workerStats: workerStatsRes.data || workerStatsRes,
|
||||
projectStats: projectStatsRes.data || projectStatsRes,
|
||||
errorStats: errorAnalysisRes.data || errorAnalysisRes
|
||||
};
|
||||
|
||||
console.log('📊 분석 데이터:', analysisData);
|
||||
console.log('📊 요약 통계:', analysisData.summary);
|
||||
console.log('👥 작업자 통계:', analysisData.workerStats);
|
||||
console.log('📁 프로젝트 통계:', analysisData.projectStats);
|
||||
console.log('⚠️ 오류 통계:', analysisData.errorStats);
|
||||
|
||||
// 결과 표시
|
||||
displayPeriodAnalysis(analysisData);
|
||||
|
||||
// 결과 섹션 표시
|
||||
document.getElementById('periodResults').style.display = 'block';
|
||||
|
||||
showToast('분석이 완료되었습니다.', 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('기간별 분석 오류:', error);
|
||||
showToast('분석 중 오류가 발생했습니다.', 'error');
|
||||
} finally {
|
||||
showLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 기간별 분석 결과 표시
|
||||
function displayPeriodAnalysis(data) {
|
||||
// 요약 통계 업데이트
|
||||
updateSummaryStats(data.summary || {});
|
||||
|
||||
// 작업자별 분석 표시
|
||||
displayWorkerAnalysis(data.workerStats || []);
|
||||
|
||||
// 프로젝트별 분석 표시
|
||||
displayProjectAnalysis(data.projectStats || []);
|
||||
|
||||
// 오류 분석 표시 (전체 분석 데이터도 함께 전달)
|
||||
displayErrorAnalysis(data.errorStats || {}, data);
|
||||
}
|
||||
|
||||
// 요약 통계 업데이트
|
||||
function updateSummaryStats(summary) {
|
||||
// API 응답 구조에 맞게 필드명 조정
|
||||
document.getElementById('totalHours').textContent = `${summary.totalHours || summary.total_hours || 0}h`;
|
||||
document.getElementById('totalWorkers').textContent = `${summary.activeworkers || summary.activeWorkers || summary.total_workers || 0}명`;
|
||||
document.getElementById('totalProjects').textContent = `${summary.activeProjects || summary.active_projects || summary.total_projects || 0}개`;
|
||||
document.getElementById('errorRate').textContent = `${summary.errorRate || summary.error_rate || 0}%`;
|
||||
}
|
||||
|
||||
// 작업자별 분석 표시
|
||||
function displayWorkerAnalysis(workerStats) {
|
||||
const grid = document.getElementById('workerAnalysisGrid');
|
||||
|
||||
console.log('👥 작업자 분석 데이터 확인:', workerStats);
|
||||
console.log('👥 데이터 타입:', typeof workerStats);
|
||||
console.log('👥 배열 여부:', Array.isArray(workerStats));
|
||||
console.log('👥 길이:', workerStats ? workerStats.length : 'undefined');
|
||||
|
||||
if (!workerStats || (Array.isArray(workerStats) && workerStats.length === 0)) {
|
||||
console.log('👥 빈 데이터로 인한 empty-state 표시');
|
||||
grid.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">👥</div>
|
||||
<h3>분석할 작업자 데이터가 없습니다.</h3>
|
||||
<p>선택한 기간에 등록된 작업이 없습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
let gridHtml = '';
|
||||
|
||||
workerStats.forEach(worker => {
|
||||
const workerName = worker.worker_name || worker.name || '알 수 없음';
|
||||
const totalHours = worker.total_hours || worker.totalHours || 0;
|
||||
|
||||
gridHtml += `
|
||||
<div class="worker-card">
|
||||
<div class="worker-header">
|
||||
<div class="worker-info">
|
||||
<div class="worker-avatar">${workerName.charAt(0)}</div>
|
||||
<div class="worker-name">${workerName}</div>
|
||||
</div>
|
||||
<div class="worker-total-hours">${totalHours}h</div>
|
||||
</div>
|
||||
<div class="worker-projects">
|
||||
`;
|
||||
|
||||
// API 응답 구조에 따라 프로젝트 데이터 처리
|
||||
const projects = worker.projects || worker.project_details || [];
|
||||
if (projects.length > 0) {
|
||||
projects.forEach(project => {
|
||||
const projectName = project.project_name || project.name || '프로젝트';
|
||||
gridHtml += `
|
||||
<div class="project-item">
|
||||
<div class="project-name">${projectName}</div>
|
||||
<div class="work-items">
|
||||
`;
|
||||
|
||||
const works = project.works || project.work_details || project.tasks || [];
|
||||
if (works.length > 0) {
|
||||
works.forEach(work => {
|
||||
const workName = work.work_name || work.task_name || work.name || '작업';
|
||||
const workHours = work.hours || work.total_hours || work.work_hours || 0;
|
||||
gridHtml += `
|
||||
<div class="work-item">
|
||||
<div class="work-name">${workName}</div>
|
||||
<div class="work-hours">${workHours}h</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
} else {
|
||||
gridHtml += `
|
||||
<div class="work-item">
|
||||
<div class="work-name">총 작업시간</div>
|
||||
<div class="work-hours">${project.total_hours || project.hours || 0}h</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
gridHtml += `
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
} else {
|
||||
gridHtml += `
|
||||
<div class="project-item">
|
||||
<div class="project-name">전체 작업</div>
|
||||
<div class="work-items">
|
||||
<div class="work-item">
|
||||
<div class="work-name">총 작업시간</div>
|
||||
<div class="work-hours">${totalHours}h</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
gridHtml += `
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
grid.innerHTML = gridHtml;
|
||||
}
|
||||
|
||||
// 프로젝트별 분석 표시
|
||||
function displayProjectAnalysis(projectStats) {
|
||||
const detailsContainer = document.getElementById('projectDetails');
|
||||
|
||||
console.log('📁 프로젝트 분석 데이터 확인:', projectStats);
|
||||
console.log('📁 데이터 타입:', typeof projectStats);
|
||||
console.log('📁 배열 여부:', Array.isArray(projectStats));
|
||||
console.log('📁 길이:', projectStats ? projectStats.length : 'undefined');
|
||||
|
||||
if (projectStats && projectStats.length > 0) {
|
||||
console.log('📁 첫 번째 프로젝트 데이터:', projectStats[0]);
|
||||
}
|
||||
|
||||
if (!projectStats || projectStats.length === 0) {
|
||||
console.log('📁 빈 데이터로 인한 empty-state 표시');
|
||||
detailsContainer.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">📁</div>
|
||||
<h3>분석할 프로젝트 데이터가 없습니다.</h3>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// 프로젝트 상세 정보 표시
|
||||
let detailsHtml = '';
|
||||
|
||||
// 전체 시간 계산 (퍼센트 계산용)
|
||||
const totalAllHours = projectStats.reduce((sum, p) => {
|
||||
return sum + (p.totalHours || p.total_hours || p.hours || 0);
|
||||
}, 0);
|
||||
|
||||
projectStats.forEach(project => {
|
||||
console.log('📁 개별 프로젝트 처리:', project);
|
||||
|
||||
const projectName = project.project_name || project.name || project.projectName || '프로젝트';
|
||||
const totalHours = project.totalHours || project.total_hours || project.hours || 0;
|
||||
|
||||
// 퍼센트 계산
|
||||
let percentage = project.percentage || project.percent || 0;
|
||||
if (percentage === 0 && totalAllHours > 0) {
|
||||
percentage = Math.round((totalHours / totalAllHours) * 100);
|
||||
}
|
||||
|
||||
detailsHtml += `
|
||||
<div class="project-detail-card">
|
||||
<div class="project-detail-header">
|
||||
<div class="project-detail-name">${projectName}</div>
|
||||
<div class="project-percentage">${percentage}%</div>
|
||||
</div>
|
||||
<div class="project-hours">${totalHours}시간</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
detailsContainer.innerHTML = detailsHtml;
|
||||
|
||||
// 차트 업데이트
|
||||
updateProjectChart(projectStats);
|
||||
}
|
||||
|
||||
// 프로젝트 차트 업데이트
|
||||
function updateProjectChart(projectStats) {
|
||||
const ctx = document.getElementById('projectChart');
|
||||
|
||||
if (projectChart) {
|
||||
projectChart.destroy();
|
||||
}
|
||||
|
||||
const labels = projectStats.map(p => p.project_name || p.name || p.projectName || '프로젝트');
|
||||
const data = projectStats.map(p => p.totalHours || p.total_hours || p.hours || 0);
|
||||
|
||||
console.log('📊 차트 라벨:', labels);
|
||||
console.log('📊 차트 데이터:', data);
|
||||
const colors = [
|
||||
'#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
|
||||
'#06b6d4', '#84cc16', '#f97316', '#ec4899', '#6366f1'
|
||||
];
|
||||
|
||||
projectChart = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
data: data,
|
||||
backgroundColor: colors.slice(0, data.length),
|
||||
borderWidth: 2,
|
||||
borderColor: '#ffffff'
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
padding: 20,
|
||||
usePointStyle: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 오류 분석 표시
|
||||
function displayErrorAnalysis(errorStats, allData) {
|
||||
console.log('⚠️ 오류 분석 데이터 확인:', errorStats);
|
||||
console.log('⚠️ 데이터 타입:', typeof errorStats);
|
||||
console.log('⚠️ 배열 여부:', Array.isArray(errorStats));
|
||||
|
||||
// errorStats가 배열인 경우 첫 번째 요소 사용
|
||||
let errorData = errorStats;
|
||||
if (Array.isArray(errorStats) && errorStats.length > 0) {
|
||||
errorData = errorStats[0];
|
||||
console.log('⚠️ 배열에서 첫 번째 요소 사용:', errorData);
|
||||
}
|
||||
|
||||
// 오류 요약 업데이트 - 실제 데이터 구조에 맞게 수정
|
||||
const errorHours = errorData.totalHours || errorData.total_hours || errorData.error_hours || 0;
|
||||
|
||||
// 전체 작업 시간에서 오류 시간을 빼서 정규 시간 계산
|
||||
// 요약 통계에서 전체 시간을 가져와서 계산
|
||||
const totalHours = allData && allData.summary ? allData.summary.totalHours : 0;
|
||||
const normalHours = Math.max(0, totalHours - errorHours);
|
||||
|
||||
console.log('⚠️ 정규 시간:', normalHours, '오류 시간:', errorHours);
|
||||
|
||||
document.getElementById('normalHours').textContent = `${normalHours}h`;
|
||||
document.getElementById('errorHours').textContent = `${errorHours}h`;
|
||||
|
||||
// 프로젝트별 에러율 차트
|
||||
if (errorStats.projectErrorRates) {
|
||||
updateErrorByProjectChart(errorStats.projectErrorRates);
|
||||
}
|
||||
|
||||
// 일별 오류 추이 차트
|
||||
if (errorStats.dailyErrorTrend) {
|
||||
updateErrorTimelineChart(errorStats.dailyErrorTrend);
|
||||
}
|
||||
|
||||
// 오류 유형별 분석
|
||||
if (errorStats.errorTypes) {
|
||||
displayErrorTypes(errorStats.errorTypes);
|
||||
}
|
||||
}
|
||||
|
||||
// 프로젝트별 에러율 차트 업데이트
|
||||
function updateErrorByProjectChart(projectErrorRates) {
|
||||
const ctx = document.getElementById('errorByProjectChart');
|
||||
|
||||
if (errorByProjectChart) {
|
||||
errorByProjectChart.destroy();
|
||||
}
|
||||
|
||||
const labels = projectErrorRates.map(p => p.project_name);
|
||||
const data = projectErrorRates.map(p => p.error_rate);
|
||||
|
||||
errorByProjectChart = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: '에러율 (%)',
|
||||
data: data,
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.8)',
|
||||
borderColor: 'rgba(239, 68, 68, 1)',
|
||||
borderWidth: 1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
max: 100,
|
||||
ticks: {
|
||||
callback: function(value) {
|
||||
return value + '%';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 일별 오류 추이 차트 업데이트
|
||||
function updateErrorTimelineChart(dailyErrorTrend) {
|
||||
const ctx = document.getElementById('errorTimelineChart');
|
||||
|
||||
if (errorTimelineChart) {
|
||||
errorTimelineChart.destroy();
|
||||
}
|
||||
|
||||
const labels = dailyErrorTrend.map(d => formatDate(new Date(d.date)));
|
||||
const errorData = dailyErrorTrend.map(d => d.error_count);
|
||||
const totalData = dailyErrorTrend.map(d => d.total_count);
|
||||
|
||||
errorTimelineChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{
|
||||
label: '총 작업',
|
||||
data: totalData,
|
||||
borderColor: 'rgba(59, 130, 246, 1)',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
fill: true
|
||||
},
|
||||
{
|
||||
label: '오류 작업',
|
||||
data: errorData,
|
||||
borderColor: 'rgba(239, 68, 68, 1)',
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||
fill: true
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 오류 유형별 분석 표시
|
||||
function displayErrorTypes(errorTypes) {
|
||||
const container = document.getElementById('errorTypesAnalysis');
|
||||
|
||||
if (!errorTypes || errorTypes.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">⚠️</div>
|
||||
<h3>오류 유형 데이터가 없습니다.</h3>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<h4>🔍 오류 유형별 상세 분석</h4>';
|
||||
|
||||
errorTypes.forEach(errorType => {
|
||||
html += `
|
||||
<div class="error-type-item">
|
||||
<div class="error-type-info">
|
||||
<div class="error-type-icon">⚠️</div>
|
||||
<div class="error-type-name">${errorType.error_name}</div>
|
||||
</div>
|
||||
<div class="error-type-stats">
|
||||
<div class="error-type-count">${errorType.count}건</div>
|
||||
<div class="error-type-percentage">${errorType.percentage}%</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// 프로젝트별 분석 로드
|
||||
async function loadProjectAnalysis() {
|
||||
const projectId = document.getElementById('projectModeSelect').value;
|
||||
const startDate = document.getElementById('projectStartDate').value;
|
||||
const endDate = document.getElementById('projectEndDate').value;
|
||||
|
||||
if (!projectId) {
|
||||
showToast('프로젝트를 선택해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
showLoading(true);
|
||||
|
||||
try {
|
||||
console.log('📁 프로젝트별 분석 데이터 로딩 시작');
|
||||
|
||||
// API 호출 파라미터 구성
|
||||
const params = new URLSearchParams({
|
||||
project_id: projectId
|
||||
});
|
||||
|
||||
if (startDate) params.append('start', startDate);
|
||||
if (endDate) params.append('end', endDate);
|
||||
|
||||
// 프로젝트별 상세 분석 데이터 로드
|
||||
const response = await apiCall(`/work-analysis/project-worktype-analysis?${params}`, 'GET');
|
||||
const projectAnalysisData = response.data || response;
|
||||
|
||||
console.log('📁 프로젝트 분석 데이터:', projectAnalysisData);
|
||||
|
||||
// 결과 표시
|
||||
displayProjectModeAnalysis(projectAnalysisData);
|
||||
|
||||
// 결과 섹션 표시
|
||||
document.getElementById('projectModeResults').style.display = 'block';
|
||||
|
||||
showToast('프로젝트 분석이 완료되었습니다.', 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('프로젝트별 분석 오류:', error);
|
||||
showToast('프로젝트 분석 중 오류가 발생했습니다.', 'error');
|
||||
} finally {
|
||||
showLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 프로젝트별 분석 결과 표시
|
||||
function displayProjectModeAnalysis(data) {
|
||||
const container = document.getElementById('projectModeResults');
|
||||
|
||||
// 프로젝트별 분석 결과 HTML 생성
|
||||
let html = `
|
||||
<div class="project-mode-analysis">
|
||||
<h3>📁 ${data.project_name} 분석 결과</h3>
|
||||
<!-- 프로젝트별 상세 분석 내용 -->
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// 로딩 상태 표시/숨김
|
||||
function showLoading(show) {
|
||||
const overlay = document.getElementById('loadingOverlay');
|
||||
if (overlay) {
|
||||
overlay.style.display = show ? 'flex' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// 날짜 포맷팅
|
||||
function formatDate(date) {
|
||||
if (!date) return '';
|
||||
|
||||
const d = new Date(date);
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// 토스트 메시지 표시
|
||||
function showToast(message, type = 'info') {
|
||||
// 기존 토스트 제거
|
||||
const existingToast = document.querySelector('.toast');
|
||||
if (existingToast) {
|
||||
existingToast.remove();
|
||||
}
|
||||
|
||||
// 새 토스트 생성
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.textContent = message;
|
||||
|
||||
// 스타일 적용
|
||||
Object.assign(toast.style, {
|
||||
position: 'fixed',
|
||||
top: '20px',
|
||||
right: '20px',
|
||||
padding: '12px 24px',
|
||||
borderRadius: '8px',
|
||||
color: 'white',
|
||||
fontWeight: '500',
|
||||
zIndex: '10000',
|
||||
transform: 'translateX(100%)',
|
||||
transition: 'transform 0.3s ease'
|
||||
});
|
||||
|
||||
// 타입별 배경색
|
||||
const colors = {
|
||||
success: '#10b981',
|
||||
error: '#ef4444',
|
||||
warning: '#f59e0b',
|
||||
info: '#3b82f6'
|
||||
};
|
||||
toast.style.backgroundColor = colors[type] || colors.info;
|
||||
|
||||
document.body.appendChild(toast);
|
||||
|
||||
// 애니메이션
|
||||
setTimeout(() => {
|
||||
toast.style.transform = 'translateX(0)';
|
||||
}, 100);
|
||||
|
||||
// 자동 제거
|
||||
setTimeout(() => {
|
||||
toast.style.transform = 'translateX(100%)';
|
||||
setTimeout(() => {
|
||||
if (toast.parentNode) {
|
||||
toast.remove();
|
||||
}
|
||||
}, 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// 전역 함수로 노출
|
||||
window.switchAnalysisMode = switchAnalysisMode;
|
||||
window.switchAnalysisTab = switchAnalysisTab;
|
||||
window.loadPeriodAnalysis = loadPeriodAnalysis;
|
||||
window.loadProjectAnalysis = loadProjectAnalysis;
|
||||
231
synology_deployment/web-ui/js/work-analysis/api-client.js
Normal file
231
synology_deployment/web-ui/js/work-analysis/api-client.js
Normal file
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* Work Analysis API Client Module
|
||||
* 작업 분석 관련 모든 API 호출을 관리하는 모듈
|
||||
*/
|
||||
|
||||
class WorkAnalysisAPIClient {
|
||||
constructor() {
|
||||
this.baseURL = window.API_BASE_URL || 'http://localhost:20005/api';
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 API 호출 메서드
|
||||
* @param {string} endpoint - API 엔드포인트
|
||||
* @param {string} method - HTTP 메서드
|
||||
* @param {Object} data - 요청 데이터
|
||||
* @returns {Promise<Object>} API 응답
|
||||
*/
|
||||
async apiCall(endpoint, method = 'GET', data = null) {
|
||||
try {
|
||||
const config = {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
};
|
||||
|
||||
if (data && method !== 'GET') {
|
||||
config.body = JSON.stringify(data);
|
||||
}
|
||||
|
||||
console.log(`📡 API 호출: ${this.baseURL}${endpoint} (${method})`);
|
||||
|
||||
const response = await fetch(`${this.baseURL}${endpoint}`, config);
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.message || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
console.log(`✅ API 성공: ${this.baseURL}${endpoint}`);
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ API 실패: ${this.baseURL}${endpoint}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 범위 파라미터 생성
|
||||
* @param {string} startDate - 시작일
|
||||
* @param {string} endDate - 종료일
|
||||
* @param {Object} additionalParams - 추가 파라미터
|
||||
* @returns {URLSearchParams} URL 파라미터
|
||||
*/
|
||||
createDateParams(startDate, endDate, additionalParams = {}) {
|
||||
const params = new URLSearchParams({
|
||||
start: startDate,
|
||||
end: endDate,
|
||||
...additionalParams
|
||||
});
|
||||
return params;
|
||||
}
|
||||
|
||||
// ========== 기본 통계 API ==========
|
||||
|
||||
/**
|
||||
* 기본 통계 조회
|
||||
*/
|
||||
async getBasicStats(startDate, endDate, projectId = null) {
|
||||
console.log('🔍 getBasicStats 호출:', startDate, '~', endDate, projectId ? `(프로젝트: ${projectId})` : '');
|
||||
const params = this.createDateParams(startDate, endDate,
|
||||
projectId ? { project_id: projectId } : {}
|
||||
);
|
||||
console.log('🌐 API 요청 URL:', `/work-analysis/stats?${params}`);
|
||||
return await this.apiCall(`/work-analysis/stats?${params}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 일별 추이 조회
|
||||
*/
|
||||
async getDailyTrend(startDate, endDate, projectId = null) {
|
||||
const params = this.createDateParams(startDate, endDate,
|
||||
projectId ? { project_id: projectId } : {}
|
||||
);
|
||||
return await this.apiCall(`/work-analysis/daily-trend?${params}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업자별 통계 조회
|
||||
*/
|
||||
async getWorkerStats(startDate, endDate, projectId = null) {
|
||||
const params = this.createDateParams(startDate, endDate,
|
||||
projectId ? { project_id: projectId } : {}
|
||||
);
|
||||
return await this.apiCall(`/work-analysis/worker-stats?${params}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로젝트별 통계 조회
|
||||
*/
|
||||
async getProjectStats(startDate, endDate) {
|
||||
const params = this.createDateParams(startDate, endDate);
|
||||
return await this.apiCall(`/work-analysis/project-stats?${params}`);
|
||||
}
|
||||
|
||||
// ========== 상세 분석 API ==========
|
||||
|
||||
/**
|
||||
* 프로젝트별-작업유형별 분석
|
||||
*/
|
||||
async getProjectWorkTypeAnalysis(startDate, endDate, limit = 2000) {
|
||||
const params = this.createDateParams(startDate, endDate, { limit });
|
||||
return await this.apiCall(`/work-analysis/project-worktype-analysis?${params}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 최근 작업 데이터 조회
|
||||
*/
|
||||
async getRecentWork(startDate, endDate, limit = 2000) {
|
||||
const params = this.createDateParams(startDate, endDate, { limit });
|
||||
return await this.apiCall(`/work-analysis/recent-work?${params}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 오류 분석 데이터 조회
|
||||
*/
|
||||
async getErrorAnalysis(startDate, endDate) {
|
||||
const params = this.createDateParams(startDate, endDate);
|
||||
return await this.apiCall(`/work-analysis/error-analysis?${params}`);
|
||||
}
|
||||
|
||||
// ========== 배치 API 호출 ==========
|
||||
|
||||
/**
|
||||
* 여러 API를 병렬로 호출
|
||||
* @param {Array} apiCalls - API 호출 배열
|
||||
* @returns {Promise<Array>} 결과 배열
|
||||
*/
|
||||
async batchCall(apiCalls) {
|
||||
console.log('🔄 배치 API 호출 시작:', apiCalls.length, '개');
|
||||
|
||||
const promises = apiCalls.map(async ({ name, method, ...args }) => {
|
||||
try {
|
||||
const result = await this[method](...args);
|
||||
return { name, success: true, data: result };
|
||||
} catch (error) {
|
||||
console.warn(`⚠️ ${name} API 오류:`, error);
|
||||
return { name, success: false, error: error.message, data: null };
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
console.log('✅ 배치 API 호출 완료');
|
||||
|
||||
return results.reduce((acc, result) => {
|
||||
acc[result.name] = result;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 차트 데이터를 위한 배치 호출
|
||||
*/
|
||||
async getChartData(startDate, endDate, projectId = null) {
|
||||
return await this.batchCall([
|
||||
{
|
||||
name: 'dailyTrend',
|
||||
method: 'getDailyTrend',
|
||||
startDate,
|
||||
endDate,
|
||||
projectId
|
||||
},
|
||||
{
|
||||
name: 'workerStats',
|
||||
method: 'getWorkerStats',
|
||||
startDate,
|
||||
endDate,
|
||||
projectId
|
||||
},
|
||||
{
|
||||
name: 'projectStats',
|
||||
method: 'getProjectStats',
|
||||
startDate,
|
||||
endDate
|
||||
},
|
||||
{
|
||||
name: 'errorAnalysis',
|
||||
method: 'getErrorAnalysis',
|
||||
startDate,
|
||||
endDate
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로젝트 분포 분석을 위한 배치 호출
|
||||
*/
|
||||
async getProjectDistributionData(startDate, endDate) {
|
||||
return await this.batchCall([
|
||||
{
|
||||
name: 'projectWorkType',
|
||||
method: 'getProjectWorkTypeAnalysis',
|
||||
startDate,
|
||||
endDate
|
||||
},
|
||||
{
|
||||
name: 'workerStats',
|
||||
method: 'getWorkerStats',
|
||||
startDate,
|
||||
endDate
|
||||
},
|
||||
{
|
||||
name: 'recentWork',
|
||||
method: 'getRecentWork',
|
||||
startDate,
|
||||
endDate
|
||||
}
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 인스턴스 생성
|
||||
window.WorkAnalysisAPI = new WorkAnalysisAPIClient();
|
||||
|
||||
// 하위 호환성을 위한 전역 함수
|
||||
window.apiCall = (endpoint, method, data) => {
|
||||
return window.WorkAnalysisAPI.apiCall(endpoint, method, data);
|
||||
};
|
||||
|
||||
// Export는 브라우저 환경에서 제거됨
|
||||
455
synology_deployment/web-ui/js/work-analysis/chart-renderer.js
Normal file
455
synology_deployment/web-ui/js/work-analysis/chart-renderer.js
Normal file
@@ -0,0 +1,455 @@
|
||||
/**
|
||||
* Work Analysis Chart Renderer Module
|
||||
* 작업 분석 차트 렌더링을 담당하는 모듈
|
||||
*/
|
||||
|
||||
class WorkAnalysisChartRenderer {
|
||||
constructor() {
|
||||
this.charts = new Map(); // 차트 인스턴스 관리
|
||||
this.dataProcessor = window.WorkAnalysisDataProcessor;
|
||||
this.defaultColors = [
|
||||
'#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
|
||||
'#06b6d4', '#84cc16', '#f97316', '#ec4899', '#6366f1'
|
||||
];
|
||||
}
|
||||
|
||||
// ========== 차트 관리 ==========
|
||||
|
||||
/**
|
||||
* 기존 차트 제거
|
||||
* @param {string} chartId - 차트 ID
|
||||
*/
|
||||
destroyChart(chartId) {
|
||||
if (this.charts.has(chartId)) {
|
||||
this.charts.get(chartId).destroy();
|
||||
this.charts.delete(chartId);
|
||||
console.log('🗑️ 차트 제거:', chartId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 차트 제거
|
||||
*/
|
||||
destroyAllCharts() {
|
||||
this.charts.forEach((chart, id) => {
|
||||
chart.destroy();
|
||||
console.log('🗑️ 차트 제거:', id);
|
||||
});
|
||||
this.charts.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 차트 생성 및 등록
|
||||
* @param {string} chartId - 차트 ID
|
||||
* @param {HTMLCanvasElement} canvas - 캔버스 요소
|
||||
* @param {Object} config - 차트 설정
|
||||
* @returns {Chart} 생성된 차트 인스턴스
|
||||
*/
|
||||
createChart(chartId, canvas, config) {
|
||||
// 기존 차트가 있으면 제거
|
||||
this.destroyChart(chartId);
|
||||
|
||||
const chart = new Chart(canvas, config);
|
||||
this.charts.set(chartId, chart);
|
||||
|
||||
console.log('📊 차트 생성:', chartId);
|
||||
return chart;
|
||||
}
|
||||
|
||||
// ========== 시계열 차트 ==========
|
||||
|
||||
/**
|
||||
* 시계열 차트 렌더링 (기간별 작업 현황)
|
||||
* @param {string} startDate - 시작일
|
||||
* @param {string} endDate - 종료일
|
||||
* @param {string} projectId - 프로젝트 ID (선택사항)
|
||||
*/
|
||||
async renderTimeSeriesChart(startDate, endDate, projectId = '') {
|
||||
console.log('📈 시계열 차트 렌더링 시작');
|
||||
|
||||
try {
|
||||
const api = window.WorkAnalysisAPI;
|
||||
const dailyTrendResponse = await api.getDailyTrend(startDate, endDate, projectId);
|
||||
|
||||
if (!dailyTrendResponse.success || !dailyTrendResponse.data) {
|
||||
throw new Error('일별 추이 데이터를 가져올 수 없습니다');
|
||||
}
|
||||
|
||||
const canvas = document.getElementById('workStatusChart');
|
||||
if (!canvas) {
|
||||
console.error('❌ workStatusChart 캔버스를 찾을 수 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
const chartData = this.dataProcessor.processTimeSeriesData(dailyTrendResponse.data);
|
||||
|
||||
const config = {
|
||||
type: 'line',
|
||||
data: chartData,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
aspectRatio: 2,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: '작업시간 (h)'
|
||||
}
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'right',
|
||||
title: {
|
||||
display: true,
|
||||
text: '작업자 수 (명)'
|
||||
},
|
||||
grid: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: '일별 작업 현황'
|
||||
},
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.createChart('workStatus', canvas, config);
|
||||
console.log('✅ 시계열 차트 렌더링 완료');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 시계열 차트 렌더링 실패:', error);
|
||||
this._showChartError('workStatusChart', '시계열 차트를 불러올 수 없습니다');
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 스택 바 차트 ==========
|
||||
|
||||
/**
|
||||
* 스택 바 차트 렌더링 (프로젝트별 → 작업유형별)
|
||||
* @param {Array} projectData - 프로젝트 데이터
|
||||
*/
|
||||
renderStackedBarChart(projectData) {
|
||||
console.log('📊 스택 바 차트 렌더링 시작');
|
||||
|
||||
const canvas = document.getElementById('projectDistributionChart');
|
||||
if (!canvas) {
|
||||
console.error('❌ projectDistributionChart 캔버스를 찾을 수 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!projectData || !projectData.projects || projectData.projects.length === 0) {
|
||||
this._showChartError('projectDistributionChart', '프로젝트 데이터가 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
// 데이터 변환
|
||||
const { labels, datasets } = this._processStackedBarData(projectData.projects);
|
||||
|
||||
const config = {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
aspectRatio: 2,
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: '프로젝트'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: '작업시간 (h)'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: '프로젝트별 작업유형 분포'
|
||||
},
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top'
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
title: function(context) {
|
||||
return `${context[0].label}`;
|
||||
},
|
||||
label: function(context) {
|
||||
const workType = context.dataset.label;
|
||||
const hours = context.parsed.y;
|
||||
const percentage = ((hours / projectData.totalHours) * 100).toFixed(1);
|
||||
return `${workType}: ${hours}h (${percentage}%)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.createChart('projectDistribution', canvas, config);
|
||||
console.log('✅ 스택 바 차트 렌더링 완료');
|
||||
}
|
||||
|
||||
/**
|
||||
* 스택 바 차트 데이터 처리
|
||||
*/
|
||||
_processStackedBarData(projects) {
|
||||
// 모든 작업유형 수집
|
||||
const allWorkTypes = new Set();
|
||||
projects.forEach(project => {
|
||||
project.workTypes.forEach(wt => {
|
||||
allWorkTypes.add(wt.work_type_name);
|
||||
});
|
||||
});
|
||||
|
||||
const workTypeArray = Array.from(allWorkTypes);
|
||||
const labels = projects.map(p => p.project_name);
|
||||
|
||||
// 작업유형별 데이터셋 생성
|
||||
const datasets = workTypeArray.map((workTypeName, index) => {
|
||||
const data = projects.map(project => {
|
||||
const workType = project.workTypes.find(wt => wt.work_type_name === workTypeName);
|
||||
return workType ? workType.totalHours : 0;
|
||||
});
|
||||
|
||||
return {
|
||||
label: workTypeName,
|
||||
data,
|
||||
backgroundColor: this.defaultColors[index % this.defaultColors.length],
|
||||
borderColor: this.defaultColors[index % this.defaultColors.length],
|
||||
borderWidth: 1
|
||||
};
|
||||
});
|
||||
|
||||
return { labels, datasets };
|
||||
}
|
||||
|
||||
// ========== 도넛 차트 ==========
|
||||
|
||||
/**
|
||||
* 도넛 차트 렌더링 (작업자별 성과)
|
||||
* @param {Array} workerData - 작업자 데이터
|
||||
*/
|
||||
renderWorkerPerformanceChart(workerData) {
|
||||
console.log('👤 작업자별 성과 차트 렌더링 시작');
|
||||
|
||||
const canvas = document.getElementById('workerPerformanceChart');
|
||||
if (!canvas) {
|
||||
console.error('❌ workerPerformanceChart 캔버스를 찾을 수 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!workerData || workerData.length === 0) {
|
||||
this._showChartError('workerPerformanceChart', '작업자 데이터가 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
const chartData = this.dataProcessor.processDonutChartData(
|
||||
workerData.map(worker => ({
|
||||
name: worker.worker_name,
|
||||
hours: worker.totalHours
|
||||
}))
|
||||
);
|
||||
|
||||
const config = {
|
||||
type: 'doughnut',
|
||||
data: chartData,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
aspectRatio: 1,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: '작업자별 작업시간 분포'
|
||||
},
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'right'
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
const label = context.label;
|
||||
const value = context.parsed;
|
||||
const total = context.dataset.data.reduce((a, b) => a + b, 0);
|
||||
const percentage = ((value / total) * 100).toFixed(1);
|
||||
return `${label}: ${value}h (${percentage}%)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.createChart('workerPerformance', canvas, config);
|
||||
console.log('✅ 작업자별 성과 차트 렌더링 완료');
|
||||
}
|
||||
|
||||
// ========== 오류 분석 차트 ==========
|
||||
|
||||
/**
|
||||
* 오류 분석 차트 렌더링
|
||||
* @param {Array} errorData - 오류 데이터
|
||||
*/
|
||||
renderErrorAnalysisChart(errorData) {
|
||||
console.log('⚠️ 오류 분석 차트 렌더링 시작');
|
||||
|
||||
const canvas = document.getElementById('errorAnalysisChart');
|
||||
if (!canvas) {
|
||||
console.error('❌ errorAnalysisChart 캔버스를 찾을 수 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!errorData || errorData.length === 0) {
|
||||
this._showChartError('errorAnalysisChart', '오류 데이터가 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
// 오류가 있는 데이터만 필터링
|
||||
const errorItems = errorData.filter(item =>
|
||||
item.error_count > 0 || (item.errorDetails && item.errorDetails.length > 0)
|
||||
);
|
||||
|
||||
if (errorItems.length === 0) {
|
||||
this._showChartError('errorAnalysisChart', '오류가 발생한 항목이 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
const chartData = this.dataProcessor.processDonutChartData(
|
||||
errorItems.map(item => ({
|
||||
name: item.project_name || item.name,
|
||||
hours: item.errorHours || item.error_count
|
||||
}))
|
||||
);
|
||||
|
||||
const config = {
|
||||
type: 'doughnut',
|
||||
data: chartData,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
aspectRatio: 1,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: '프로젝트별 오류 분포'
|
||||
},
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom'
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
const label = context.label;
|
||||
const value = context.parsed;
|
||||
const total = context.dataset.data.reduce((a, b) => a + b, 0);
|
||||
const percentage = ((value / total) * 100).toFixed(1);
|
||||
return `${label}: ${value}h (${percentage}%)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.createChart('errorAnalysis', canvas, config);
|
||||
console.log('✅ 오류 분석 차트 렌더링 완료');
|
||||
}
|
||||
|
||||
// ========== 유틸리티 ==========
|
||||
|
||||
/**
|
||||
* 차트 오류 표시
|
||||
* @param {string} canvasId - 캔버스 ID
|
||||
* @param {string} message - 오류 메시지
|
||||
*/
|
||||
_showChartError(canvasId, message) {
|
||||
const canvas = document.getElementById(canvasId);
|
||||
if (!canvas) return;
|
||||
|
||||
const container = canvas.parentElement;
|
||||
if (container) {
|
||||
container.innerHTML = `
|
||||
<div style="
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 300px;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
">
|
||||
<div style="font-size: 3rem; margin-bottom: 1rem;">📊</div>
|
||||
<div style="font-size: 1.1rem; font-weight: 600; margin-bottom: 0.5rem;">차트를 표시할 수 없습니다</div>
|
||||
<div style="font-size: 0.9rem;">${message}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 차트 리사이즈
|
||||
*/
|
||||
resizeCharts() {
|
||||
this.charts.forEach((chart, id) => {
|
||||
try {
|
||||
chart.resize();
|
||||
console.log('📏 차트 리사이즈:', id);
|
||||
} catch (error) {
|
||||
console.warn('⚠️ 차트 리사이즈 실패:', id, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 차트 상태 확인
|
||||
*/
|
||||
getChartStatus() {
|
||||
const status = {};
|
||||
this.charts.forEach((chart, id) => {
|
||||
status[id] = {
|
||||
type: chart.config.type,
|
||||
datasetCount: chart.data.datasets.length,
|
||||
dataPointCount: chart.data.labels ? chart.data.labels.length : 0
|
||||
};
|
||||
});
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 인스턴스 생성
|
||||
window.WorkAnalysisChartRenderer = new WorkAnalysisChartRenderer();
|
||||
|
||||
// 윈도우 리사이즈 이벤트 리스너
|
||||
window.addEventListener('resize', () => {
|
||||
window.WorkAnalysisChartRenderer.resizeCharts();
|
||||
});
|
||||
|
||||
// Export는 브라우저 환경에서 제거됨
|
||||
355
synology_deployment/web-ui/js/work-analysis/data-processor.js
Normal file
355
synology_deployment/web-ui/js/work-analysis/data-processor.js
Normal file
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* Work Analysis Data Processor Module
|
||||
* 작업 분석 데이터 가공 및 변환을 담당하는 모듈
|
||||
*/
|
||||
|
||||
class WorkAnalysisDataProcessor {
|
||||
|
||||
// ========== 유틸리티 함수 ==========
|
||||
|
||||
/**
|
||||
* 주말 여부 확인
|
||||
* @param {string} dateString - 날짜 문자열
|
||||
* @returns {boolean} 주말 여부
|
||||
*/
|
||||
isWeekendDate(dateString) {
|
||||
const date = new Date(dateString);
|
||||
const dayOfWeek = date.getDay();
|
||||
return dayOfWeek === 0 || dayOfWeek === 6; // 일요일(0) 또는 토요일(6)
|
||||
}
|
||||
|
||||
/**
|
||||
* 연차/휴무 프로젝트 여부 확인
|
||||
* @param {string} projectName - 프로젝트명
|
||||
* @returns {boolean} 연차/휴무 여부
|
||||
*/
|
||||
isVacationProject(projectName) {
|
||||
if (!projectName) return false;
|
||||
const vacationKeywords = ['연차', '휴무', '휴가', '병가', '특별휴가'];
|
||||
return vacationKeywords.some(keyword => projectName.includes(keyword));
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 포맷팅 (간단한 형식)
|
||||
* @param {string} dateString - 날짜 문자열
|
||||
* @returns {string} 포맷된 날짜
|
||||
*/
|
||||
formatSimpleDate(dateString) {
|
||||
if (!dateString) return '';
|
||||
return dateString.split('T')[0]; // 시간 부분 제거
|
||||
}
|
||||
|
||||
// ========== 프로젝트 분포 데이터 처리 ==========
|
||||
|
||||
/**
|
||||
* 프로젝트별 데이터 집계
|
||||
* @param {Array} recentWorkData - 최근 작업 데이터
|
||||
* @returns {Object} 집계된 프로젝트 데이터
|
||||
*/
|
||||
aggregateProjectData(recentWorkData) {
|
||||
console.log('📊 프로젝트 데이터 집계 시작');
|
||||
|
||||
if (!recentWorkData || recentWorkData.length === 0) {
|
||||
return { projects: [], totalHours: 0 };
|
||||
}
|
||||
|
||||
const projectMap = new Map();
|
||||
let vacationData = null;
|
||||
|
||||
recentWorkData.forEach(work => {
|
||||
const isWeekend = this.isWeekendDate(work.report_date);
|
||||
const isVacation = this.isVacationProject(work.project_name);
|
||||
|
||||
// 주말 연차는 제외
|
||||
if (isWeekend && isVacation) {
|
||||
console.log('🏖️ 주말 연차/휴무 제외:', work.report_date, work.project_name);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isVacation) {
|
||||
// 연차/휴무 통합 처리
|
||||
if (!vacationData) {
|
||||
vacationData = {
|
||||
project_id: 'vacation',
|
||||
project_name: '연차/휴무',
|
||||
job_no: null,
|
||||
totalHours: 0,
|
||||
workTypes: new Map()
|
||||
};
|
||||
}
|
||||
this._addWorkToProject(vacationData, work, '연차/휴무');
|
||||
} else {
|
||||
// 일반 프로젝트 처리
|
||||
const projectKey = work.project_id || 'unknown';
|
||||
|
||||
if (!projectMap.has(projectKey)) {
|
||||
projectMap.set(projectKey, {
|
||||
project_id: projectKey,
|
||||
project_name: work.project_name || `프로젝트 ${projectKey}`,
|
||||
job_no: work.job_no,
|
||||
totalHours: 0,
|
||||
workTypes: new Map()
|
||||
});
|
||||
}
|
||||
|
||||
const project = projectMap.get(projectKey);
|
||||
this._addWorkToProject(project, work);
|
||||
}
|
||||
});
|
||||
|
||||
// 결과 배열 생성
|
||||
const projects = Array.from(projectMap.values());
|
||||
if (vacationData && vacationData.totalHours > 0) {
|
||||
projects.push(vacationData);
|
||||
}
|
||||
|
||||
// 작업유형을 배열로 변환하고 정렬
|
||||
projects.forEach(project => {
|
||||
project.workTypes = Array.from(project.workTypes.values())
|
||||
.sort((a, b) => b.totalHours - a.totalHours);
|
||||
});
|
||||
|
||||
// 프로젝트를 총 시간 순으로 정렬 (연차/휴무는 맨 아래)
|
||||
projects.sort((a, b) => {
|
||||
if (a.project_id === 'vacation') return 1;
|
||||
if (b.project_id === 'vacation') return -1;
|
||||
return b.totalHours - a.totalHours;
|
||||
});
|
||||
|
||||
const totalHours = projects.reduce((sum, p) => sum + p.totalHours, 0);
|
||||
|
||||
console.log('✅ 프로젝트 데이터 집계 완료:', projects.length, '개 프로젝트');
|
||||
return { projects, totalHours };
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로젝트에 작업 데이터 추가 (내부 헬퍼)
|
||||
*/
|
||||
_addWorkToProject(project, work, overrideWorkTypeName = null) {
|
||||
const hours = parseFloat(work.work_hours) || 0;
|
||||
project.totalHours += hours;
|
||||
|
||||
const workTypeKey = work.work_type_id || 'unknown';
|
||||
const workTypeName = overrideWorkTypeName || work.work_type_name || `작업유형 ${workTypeKey}`;
|
||||
|
||||
if (!project.workTypes.has(workTypeKey)) {
|
||||
project.workTypes.set(workTypeKey, {
|
||||
work_type_id: workTypeKey,
|
||||
work_type_name: workTypeName,
|
||||
totalHours: 0
|
||||
});
|
||||
}
|
||||
|
||||
project.workTypes.get(workTypeKey).totalHours += hours;
|
||||
}
|
||||
|
||||
// ========== 오류 분석 데이터 처리 ==========
|
||||
|
||||
/**
|
||||
* 작업 형태별 오류 데이터 집계
|
||||
* @param {Array} recentWorkData - 최근 작업 데이터
|
||||
* @returns {Array} 집계된 오류 데이터
|
||||
*/
|
||||
aggregateErrorData(recentWorkData) {
|
||||
console.log('📊 오류 분석 데이터 집계 시작');
|
||||
|
||||
if (!recentWorkData || recentWorkData.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const workTypeMap = new Map();
|
||||
let vacationData = null;
|
||||
|
||||
recentWorkData.forEach(work => {
|
||||
const isWeekend = this.isWeekendDate(work.report_date);
|
||||
const isVacation = this.isVacationProject(work.project_name);
|
||||
|
||||
// 주말 연차는 완전히 제외
|
||||
if (isWeekend && isVacation) {
|
||||
console.log('🏖️ 주말 연차/휴무 제외:', work.report_date, work.project_name);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isVacation) {
|
||||
// 모든 연차/휴무를 하나로 통합
|
||||
if (!vacationData) {
|
||||
vacationData = {
|
||||
project_id: 'vacation',
|
||||
project_name: '연차/휴무',
|
||||
job_no: null,
|
||||
work_type_id: 'vacation',
|
||||
work_type_name: '연차/휴무',
|
||||
regularHours: 0,
|
||||
errorHours: 0,
|
||||
errorDetails: new Map(),
|
||||
isVacation: true
|
||||
};
|
||||
}
|
||||
|
||||
this._addWorkToErrorData(vacationData, work);
|
||||
} else {
|
||||
// 일반 프로젝트 처리
|
||||
const workTypeKey = work.work_type_id || 'unknown';
|
||||
const combinedKey = `${work.project_id || 'unknown'}_${workTypeKey}`;
|
||||
|
||||
if (!workTypeMap.has(combinedKey)) {
|
||||
workTypeMap.set(combinedKey, {
|
||||
project_id: work.project_id,
|
||||
project_name: work.project_name || `프로젝트 ${work.project_id}`,
|
||||
job_no: work.job_no,
|
||||
work_type_id: workTypeKey,
|
||||
work_type_name: work.work_type_name || `작업유형 ${workTypeKey}`,
|
||||
regularHours: 0,
|
||||
errorHours: 0,
|
||||
errorDetails: new Map(),
|
||||
isVacation: false
|
||||
});
|
||||
}
|
||||
|
||||
const workTypeData = workTypeMap.get(combinedKey);
|
||||
this._addWorkToErrorData(workTypeData, work);
|
||||
}
|
||||
});
|
||||
|
||||
// 결과 배열 생성
|
||||
const result = Array.from(workTypeMap.values());
|
||||
|
||||
// 연차/휴무 데이터가 있으면 추가
|
||||
if (vacationData && (vacationData.regularHours > 0 || vacationData.errorHours > 0)) {
|
||||
result.push(vacationData);
|
||||
}
|
||||
|
||||
// 최종 데이터 처리
|
||||
const processedResult = result.map(wt => ({
|
||||
...wt,
|
||||
totalHours: wt.regularHours + wt.errorHours,
|
||||
errorRate: wt.regularHours + wt.errorHours > 0 ?
|
||||
((wt.errorHours / (wt.regularHours + wt.errorHours)) * 100).toFixed(1) : '0.0',
|
||||
errorDetails: Array.from(wt.errorDetails.entries()).map(([type, hours]) => ({
|
||||
type, hours
|
||||
}))
|
||||
})).filter(wt => wt.totalHours > 0) // 시간이 있는 것만 표시
|
||||
.sort((a, b) => {
|
||||
// 연차/휴무를 맨 아래로
|
||||
if (a.isVacation && !b.isVacation) return 1;
|
||||
if (!a.isVacation && b.isVacation) return -1;
|
||||
|
||||
// 같은 프로젝트 내에서는 오류 시간 순으로 정렬
|
||||
if (a.project_id === b.project_id) {
|
||||
return b.errorHours - a.errorHours;
|
||||
}
|
||||
|
||||
// 다른 프로젝트는 프로젝트명 순으로 정렬
|
||||
return (a.project_name || '').localeCompare(b.project_name || '');
|
||||
});
|
||||
|
||||
console.log('✅ 오류 분석 데이터 집계 완료:', processedResult.length, '개 항목');
|
||||
return processedResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업 데이터를 오류 분석 데이터에 추가 (내부 헬퍼)
|
||||
*/
|
||||
_addWorkToErrorData(workTypeData, work) {
|
||||
const hours = parseFloat(work.work_hours) || 0;
|
||||
|
||||
if (work.work_status === 'error' || work.error_type_id) {
|
||||
workTypeData.errorHours += hours;
|
||||
|
||||
// 오류 유형별 세분화
|
||||
const errorTypeName = work.error_type_name || work.error_description || '설계미스';
|
||||
if (!workTypeData.errorDetails.has(errorTypeName)) {
|
||||
workTypeData.errorDetails.set(errorTypeName, 0);
|
||||
}
|
||||
workTypeData.errorDetails.set(errorTypeName,
|
||||
workTypeData.errorDetails.get(errorTypeName) + hours
|
||||
);
|
||||
} else {
|
||||
workTypeData.regularHours += hours;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 차트 데이터 처리 ==========
|
||||
|
||||
/**
|
||||
* 시계열 차트 데이터 변환
|
||||
* @param {Array} dailyData - 일별 데이터
|
||||
* @returns {Object} 차트 데이터
|
||||
*/
|
||||
processTimeSeriesData(dailyData) {
|
||||
if (!dailyData || dailyData.length === 0) {
|
||||
return { labels: [], datasets: [] };
|
||||
}
|
||||
|
||||
const labels = dailyData.map(item => this.formatSimpleDate(item.date));
|
||||
const hours = dailyData.map(item => item.total_hours || 0);
|
||||
const workers = dailyData.map(item => item.worker_count || 0);
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: '총 작업시간',
|
||||
data: hours,
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
tension: 0.4
|
||||
},
|
||||
{
|
||||
label: '참여 작업자 수',
|
||||
data: workers,
|
||||
borderColor: '#10b981',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
tension: 0.4,
|
||||
yAxisID: 'y1'
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 도넛 차트 데이터 변환
|
||||
* @param {Array} projectData - 프로젝트 데이터
|
||||
* @returns {Object} 차트 데이터
|
||||
*/
|
||||
processDonutChartData(projectData) {
|
||||
if (!projectData || projectData.length === 0) {
|
||||
return { labels: [], datasets: [] };
|
||||
}
|
||||
|
||||
const labels = projectData.map(item => item.project_name || item.name);
|
||||
const data = projectData.map(item => item.total_hours || item.hours || 0);
|
||||
const colors = this._generateColors(data.length);
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [{
|
||||
data,
|
||||
backgroundColor: colors,
|
||||
borderWidth: 2,
|
||||
borderColor: '#ffffff'
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 색상 생성 헬퍼
|
||||
*/
|
||||
_generateColors(count) {
|
||||
const baseColors = [
|
||||
'#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
|
||||
'#06b6d4', '#84cc16', '#f97316', '#ec4899', '#6366f1'
|
||||
];
|
||||
|
||||
const colors = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
colors.push(baseColors[i % baseColors.length]);
|
||||
}
|
||||
return colors;
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 인스턴스 생성
|
||||
window.WorkAnalysisDataProcessor = new WorkAnalysisDataProcessor();
|
||||
|
||||
// Export는 브라우저 환경에서 제거됨
|
||||
612
synology_deployment/web-ui/js/work-analysis/main-controller.js
Normal file
612
synology_deployment/web-ui/js/work-analysis/main-controller.js
Normal file
@@ -0,0 +1,612 @@
|
||||
/**
|
||||
* Work Analysis Main Controller Module
|
||||
* 작업 분석 페이지의 메인 컨트롤러 - 모든 모듈을 조율하고 사용자 상호작용을 처리
|
||||
*/
|
||||
|
||||
class WorkAnalysisMainController {
|
||||
constructor() {
|
||||
this.api = window.WorkAnalysisAPI;
|
||||
this.state = window.WorkAnalysisState;
|
||||
this.dataProcessor = window.WorkAnalysisDataProcessor;
|
||||
this.tableRenderer = window.WorkAnalysisTableRenderer;
|
||||
this.chartRenderer = window.WorkAnalysisChartRenderer;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* 초기화
|
||||
*/
|
||||
init() {
|
||||
console.log('🚀 작업 분석 메인 컨트롤러 초기화');
|
||||
|
||||
this.setupEventListeners();
|
||||
this.setupStateListeners();
|
||||
this.initializeUI();
|
||||
|
||||
console.log('✅ 작업 분석 메인 컨트롤러 초기화 완료');
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 리스너 설정
|
||||
*/
|
||||
setupEventListeners() {
|
||||
// 기간 확정 버튼
|
||||
const confirmButton = document.getElementById('confirmPeriodBtn');
|
||||
if (confirmButton) {
|
||||
confirmButton.addEventListener('click', () => this.handlePeriodConfirm());
|
||||
}
|
||||
|
||||
// 분석 모드 탭
|
||||
document.querySelectorAll('[data-mode]').forEach(button => {
|
||||
button.addEventListener('click', (e) => {
|
||||
const mode = e.target.dataset.mode;
|
||||
this.handleModeChange(mode);
|
||||
});
|
||||
});
|
||||
|
||||
// 분석 탭 네비게이션
|
||||
document.querySelectorAll('[data-tab]').forEach(button => {
|
||||
button.addEventListener('click', (e) => {
|
||||
const tabId = e.target.dataset.tab;
|
||||
this.handleTabChange(tabId);
|
||||
});
|
||||
});
|
||||
|
||||
// 개별 분석 실행 버튼들
|
||||
this.setupAnalysisButtons();
|
||||
|
||||
// 날짜 입력 필드
|
||||
const startDateInput = document.getElementById('startDate');
|
||||
const endDateInput = document.getElementById('endDate');
|
||||
|
||||
if (startDateInput && endDateInput) {
|
||||
[startDateInput, endDateInput].forEach(input => {
|
||||
input.addEventListener('change', () => this.handleDateChange());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 분석 버튼 설정
|
||||
*/
|
||||
setupAnalysisButtons() {
|
||||
const buttons = [
|
||||
{ selector: 'button[onclick*="analyzeWorkStatus"]', handler: () => this.analyzeWorkStatus() },
|
||||
{ selector: 'button[onclick*="analyzeProjectDistribution"]', handler: () => this.analyzeProjectDistribution() },
|
||||
{ selector: 'button[onclick*="analyzeWorkerPerformance"]', handler: () => this.analyzeWorkerPerformance() },
|
||||
{ selector: 'button[onclick*="analyzeErrorAnalysis"]', handler: () => this.analyzeErrorAnalysis() }
|
||||
];
|
||||
|
||||
buttons.forEach(({ selector, handler }) => {
|
||||
const button = document.querySelector(selector);
|
||||
if (button) {
|
||||
// 기존 onclick 제거하고 새 이벤트 리스너 추가
|
||||
button.removeAttribute('onclick');
|
||||
button.addEventListener('click', handler);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 리스너 설정
|
||||
*/
|
||||
setupStateListeners() {
|
||||
// 기간 확정 상태 변경 시 UI 업데이트
|
||||
this.state.subscribe('periodConfirmed', (newState, prevState) => {
|
||||
this.updateAnalysisButtons(newState.isAnalysisEnabled);
|
||||
|
||||
if (newState.confirmedPeriod.confirmed && !prevState.confirmedPeriod.confirmed) {
|
||||
this.showAnalysisTabs();
|
||||
}
|
||||
});
|
||||
|
||||
// 로딩 상태 변경 시 UI 업데이트
|
||||
this.state.subscribe('loadingState', (newState) => {
|
||||
if (newState.isLoading) {
|
||||
this.showLoading(newState.loadingMessage);
|
||||
} else {
|
||||
this.hideLoading();
|
||||
}
|
||||
});
|
||||
|
||||
// 탭 변경 시 UI 업데이트
|
||||
this.state.subscribe('tabChange', (newState) => {
|
||||
this.updateActiveTab(newState.currentTab);
|
||||
});
|
||||
|
||||
// 에러 발생 시 처리
|
||||
this.state.subscribe('errorOccurred', (newState) => {
|
||||
if (newState.lastError) {
|
||||
this.handleError(newState.lastError);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* UI 초기화
|
||||
*/
|
||||
initializeUI() {
|
||||
// 기본 날짜 설정
|
||||
const currentState = this.state.getState();
|
||||
const startDateInput = document.getElementById('startDate');
|
||||
const endDateInput = document.getElementById('endDate');
|
||||
|
||||
if (startDateInput && currentState.confirmedPeriod.start) {
|
||||
startDateInput.value = currentState.confirmedPeriod.start;
|
||||
}
|
||||
|
||||
if (endDateInput && currentState.confirmedPeriod.end) {
|
||||
endDateInput.value = currentState.confirmedPeriod.end;
|
||||
}
|
||||
|
||||
// 분석 버튼 초기 상태 설정
|
||||
this.updateAnalysisButtons(false);
|
||||
|
||||
// 분석 탭 숨김
|
||||
this.hideAnalysisTabs();
|
||||
}
|
||||
|
||||
// ========== 이벤트 핸들러 ==========
|
||||
|
||||
/**
|
||||
* 기간 확정 처리
|
||||
*/
|
||||
async handlePeriodConfirm() {
|
||||
try {
|
||||
const startDate = document.getElementById('startDate').value;
|
||||
const endDate = document.getElementById('endDate').value;
|
||||
|
||||
console.log('🔄 기간 확정 처리 시작:', startDate, '~', endDate);
|
||||
|
||||
this.state.confirmPeriod(startDate, endDate);
|
||||
|
||||
this.showToast('기간이 확정되었습니다', 'success');
|
||||
|
||||
console.log('✅ 기간 확정 완료 - 각 분석 버튼을 눌러서 데이터를 확인하세요');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 기간 확정 처리 오류:', error);
|
||||
this.state.setError(error);
|
||||
this.showToast(error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 분석 모드 변경 처리
|
||||
*/
|
||||
handleModeChange(mode) {
|
||||
try {
|
||||
this.state.setAnalysisMode(mode);
|
||||
this.updateModeButtons(mode);
|
||||
|
||||
// 캐시 초기화
|
||||
this.state.clearCache();
|
||||
|
||||
} catch (error) {
|
||||
this.state.setError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 탭 변경 처리
|
||||
*/
|
||||
handleTabChange(tabId) {
|
||||
this.state.setCurrentTab(tabId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 변경 처리
|
||||
*/
|
||||
handleDateChange() {
|
||||
// 날짜가 변경되면 기간 확정 상태 해제
|
||||
this.state.updateState({
|
||||
confirmedPeriod: {
|
||||
...this.state.getState().confirmedPeriod,
|
||||
confirmed: false
|
||||
},
|
||||
isAnalysisEnabled: false
|
||||
});
|
||||
|
||||
this.updateAnalysisButtons(false);
|
||||
this.hideAnalysisTabs();
|
||||
}
|
||||
|
||||
// ========== 분석 실행 ==========
|
||||
|
||||
/**
|
||||
* 기본 통계 로드
|
||||
*/
|
||||
async loadBasicStats() {
|
||||
const currentState = this.state.getState();
|
||||
const { start, end } = currentState.confirmedPeriod;
|
||||
|
||||
try {
|
||||
console.log('📊 기본 통계 로딩 시작 - 기간:', start, '~', end);
|
||||
this.state.startLoading('기본 통계를 로딩 중입니다...');
|
||||
|
||||
console.log('🌐 API 호출 전 - getBasicStats 호출...');
|
||||
const statsResponse = await this.api.getBasicStats(start, end);
|
||||
|
||||
console.log('📊 기본 통계 API 응답:', statsResponse);
|
||||
|
||||
if (statsResponse.success && statsResponse.data) {
|
||||
const stats = statsResponse.data;
|
||||
|
||||
// 정상/오류 시간 계산
|
||||
const totalHours = stats.totalHours || 0;
|
||||
const errorReports = stats.errorRate || 0;
|
||||
const errorHours = Math.round(totalHours * (errorReports / 100));
|
||||
const normalHours = totalHours - errorHours;
|
||||
|
||||
const cardData = {
|
||||
totalHours: totalHours,
|
||||
normalHours: normalHours,
|
||||
errorHours: errorHours,
|
||||
workerCount: stats.activeWorkers || stats.activeworkers || 0,
|
||||
errorRate: errorReports
|
||||
};
|
||||
|
||||
this.state.setCache('basicStats', cardData);
|
||||
this.updateResultCards(cardData);
|
||||
|
||||
console.log('✅ 기본 통계 로딩 완료:', cardData);
|
||||
} else {
|
||||
// 기본값으로 카드 업데이트
|
||||
const defaultData = {
|
||||
totalHours: 0,
|
||||
normalHours: 0,
|
||||
errorHours: 0,
|
||||
workerCount: 0,
|
||||
errorRate: 0
|
||||
};
|
||||
this.updateResultCards(defaultData);
|
||||
console.warn('⚠️ 기본 통계 데이터가 없어서 기본값으로 설정');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 기본 통계 로드 실패:', error);
|
||||
// 에러 시에도 기본값으로 카드 업데이트
|
||||
const defaultData = {
|
||||
totalHours: 0,
|
||||
normalHours: 0,
|
||||
errorHours: 0,
|
||||
workerCount: 0,
|
||||
errorRate: 0
|
||||
};
|
||||
this.updateResultCards(defaultData);
|
||||
} finally {
|
||||
this.state.stopLoading();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 기간별 작업 현황 분석
|
||||
*/
|
||||
async analyzeWorkStatus() {
|
||||
if (!this.state.canAnalyze()) {
|
||||
this.showToast('기간을 먼저 확정해주세요', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const currentState = this.state.getState();
|
||||
const { start, end } = currentState.confirmedPeriod;
|
||||
|
||||
try {
|
||||
this.state.startLoading('기간별 작업 현황을 분석 중입니다...');
|
||||
|
||||
// 실제 API 호출
|
||||
const batchData = await this.api.batchCall([
|
||||
{
|
||||
name: 'projectWorkType',
|
||||
method: 'getProjectWorkTypeAnalysis',
|
||||
startDate: start,
|
||||
endDate: end
|
||||
},
|
||||
{
|
||||
name: 'workerStats',
|
||||
method: 'getWorkerStats',
|
||||
startDate: start,
|
||||
endDate: end
|
||||
},
|
||||
{
|
||||
name: 'recentWork',
|
||||
method: 'getRecentWork',
|
||||
startDate: start,
|
||||
endDate: end,
|
||||
limit: 2000
|
||||
}
|
||||
]);
|
||||
|
||||
console.log('🔍 기간별 작업 현황 API 응답:', batchData);
|
||||
|
||||
// 데이터 처리
|
||||
const recentWorkData = batchData.recentWork?.success ? batchData.recentWork.data.data : [];
|
||||
const workerData = batchData.workerStats?.success ? batchData.workerStats.data.data : [];
|
||||
|
||||
const projectData = this.dataProcessor.aggregateProjectData(recentWorkData);
|
||||
|
||||
// 테이블 렌더링
|
||||
this.tableRenderer.renderWorkStatusTable(projectData, workerData, recentWorkData);
|
||||
|
||||
this.showToast('기간별 작업 현황 분석이 완료되었습니다', 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 기간별 작업 현황 분석 오류:', error);
|
||||
this.state.setError(error);
|
||||
this.showToast('기간별 작업 현황 분석에 실패했습니다', 'error');
|
||||
} finally {
|
||||
this.state.stopLoading();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로젝트별 분포 분석
|
||||
*/
|
||||
async analyzeProjectDistribution() {
|
||||
if (!this.state.canAnalyze()) {
|
||||
this.showToast('기간을 먼저 확정해주세요', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const currentState = this.state.getState();
|
||||
const { start, end } = currentState.confirmedPeriod;
|
||||
|
||||
try {
|
||||
this.state.startLoading('프로젝트별 분포를 분석 중입니다...');
|
||||
|
||||
// 실제 API 호출
|
||||
const distributionData = await this.api.getProjectDistributionData(start, end);
|
||||
|
||||
console.log('🔍 프로젝트별 분포 API 응답:', distributionData);
|
||||
|
||||
// 데이터 처리
|
||||
const recentWorkData = distributionData.recentWork?.success ? distributionData.recentWork.data.data : [];
|
||||
const workerData = distributionData.workerStats?.success ? distributionData.workerStats.data.data : [];
|
||||
|
||||
const projectData = this.dataProcessor.aggregateProjectData(recentWorkData);
|
||||
|
||||
console.log('📊 취합된 프로젝트 데이터:', projectData);
|
||||
|
||||
// 테이블 렌더링
|
||||
this.tableRenderer.renderProjectDistributionTable(projectData, workerData);
|
||||
|
||||
this.showToast('프로젝트별 분포 분석이 완료되었습니다', 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 프로젝트별 분포 분석 오류:', error);
|
||||
this.state.setError(error);
|
||||
this.showToast('프로젝트별 분포 분석에 실패했습니다', 'error');
|
||||
} finally {
|
||||
this.state.stopLoading();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업자별 성과 분석
|
||||
*/
|
||||
async analyzeWorkerPerformance() {
|
||||
if (!this.state.canAnalyze()) {
|
||||
this.showToast('기간을 먼저 확정해주세요', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const currentState = this.state.getState();
|
||||
const { start, end } = currentState.confirmedPeriod;
|
||||
|
||||
try {
|
||||
this.state.startLoading('작업자별 성과를 분석 중입니다...');
|
||||
|
||||
const workerStatsResponse = await this.api.getWorkerStats(start, end);
|
||||
|
||||
console.log('👤 작업자 통계 API 응답:', workerStatsResponse);
|
||||
|
||||
if (workerStatsResponse.success && workerStatsResponse.data) {
|
||||
this.chartRenderer.renderWorkerPerformanceChart(workerStatsResponse.data);
|
||||
this.showToast('작업자별 성과 분석이 완료되었습니다', 'success');
|
||||
} else {
|
||||
throw new Error('작업자 데이터를 가져올 수 없습니다');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 작업자별 성과 분석 오류:', error);
|
||||
this.state.setError(error);
|
||||
this.showToast('작업자별 성과 분석에 실패했습니다', 'error');
|
||||
} finally {
|
||||
this.state.stopLoading();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 오류 분석
|
||||
*/
|
||||
async analyzeErrorAnalysis() {
|
||||
if (!this.state.canAnalyze()) {
|
||||
this.showToast('기간을 먼저 확정해주세요', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const currentState = this.state.getState();
|
||||
const { start, end } = currentState.confirmedPeriod;
|
||||
|
||||
try {
|
||||
this.state.startLoading('오류 분석을 진행 중입니다...');
|
||||
|
||||
// 병렬로 API 호출
|
||||
const [recentWorkResponse, errorAnalysisResponse] = await Promise.all([
|
||||
this.api.getRecentWork(start, end, 2000),
|
||||
this.api.getErrorAnalysis(start, end)
|
||||
]);
|
||||
|
||||
console.log('🔍 오류 분석 API 응답:', recentWorkResponse);
|
||||
|
||||
if (recentWorkResponse.success && recentWorkResponse.data) {
|
||||
this.tableRenderer.renderErrorAnalysisTable(recentWorkResponse.data);
|
||||
this.showToast('오류 분석이 완료되었습니다', 'success');
|
||||
} else {
|
||||
throw new Error('작업 데이터를 가져올 수 없습니다');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 오류 분석 실패:', error);
|
||||
this.state.setError(error);
|
||||
this.showToast('오류 분석에 실패했습니다', 'error');
|
||||
} finally {
|
||||
this.state.stopLoading();
|
||||
}
|
||||
}
|
||||
|
||||
// ========== UI 업데이트 ==========
|
||||
|
||||
/**
|
||||
* 결과 카드 업데이트
|
||||
*/
|
||||
updateResultCards(stats) {
|
||||
const cards = {
|
||||
totalHours: stats.totalHours || 0,
|
||||
normalHours: stats.normalHours || 0,
|
||||
errorHours: stats.errorHours || 0,
|
||||
workerCount: stats.activeWorkers || 0,
|
||||
errorRate: stats.errorRate || 0
|
||||
};
|
||||
|
||||
Object.entries(cards).forEach(([key, value]) => {
|
||||
const element = document.getElementById(key);
|
||||
if (element) {
|
||||
element.textContent = typeof value === 'number' ?
|
||||
(key.includes('Rate') ? `${value}%` : value.toLocaleString()) : value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 분석 버튼 상태 업데이트
|
||||
*/
|
||||
updateAnalysisButtons(enabled) {
|
||||
const buttons = document.querySelectorAll('.chart-analyze-btn');
|
||||
buttons.forEach(button => {
|
||||
button.disabled = !enabled;
|
||||
button.style.opacity = enabled ? '1' : '0.5';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 분석 탭 표시
|
||||
*/
|
||||
showAnalysisTabs() {
|
||||
const tabNavigation = document.getElementById('analysisTabNavigation');
|
||||
if (tabNavigation) {
|
||||
tabNavigation.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 분석 탭 숨김
|
||||
*/
|
||||
hideAnalysisTabs() {
|
||||
const tabNavigation = document.getElementById('analysisTabNavigation');
|
||||
if (tabNavigation) {
|
||||
tabNavigation.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 탭 업데이트
|
||||
*/
|
||||
updateActiveTab(tabId) {
|
||||
// 탭 버튼 업데이트
|
||||
document.querySelectorAll('.tab-button').forEach(button => {
|
||||
button.classList.remove('active');
|
||||
if (button.dataset.tab === tabId) {
|
||||
button.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
// 탭 컨텐츠 업데이트
|
||||
document.querySelectorAll('.tab-content').forEach(content => {
|
||||
content.classList.remove('active');
|
||||
if (content.id === `${tabId}-tab`) {
|
||||
content.classList.add('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 모드 버튼 업데이트
|
||||
*/
|
||||
updateModeButtons(mode) {
|
||||
document.querySelectorAll('[data-mode]').forEach(button => {
|
||||
button.classList.remove('active');
|
||||
if (button.dataset.mode === mode) {
|
||||
button.classList.add('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 로딩 표시
|
||||
*/
|
||||
showLoading(message = '분석 중입니다...') {
|
||||
const loadingElement = document.getElementById('loadingState');
|
||||
if (loadingElement) {
|
||||
const textElement = loadingElement.querySelector('.loading-text');
|
||||
if (textElement) {
|
||||
textElement.textContent = message;
|
||||
}
|
||||
loadingElement.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 로딩 숨김
|
||||
*/
|
||||
hideLoading() {
|
||||
const loadingElement = document.getElementById('loadingState');
|
||||
if (loadingElement) {
|
||||
loadingElement.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 토스트 메시지 표시
|
||||
*/
|
||||
showToast(message, type = 'info') {
|
||||
console.log(`📢 ${type.toUpperCase()}: ${message}`);
|
||||
|
||||
// 간단한 토스트 구현 (실제로는 더 정교한 토스트 라이브러리 사용 권장)
|
||||
if (type === 'error') {
|
||||
alert(`❌ ${message}`);
|
||||
} else if (type === 'success') {
|
||||
console.log(`✅ ${message}`);
|
||||
} else if (type === 'warning') {
|
||||
alert(`⚠️ ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 처리
|
||||
*/
|
||||
handleError(errorInfo) {
|
||||
console.error('❌ 에러 발생:', errorInfo);
|
||||
this.showToast(errorInfo.message, 'error');
|
||||
}
|
||||
|
||||
// ========== 유틸리티 ==========
|
||||
|
||||
/**
|
||||
* 컨트롤러 상태 디버그
|
||||
*/
|
||||
debug() {
|
||||
console.log('🔍 메인 컨트롤러 상태:');
|
||||
console.log('- API 클라이언트:', this.api);
|
||||
console.log('- 상태 관리자:', this.state.getState());
|
||||
console.log('- 차트 상태:', this.chartRenderer.getChartStatus());
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 인스턴스 생성 및 초기화
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.WorkAnalysisMainController = new WorkAnalysisMainController();
|
||||
});
|
||||
|
||||
// Export는 브라우저 환경에서 제거됨
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user