해당 서비스 도커화 성공, 룰 추가, 로그인 오류 수정, 소문자 룰 어느정도 해결
This commit is contained in:
1635
fastapi-bridge/static/Minutes/safety.html
Normal file
1635
fastapi-bridge/static/Minutes/safety.html
Normal file
File diff suppressed because it is too large
Load Diff
105
fastapi-bridge/static/Minutes/안전 회의록 백업/2025.06.27.json
Normal file
105
fastapi-bridge/static/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
fastapi-bridge/static/Minutes/인수인계관련/안전회의록 2025년 5월.xlsx
Normal file
BIN
fastapi-bridge/static/Minutes/인수인계관련/안전회의록 2025년 5월.xlsx
Normal file
Binary file not shown.
BIN
fastapi-bridge/static/Minutes/인수인계관련/하주현 선임 인수인계 (안전) 복사본.xlsx
Normal file
BIN
fastapi-bridge/static/Minutes/인수인계관련/하주현 선임 인수인계 (안전) 복사본.xlsx
Normal file
Binary file not shown.
351
fastapi-bridge/static/components/navbar.html
Normal file
351
fastapi-bridge/static/components/navbar.html
Normal file
@@ -0,0 +1,351 @@
|
||||
<!-- 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/settings.html" class="dropdown-item">
|
||||
<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>
|
||||
</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);
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@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 {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,48 @@
|
||||
<!-- ✅ /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>
|
||||
</ul>
|
||||
</section>
|
||||
136
fastapi-bridge/static/components/sidebar.html
Normal file
136
fastapi-bridge/static/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>
|
||||
25
fastapi-bridge/static/css/admin.css
Normal file
25
fastapi-bridge/static/css/admin.css
Normal file
@@ -0,0 +1,25 @@
|
||||
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;
|
||||
}
|
||||
883
fastapi-bridge/static/css/attendance-validation.css
Normal file
883
fastapi-bridge/static/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
fastapi-bridge/static/css/attendance.css
Normal file
72
fastapi-bridge/static/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; }
|
||||
90
fastapi-bridge/static/css/daily-issue.css
Normal file
90
fastapi-bridge/static/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
fastapi-bridge/static/css/daily-report-viewer.css
Normal file
548
fastapi-bridge/static/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;
|
||||
}
|
||||
}
|
||||
756
fastapi-bridge/static/css/daily-work-report.css
Normal file
756
fastapi-bridge/static/css/daily-work-report.css
Normal file
@@ -0,0 +1,756 @@
|
||||
/* daily-work-report.css - 덮어쓰기 방지 완전 스타일 */
|
||||
|
||||
/* 기본 레이아웃 */
|
||||
.daily-work-report-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
/* 단계 표시 */
|
||||
.steps-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 30px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 20px;
|
||||
opacity: 0.5;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.step.active {
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.step.completed {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #e9ecef 0%, #adb5bd 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
color: #495057;
|
||||
margin-right: 10px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.step.active .step-number {
|
||||
background: linear-gradient(135deg, #007bff 0%, #0056b3 100%);
|
||||
color: white;
|
||||
box-shadow: 0 4px 15px rgba(0, 123, 255, 0.3);
|
||||
}
|
||||
|
||||
.step.completed .step-number {
|
||||
background: linear-gradient(135deg, #28a745 0%, #1e7e34 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 🛡️ 보호 모드 표시기 */
|
||||
.protection-indicator {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: linear-gradient(135deg, #27ae60 0%, #229954 100%);
|
||||
color: white;
|
||||
padding: 10px 15px;
|
||||
border-radius: 25px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
box-shadow: 0 2px 10px rgba(39, 174, 96, 0.3);
|
||||
z-index: 1000;
|
||||
border: 2px solid #1e8449;
|
||||
animation: pulse-protection 2s infinite;
|
||||
}
|
||||
|
||||
.protection-indicator::before {
|
||||
content: "🛡️";
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
@keyframes pulse-protection {
|
||||
0%, 100% { box-shadow: 0 2px 10px rgba(39, 174, 96, 0.3); }
|
||||
50% { box-shadow: 0 4px 20px rgba(39, 174, 96, 0.5); }
|
||||
}
|
||||
|
||||
/* 메시지 컨테이너 */
|
||||
.message-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 500;
|
||||
border: 2px solid transparent;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.message.success {
|
||||
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
|
||||
color: white;
|
||||
border-color: #1e7e34;
|
||||
box-shadow: 0 4px 15px rgba(40, 167, 69, 0.3);
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
|
||||
color: white;
|
||||
border-color: #bd2130;
|
||||
box-shadow: 0 4px 15px rgba(220, 53, 69, 0.3);
|
||||
}
|
||||
|
||||
.message.warning {
|
||||
background: linear-gradient(135deg, #f39c12 0%, #e67e22 100%);
|
||||
color: white;
|
||||
border-color: #d68910;
|
||||
box-shadow: 0 4px 15px rgba(243, 156, 18, 0.3);
|
||||
animation: fadeInShake 0.5s ease-out;
|
||||
}
|
||||
|
||||
.message.loading {
|
||||
background: linear-gradient(135deg, #3498db 0%, #2980b9 100%);
|
||||
color: white;
|
||||
border-color: #2471a3;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.message.loading::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent);
|
||||
animation: loading-shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes loading-shimmer {
|
||||
0% { left: -100%; }
|
||||
100% { left: 100%; }
|
||||
}
|
||||
|
||||
@keyframes fadeInShake {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: translateX(5px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 단계별 콘텐츠 */
|
||||
.step-content {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 30px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
border: 2px solid #e9ecef;
|
||||
}
|
||||
|
||||
/* 1단계: 날짜 선택 */
|
||||
.date-selection {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.date-input {
|
||||
padding: 15px;
|
||||
font-size: 18px;
|
||||
border: 2px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
width: 200px;
|
||||
margin: 20px 0;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.date-input:focus {
|
||||
border-color: #007bff;
|
||||
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.25);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* 2단계: 작업자 선택 */
|
||||
.worker-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 15px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.worker-btn {
|
||||
padding: 15px;
|
||||
border: 2px solid #dee2e6;
|
||||
border-radius: 10px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.worker-btn:hover {
|
||||
border-color: #007bff;
|
||||
background: #f8f9fa;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 15px rgba(0, 123, 255, 0.2);
|
||||
}
|
||||
|
||||
.worker-btn.selected {
|
||||
background: linear-gradient(135deg, #007bff 0%, #0056b3 100%);
|
||||
color: white;
|
||||
border-color: #0056b3;
|
||||
box-shadow: 0 4px 15px rgba(0, 123, 255, 0.3);
|
||||
}
|
||||
|
||||
/* 🚫 차단된 작업자 버튼 스타일 */
|
||||
.worker-btn.blocked {
|
||||
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%) !important;
|
||||
color: white !important;
|
||||
border: 2px solid #a93226 !important;
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed !important;
|
||||
position: relative;
|
||||
animation: pulse-blocked 2s infinite;
|
||||
}
|
||||
|
||||
.worker-btn.blocked:hover {
|
||||
transform: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.worker-btn.blocked::after {
|
||||
content: "🚫";
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
border: 2px solid #e74c3c;
|
||||
}
|
||||
|
||||
@keyframes pulse-blocked {
|
||||
0%, 100% { opacity: 0.7; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* 3단계: 작업 입력 */
|
||||
.work-entries-container {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.work-entry {
|
||||
background: #f8f9fa;
|
||||
border: 2px solid #e9ecef;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.work-entry:hover {
|
||||
border-color: #007bff;
|
||||
box-shadow: 0 4px 15px rgba(0, 123, 255, 0.1);
|
||||
}
|
||||
|
||||
.work-entry-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.work-entry-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.remove-work-btn {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.remove-work-btn:hover {
|
||||
background: #c82333;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.work-entry-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
color: #495057;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.large-select {
|
||||
padding: 12px;
|
||||
border: 2px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s ease;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.large-select:focus {
|
||||
border-color: #007bff;
|
||||
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.25);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* 에러 유형 섹션 */
|
||||
.error-type-section {
|
||||
opacity: 0.5;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.error-type-section.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 시간 입력 */
|
||||
.time-input-row {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.quick-time-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.quick-time-btn {
|
||||
padding: 8px 15px;
|
||||
background: #e9ecef;
|
||||
border: 2px solid #dee2e6;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.quick-time-btn:hover {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border-color: #0056b3;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* 총 시간 표시 */
|
||||
.total-hours-display {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin: 20px 0;
|
||||
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
/* 버튼 스타일 */
|
||||
.btn {
|
||||
padding: 12px 25px;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 8px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #007bff 0%, #0056b3 100%);
|
||||
color: white;
|
||||
border-color: #0056b3;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(135deg, #0056b3 0%, #004085 100%);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 15px rgba(0, 123, 255, 0.3);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: linear-gradient(135deg, #28a745 0%, #1e7e34 100%);
|
||||
color: white;
|
||||
border-color: #1e7e34;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: linear-gradient(135deg, #1e7e34 0%, #155724 100%);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 15px rgba(40, 167, 69, 0.3);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* 작업 추가 버튼 */
|
||||
.add-work-container {
|
||||
text-align: center;
|
||||
margin: 30px 0;
|
||||
}
|
||||
|
||||
.add-work-btn {
|
||||
background: linear-gradient(135deg, #17a2b8 0%, #138496 100%);
|
||||
color: white;
|
||||
border: 2px solid #138496;
|
||||
padding: 15px 30px;
|
||||
border-radius: 25px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.add-work-btn:hover {
|
||||
background: linear-gradient(135deg, #138496 0%, #0f6674 100%);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 6px 20px rgba(23, 162, 184, 0.3);
|
||||
}
|
||||
|
||||
/* 저장 버튼 */
|
||||
.submit-container {
|
||||
text-align: center;
|
||||
margin: 40px 0;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
|
||||
color: white;
|
||||
border: 2px solid #1e7e34;
|
||||
padding: 18px 40px;
|
||||
border-radius: 30px;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
background: linear-gradient(135deg, #1e7e34 0%, #17a2b8 100%);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 8px 25px rgba(40, 167, 69, 0.4);
|
||||
}
|
||||
|
||||
/* 오늘 작업자 현황 */
|
||||
.daily-workers-section {
|
||||
margin-top: 40px;
|
||||
padding: 20px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 12px;
|
||||
border: 2px solid #e9ecef;
|
||||
}
|
||||
|
||||
.daily-workers-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border-radius: 10px;
|
||||
border: 2px solid #5a6fd8;
|
||||
}
|
||||
|
||||
.daily-workers-header h4 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
padding: 8px 15px;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.refresh-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* 작업자 현황 그리드 */
|
||||
.worker-status-grid {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.worker-status-item {
|
||||
background: white;
|
||||
border: 2px solid #e9ecef;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.worker-status-item:hover {
|
||||
border-color: #007bff;
|
||||
box-shadow: 0 4px 15px rgba(0, 123, 255, 0.1);
|
||||
}
|
||||
|
||||
.worker-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid #e9ecef;
|
||||
}
|
||||
|
||||
.worker-name {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.worker-total-hours {
|
||||
background: linear-gradient(135deg, #17a2b8 0%, #138496 100%);
|
||||
color: white;
|
||||
padding: 5px 15px;
|
||||
border-radius: 15px;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* 개별 작업 항목 */
|
||||
.individual-works-container {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.individual-work-item {
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||
border: 2px solid #dee2e6;
|
||||
border-radius: 10px;
|
||||
padding: 15px;
|
||||
margin-bottom: 10px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.individual-work-item:hover {
|
||||
background: linear-gradient(135deg, #e9ecef 0%, #dee2e6 100%);
|
||||
border-color: #adb5bd;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.work-details-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
color: #6c757d;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #212529;
|
||||
padding: 8px 12px;
|
||||
background: white;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* 액션 버튼들 */
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.edit-btn, .delete-btn {
|
||||
padding: 8px 15px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
background: linear-gradient(135deg, #3498db 0%, #2980b9 100%);
|
||||
color: white;
|
||||
border: 2px solid #2471a3;
|
||||
}
|
||||
|
||||
.edit-btn:hover {
|
||||
background: linear-gradient(135deg, #2980b9 0%, #2471a3 100%);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 10px rgba(52, 152, 219, 0.3);
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
|
||||
color: white;
|
||||
border: 2px solid #a93226;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: linear-gradient(135deg, #c0392b 0%, #a93226 100%);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 10px rgba(231, 76, 60, 0.3);
|
||||
}
|
||||
|
||||
/* 데이터 없음 메시지 */
|
||||
.no-data-message {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #6c757d;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.no-data-message small {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
color: #adb5bd;
|
||||
}
|
||||
|
||||
/* 로딩 스피너 */
|
||||
.loading-spinner {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
font-size: 16px;
|
||||
color: #007bff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* 반응형 디자인 */
|
||||
@media (max-width: 768px) {
|
||||
.daily-work-report-container {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.work-entry-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.worker-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.work-details-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.daily-workers-header {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.protection-indicator {
|
||||
position: relative;
|
||||
top: auto;
|
||||
right: auto;
|
||||
margin: 10px auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.steps-container {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.step {
|
||||
margin: 10px 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.worker-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.date-input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
61
fastapi-bridge/static/css/factory.css
Normal file
61
fastapi-bridge/static/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
fastapi-bridge/static/css/login.css
Normal file
54
fastapi-bridge/static/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
fastapi-bridge/static/css/main-layout.css
Normal file
160
fastapi-bridge/static/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
fastapi-bridge/static/css/management-dashboard.css
Normal file
1005
fastapi-bridge/static/css/management-dashboard.css
Normal file
File diff suppressed because it is too large
Load Diff
57
fastapi-bridge/static/css/user.css
Normal file
57
fastapi-bridge/static/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;
|
||||
}
|
||||
108
fastapi-bridge/static/css/work-report.css
Normal file
108
fastapi-bridge/static/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
fastapi-bridge/static/css/work-review.css
Normal file
839
fastapi-bridge/static/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;
|
||||
}
|
||||
}
|
||||
394
fastapi-bridge/static/docs/assets/css/style.css
Normal file
394
fastapi-bridge/static/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
fastapi-bridge/static/docs/assets/js/main.js
Normal file
60
fastapi-bridge/static/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
fastapi-bridge/static/docs/hr.html
Normal file
357
fastapi-bridge/static/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
fastapi-bridge/static/docs/hse.html
Normal file
447
fastapi-bridge/static/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
fastapi-bridge/static/docs/hse/TK-HSE-P-410.html
Normal file
1208
fastapi-bridge/static/docs/hse/TK-HSE-P-410.html
Normal file
File diff suppressed because it is too large
Load Diff
1529
fastapi-bridge/static/docs/hse/TK-HSE-P-510.html
Normal file
1529
fastapi-bridge/static/docs/hse/TK-HSE-P-510.html
Normal file
File diff suppressed because it is too large
Load Diff
1826
fastapi-bridge/static/docs/hse/TK-HSE-P-520.html
Normal file
1826
fastapi-bridge/static/docs/hse/TK-HSE-P-520.html
Normal file
File diff suppressed because it is too large
Load Diff
1671
fastapi-bridge/static/docs/hse/TK-HSE-P-610.html
Normal file
1671
fastapi-bridge/static/docs/hse/TK-HSE-P-610.html
Normal file
File diff suppressed because it is too large
Load Diff
1784
fastapi-bridge/static/docs/hse/TK-HSE-P-620.html
Normal file
1784
fastapi-bridge/static/docs/hse/TK-HSE-P-620.html
Normal file
File diff suppressed because it is too large
Load Diff
1894
fastapi-bridge/static/docs/hse/TK-HSE-P-630.html
Normal file
1894
fastapi-bridge/static/docs/hse/TK-HSE-P-630.html
Normal file
File diff suppressed because it is too large
Load Diff
295
fastapi-bridge/static/docs/index.html
Normal file
295
fastapi-bridge/static/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
fastapi-bridge/static/docs/iso45001_bilingual_manual.html
Normal file
1469
fastapi-bridge/static/docs/iso45001_bilingual_manual.html
Normal file
File diff suppressed because it is too large
Load Diff
317
fastapi-bridge/static/docs/policy.html
Normal file
317
fastapi-bridge/static/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
fastapi-bridge/static/docs/quality.html
Normal file
373
fastapi-bridge/static/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
fastapi-bridge/static/docs/technical.html
Normal file
333
fastapi-bridge/static/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
fastapi-bridge/static/img/login-bg.jpeg
Normal file
BIN
fastapi-bridge/static/img/login-bg.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
BIN
fastapi-bridge/static/img/logo.png
Normal file
BIN
fastapi-bridge/static/img/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
fastapi-bridge/static/img/technicalkorea Logo.jpg
Normal file
BIN
fastapi-bridge/static/img/technicalkorea Logo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
28071
fastapi-bridge/static/img/technicalkorea_Logo.ai
Normal file
28071
fastapi-bridge/static/img/technicalkorea_Logo.ai
Normal file
File diff suppressed because it is too large
Load Diff
28071
fastapi-bridge/static/img/technicalkorea_Logo.eps
Normal file
28071
fastapi-bridge/static/img/technicalkorea_Logo.eps
Normal file
File diff suppressed because it is too large
Load Diff
3390
fastapi-bridge/static/img/technicalkorea_Logo.svg
Normal file
3390
fastapi-bridge/static/img/technicalkorea_Logo.svg
Normal file
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 369 KiB |
80
fastapi-bridge/static/index.html
Normal file
80
fastapi-bridge/static/index.html
Normal file
@@ -0,0 +1,80 @@
|
||||
<!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">
|
||||
<style>
|
||||
/* 문서시스템 버튼 스타일 */
|
||||
.docs-section {
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.docs-button {
|
||||
display: inline-block;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
text-decoration: none;
|
||||
padding: 15px 25px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.docs-button:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.docs-title {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 14px;
|
||||
margin-bottom: 15px;
|
||||
font-weight: 400;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.docs-button {
|
||||
padding: 12px 20px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</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 class="docs-section">
|
||||
<div class="docs-title">회사 문서시스템</div>
|
||||
<a href="docs/" class="docs-button">
|
||||
문서관리 시스템
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ✅ 모듈로 지정 (import 쓸 수 있도록) -->
|
||||
<script type="module" src="js/login.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
34
fastapi-bridge/static/js/admin.js
Normal file
34
fastapi-bridge/static/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);
|
||||
143
fastapi-bridge/static/js/api-config.js
Normal file
143
fastapi-bridge/static/js/api-config.js
Normal file
@@ -0,0 +1,143 @@
|
||||
// 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 경로만 추가
|
||||
const baseUrl = port && port !== '80' && port !== '443'
|
||||
? `${protocol}//${hostname}:${port}/api`
|
||||
: `${protocol}//${hostname}/api`;
|
||||
|
||||
console.log('✅ nginx 프록시 사용:', baseUrl);
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
// 🚨 백업: 직접 접근 (nginx 프록시 실패시에만)
|
||||
console.warn('⚠️ 직접 API 접근 (백업 모드)');
|
||||
return `${protocol}//${hostname}:8000/api`;
|
||||
}
|
||||
|
||||
export const API = getApiBaseUrl();
|
||||
|
||||
export function ensureAuthenticated() {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token || token === 'undefined') {
|
||||
alert('로그인이 필요합니다');
|
||||
localStorage.removeItem('token');
|
||||
window.location.href = '/';
|
||||
return null;
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
export function getAuthHeaders() {
|
||||
const token = localStorage.getItem('token');
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
};
|
||||
}
|
||||
|
||||
// 🔧 개선된 API 호출 함수 (에러 처리 강화)
|
||||
export async function apiCall(url, options = {}) {
|
||||
const defaultOptions = {
|
||||
headers: getAuthHeaders()
|
||||
};
|
||||
|
||||
const finalOptions = {
|
||||
...defaultOptions,
|
||||
...options,
|
||||
headers: {
|
||||
...defaultOptions.headers,
|
||||
...options.headers
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
console.log(`📡 API 호출: ${url}`);
|
||||
const response = await fetch(url, finalOptions);
|
||||
|
||||
// 인증 만료 처리
|
||||
if (response.status === 401) {
|
||||
console.error('❌ 인증 만료');
|
||||
localStorage.removeItem('token');
|
||||
alert('인증이 만료되었습니다. 다시 로그인해주세요.');
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
|
||||
// 응답 실패 처리
|
||||
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 성공: ${url}`);
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ API 오류 (${url}):`, 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 연결 테스트 함수 (개발용)
|
||||
export 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;
|
||||
}
|
||||
}
|
||||
|
||||
// 개발 모드에서 자동 테스트
|
||||
if (window.location.hostname === 'localhost' || window.location.hostname.startsWith('192.168.')) {
|
||||
setTimeout(() => {
|
||||
testApiConnection();
|
||||
}, 1000);
|
||||
}
|
||||
115
fastapi-bridge/static/js/api-helper.js
Normal file
115
fastapi-bridge/static/js/api-helper.js
Normal file
@@ -0,0 +1,115 @@
|
||||
// /public/js/api-helper.js
|
||||
|
||||
import { API_BASE_URL } from './api-config.js';
|
||||
import { getToken, clearAuthData } from './auth.js';
|
||||
|
||||
/**
|
||||
* 로그인 API를 호출합니다. (인증이 필요 없는 public 요청)
|
||||
* @param {string} username - 사용자 아이디
|
||||
* @param {string} password - 사용자 비밀번호
|
||||
* @returns {Promise<object>} - API 응답 결과
|
||||
*/
|
||||
export 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 엔드포인트
|
||||
*/
|
||||
export async function apiGet(endpoint) {
|
||||
const response = await authFetch(endpoint);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* POST 요청 헬퍼
|
||||
* @param {string} endpoint - API 엔드포인트
|
||||
* @param {object} data - 전송할 데이터
|
||||
*/
|
||||
export 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 - 전송할 데이터
|
||||
*/
|
||||
export async function apiPut(endpoint, data) {
|
||||
const response = await authFetch(endpoint, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE 요청 헬퍼
|
||||
* @param {string} endpoint - API 엔드포인트
|
||||
*/
|
||||
export async function apiDelete(endpoint) {
|
||||
const response = await authFetch(endpoint, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
1038
fastapi-bridge/static/js/attendance-validation.js
Normal file
1038
fastapi-bridge/static/js/attendance-validation.js
Normal file
File diff suppressed because it is too large
Load Diff
170
fastapi-bridge/static/js/attendance.js
Normal file
170
fastapi-bridge/static/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);
|
||||
27
fastapi-bridge/static/js/auth-check.js
Normal file
27
fastapi-bridge/static/js/auth-check.js
Normal file
@@ -0,0 +1,27 @@
|
||||
// /js/auth-check.js
|
||||
import { isLoggedIn, getUser, clearAuthData } from './auth.js';
|
||||
|
||||
// 즉시 실행 함수로 스코프를 보호하고 로직을 실행
|
||||
(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
fastapi-bridge/static/js/auth.js
Normal file
76
fastapi-bridge/static/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
fastapi-bridge/static/js/calendar.js
Normal file
59
fastapi-bridge/static/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
fastapi-bridge/static/js/change-password.js
Normal file
211
fastapi-bridge/static/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');
|
||||
});
|
||||
66
fastapi-bridge/static/js/daily-issue-api.js
Normal file
66
fastapi-bridge/static/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
fastapi-bridge/static/js/daily-issue-ui.js
Normal file
103
fastapi-bridge/static/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
fastapi-bridge/static/js/daily-issue.js
Normal file
89
fastapi-bridge/static/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
fastapi-bridge/static/js/daily-report-viewer 복사본.js
Normal file
1009
fastapi-bridge/static/js/daily-report-viewer 복사본.js
Normal file
File diff suppressed because it is too large
Load Diff
81
fastapi-bridge/static/js/daily-report-viewer.js
Normal file
81
fastapi-bridge/static/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);
|
||||
897
fastapi-bridge/static/js/daily-work-report.js
Normal file
897
fastapi-bridge/static/js/daily-work-report.js
Normal file
@@ -0,0 +1,897 @@
|
||||
// daily-work-report.js - 통합 API 설정 적용 버전
|
||||
|
||||
// =================================================================
|
||||
// 🌐 통합 API 설정 import
|
||||
// =================================================================
|
||||
import { API, getAuthHeaders, apiCall } from '/js/api-config.js';
|
||||
|
||||
// 전역 변수
|
||||
let workTypes = [];
|
||||
let workStatusTypes = [];
|
||||
let errorTypes = [];
|
||||
let workers = [];
|
||||
let projects = [];
|
||||
let selectedWorkers = new Set();
|
||||
let workEntryCounter = 0;
|
||||
let currentStep = 1;
|
||||
let editingWorkId = null; // 수정 중인 작업 ID
|
||||
|
||||
// 한국 시간 기준 오늘 날짜 가져오기
|
||||
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 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 goToStep(stepNumber) {
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const step = document.getElementById(`step${i}`);
|
||||
if (step) {
|
||||
step.classList.remove('active', 'completed');
|
||||
if (i < stepNumber) {
|
||||
step.classList.add('completed');
|
||||
const stepNum = step.querySelector('.step-number');
|
||||
if (stepNum) stepNum.classList.add('completed');
|
||||
} else if (i === stepNumber) {
|
||||
step.classList.add('active');
|
||||
}
|
||||
}
|
||||
}
|
||||
currentStep = stepNumber;
|
||||
}
|
||||
|
||||
// 초기 데이터 로드 (통합 API 사용)
|
||||
async function loadData() {
|
||||
try {
|
||||
showMessage('데이터를 불러오는 중...', 'loading');
|
||||
|
||||
console.log('🔗 통합 API 설정을 사용한 기본 데이터 로딩 시작...');
|
||||
await loadWorkers();
|
||||
await loadProjects();
|
||||
await loadWorkTypes();
|
||||
await loadWorkStatusTypes();
|
||||
await loadErrorTypes();
|
||||
|
||||
console.log('로드된 작업자 수:', workers.length);
|
||||
console.log('로드된 프로젝트 수:', projects.length);
|
||||
console.log('작업 유형 수:', workTypes.length);
|
||||
|
||||
populateWorkerGrid();
|
||||
hideMessage();
|
||||
|
||||
} catch (error) {
|
||||
console.error('데이터 로드 실패:', error);
|
||||
showMessage('데이터 로드 중 오류가 발생했습니다: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorkers() {
|
||||
try {
|
||||
console.log('Workers API 호출 중... (통합 API 사용)');
|
||||
const data = await apiCall(`${API}/workers`);
|
||||
workers = Array.isArray(data) ? data : (data.workers || []);
|
||||
console.log('✅ Workers 로드 성공:', workers.length);
|
||||
} catch (error) {
|
||||
console.error('작업자 로딩 오류:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
console.log('Projects API 호출 중... (통합 API 사용)');
|
||||
const data = await apiCall(`${API}/projects`);
|
||||
projects = Array.isArray(data) ? data : (data.projects || []);
|
||||
console.log('✅ Projects 로드 성공:', projects.length);
|
||||
} catch (error) {
|
||||
console.error('프로젝트 로딩 오류:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorkTypes() {
|
||||
try {
|
||||
const data = await apiCall(`${API}/daily-work-reports/work-types`);
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
workTypes = data;
|
||||
console.log('✅ 작업 유형 API 사용 (통합 설정)');
|
||||
return;
|
||||
}
|
||||
throw new Error('API 실패');
|
||||
} catch (error) {
|
||||
console.log('⚠️ 작업 유형 API 사용 불가, 기본값 사용');
|
||||
workTypes = [
|
||||
{id: 1, name: 'Base'},
|
||||
{id: 2, name: 'Vessel'},
|
||||
{id: 3, name: 'Piping'}
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorkStatusTypes() {
|
||||
try {
|
||||
const data = await apiCall(`${API}/daily-work-reports/work-status-types`);
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
workStatusTypes = data;
|
||||
console.log('✅ 업무 상태 유형 API 사용 (통합 설정)');
|
||||
return;
|
||||
}
|
||||
throw new Error('API 실패');
|
||||
} catch (error) {
|
||||
console.log('⚠️ 업무 상태 유형 API 사용 불가, 기본값 사용');
|
||||
workStatusTypes = [
|
||||
{id: 1, name: '정규'},
|
||||
{id: 2, name: '에러'}
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadErrorTypes() {
|
||||
try {
|
||||
const data = await apiCall(`${API}/daily-work-reports/error-types`);
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
errorTypes = data;
|
||||
console.log('✅ 에러 유형 API 사용 (통합 설정)');
|
||||
return;
|
||||
}
|
||||
throw new Error('API 실패');
|
||||
} catch (error) {
|
||||
console.log('⚠️ 에러 유형 API 사용 불가, 기본값 사용');
|
||||
errorTypes = [
|
||||
{id: 1, name: '설계미스'},
|
||||
{id: 2, name: '외주작업 불량'},
|
||||
{id: 3, name: '입고지연'},
|
||||
{id: 4, name: '작업 불량'}
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 작업자 그리드 생성
|
||||
function populateWorkerGrid() {
|
||||
const grid = document.getElementById('workerGrid');
|
||||
grid.innerHTML = '';
|
||||
|
||||
workers.forEach(worker => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'worker-btn';
|
||||
btn.textContent = worker.worker_name;
|
||||
btn.dataset.id = worker.worker_id;
|
||||
|
||||
btn.addEventListener('click', () => {
|
||||
toggleWorkerSelection(worker.worker_id, btn);
|
||||
});
|
||||
|
||||
grid.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
// 작업자 선택 토글
|
||||
function toggleWorkerSelection(workerId, btnElement) {
|
||||
if (selectedWorkers.has(workerId)) {
|
||||
selectedWorkers.delete(workerId);
|
||||
btnElement.classList.remove('selected');
|
||||
} else {
|
||||
selectedWorkers.add(workerId);
|
||||
btnElement.classList.add('selected');
|
||||
}
|
||||
|
||||
const nextBtn = document.getElementById('nextStep2');
|
||||
nextBtn.disabled = selectedWorkers.size === 0;
|
||||
}
|
||||
|
||||
// 작업 항목 추가
|
||||
function addWorkEntry() {
|
||||
const container = document.getElementById('workEntriesList');
|
||||
workEntryCounter++;
|
||||
|
||||
const entryDiv = document.createElement('div');
|
||||
entryDiv.className = 'work-entry';
|
||||
entryDiv.dataset.id = workEntryCounter;
|
||||
|
||||
entryDiv.innerHTML = `
|
||||
<div class="work-entry-header">
|
||||
<div class="work-entry-title">작업 ${workEntryCounter}</div>
|
||||
<button type="button" class="remove-work-btn" onclick="removeWorkEntry(${workEntryCounter})">×</button>
|
||||
</div>
|
||||
|
||||
<div class="work-entry-row">
|
||||
<div class="form-group">
|
||||
<label>🏗️ 프로젝트</label>
|
||||
<select class="large-select project-select" required>
|
||||
<option value="">프로젝트 선택</option>
|
||||
${projects.map(p => `<option value="${p.project_id}">${p.project_name}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>⚙️ 작업 유형</label>
|
||||
<select class="large-select work-type-select" required>
|
||||
<option value="">작업 유형 선택</option>
|
||||
${workTypes.map(wt => `<option value="${wt.id}">${wt.name}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="work-entry-row">
|
||||
<div class="form-group">
|
||||
<label>📊 업무 상태</label>
|
||||
<select class="large-select work-status-select" required>
|
||||
<option value="">업무 상태 선택</option>
|
||||
${workStatusTypes.map(ws => `<option value="${ws.id}">${ws.name}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group error-type-section">
|
||||
<label>❌ 에러 유형</label>
|
||||
<select class="large-select error-type-select">
|
||||
<option value="">에러 유형 선택</option>
|
||||
${errorTypes.map(et => `<option value="${et.id}">${et.name}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="time-input-row">
|
||||
<div class="form-group">
|
||||
<label>⏰ 작업 시간</label>
|
||||
<input type="number" class="large-select time-input"
|
||||
placeholder="시간 입력"
|
||||
min="0"
|
||||
max="24"
|
||||
step="0.5"
|
||||
required>
|
||||
<div class="quick-time-buttons">
|
||||
<div class="quick-time-btn" data-hours="1">1시간</div>
|
||||
<div class="quick-time-btn" data-hours="2">2시간</div>
|
||||
<div class="quick-time-btn" data-hours="4">4시간</div>
|
||||
<div class="quick-time-btn" data-hours="8">8시간</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.appendChild(entryDiv);
|
||||
setupWorkEntryEvents(entryDiv);
|
||||
}
|
||||
|
||||
// 작업 항목 이벤트 설정
|
||||
function setupWorkEntryEvents(entryDiv) {
|
||||
const timeInput = entryDiv.querySelector('.time-input');
|
||||
timeInput.addEventListener('input', updateTotalHours);
|
||||
|
||||
entryDiv.querySelectorAll('.quick-time-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
timeInput.value = btn.dataset.hours;
|
||||
updateTotalHours();
|
||||
});
|
||||
});
|
||||
|
||||
const workStatusSelect = entryDiv.querySelector('.work-status-select');
|
||||
const errorTypeSection = entryDiv.querySelector('.error-type-section');
|
||||
|
||||
workStatusSelect.addEventListener('change', (e) => {
|
||||
if (e.target.value === '2') {
|
||||
errorTypeSection.classList.add('visible');
|
||||
errorTypeSection.querySelector('.error-type-select').required = true;
|
||||
} else {
|
||||
errorTypeSection.classList.remove('visible');
|
||||
errorTypeSection.querySelector('.error-type-select').required = false;
|
||||
errorTypeSection.querySelector('.error-type-select').value = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 작업 항목 제거
|
||||
function removeWorkEntry(id) {
|
||||
const entry = document.querySelector(`[data-id="${id}"]`);
|
||||
if (entry) {
|
||||
entry.remove();
|
||||
updateTotalHours();
|
||||
}
|
||||
}
|
||||
|
||||
// 총 시간 업데이트
|
||||
function updateTotalHours() {
|
||||
const timeInputs = document.querySelectorAll('.time-input');
|
||||
let total = 0;
|
||||
|
||||
timeInputs.forEach(input => {
|
||||
const value = parseFloat(input.value) || 0;
|
||||
total += value;
|
||||
});
|
||||
|
||||
const display = document.getElementById('totalHoursDisplay');
|
||||
display.textContent = `총 작업시간: ${total}시간`;
|
||||
|
||||
if (total > 24) {
|
||||
display.style.background = 'linear-gradient(135deg, #e74c3c 0%, #c0392b 100%)';
|
||||
display.textContent += ' ⚠️ 24시간 초과';
|
||||
} else {
|
||||
display.style.background = 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)';
|
||||
}
|
||||
}
|
||||
|
||||
// 저장 함수 (통합 API 사용)
|
||||
async function saveWorkReport() {
|
||||
const reportDate = document.getElementById('reportDate').value;
|
||||
|
||||
if (!reportDate || selectedWorkers.size === 0) {
|
||||
showMessage('날짜와 작업자를 선택해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = document.querySelectorAll('.work-entry');
|
||||
if (entries.length === 0) {
|
||||
showMessage('최소 하나의 작업을 추가해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const newWorkEntries = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const projectId = entry.querySelector('.project-select').value;
|
||||
const workTypeId = entry.querySelector('.work-type-select').value;
|
||||
const workStatusId = entry.querySelector('.work-status-select').value;
|
||||
const errorTypeId = entry.querySelector('.error-type-select').value;
|
||||
const workHours = entry.querySelector('.time-input').value;
|
||||
|
||||
if (!projectId || !workTypeId || !workStatusId || !workHours) {
|
||||
showMessage('모든 작업 항목을 완성해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (workStatusId === '2' && !errorTypeId) {
|
||||
showMessage('에러 상태인 경우 에러 유형을 선택해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
newWorkEntries.push({
|
||||
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)
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = '💾 저장 중...';
|
||||
|
||||
const currentUser = getCurrentUser();
|
||||
let totalSaved = 0;
|
||||
let totalFailed = 0;
|
||||
const failureDetails = [];
|
||||
|
||||
for (const workerId of selectedWorkers) {
|
||||
const requestData = {
|
||||
report_date: reportDate,
|
||||
worker_id: parseInt(workerId),
|
||||
work_entries: newWorkEntries,
|
||||
created_by: currentUser?.user_id || currentUser?.id
|
||||
};
|
||||
|
||||
console.log('전송 데이터 (통합 API 사용):', requestData);
|
||||
|
||||
try {
|
||||
const result = await apiCall(`${API}/daily-work-reports`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestData)
|
||||
});
|
||||
|
||||
console.log('✅ 저장 성공 (통합 API):', result);
|
||||
totalSaved++;
|
||||
} catch (error) {
|
||||
console.error('❌ 저장 실패:', error);
|
||||
totalFailed++;
|
||||
|
||||
const workerName = workers.find(w => w.worker_id == workerId)?.worker_name || '알 수 없음';
|
||||
failureDetails.push(`${workerName}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (totalSaved > 0 && totalFailed === 0) {
|
||||
showMessage(`✅ ${totalSaved}명의 작업보고서가 성공적으로 저장되었습니다!`, 'success');
|
||||
} else if (totalSaved > 0 && totalFailed > 0) {
|
||||
showMessage(`⚠️ ${totalSaved}명 성공, ${totalFailed}명 실패. 실패: ${failureDetails.join(', ')}`, 'warning');
|
||||
} else {
|
||||
showMessage(`❌ 모든 저장이 실패했습니다. 상세: ${failureDetails.join(', ')}`, 'error');
|
||||
}
|
||||
|
||||
if (totalSaved > 0) {
|
||||
setTimeout(() => {
|
||||
refreshTodayWorkers();
|
||||
resetForm();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('저장 오류:', error);
|
||||
showMessage('저장 중 예기치 못한 오류가 발생했습니다: ' + error.message, 'error');
|
||||
} finally {
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = '💾 작업보고서 저장';
|
||||
}
|
||||
}
|
||||
|
||||
// 폼 초기화
|
||||
function resetForm() {
|
||||
goToStep(1);
|
||||
|
||||
selectedWorkers.clear();
|
||||
document.querySelectorAll('.worker-btn.selected').forEach(btn => {
|
||||
btn.classList.remove('selected');
|
||||
});
|
||||
|
||||
const container = document.getElementById('workEntriesList');
|
||||
container.innerHTML = '';
|
||||
|
||||
workEntryCounter = 0;
|
||||
updateTotalHours();
|
||||
|
||||
document.getElementById('nextStep2').disabled = true;
|
||||
}
|
||||
|
||||
// 당일 작업자 현황 로드 (본인 입력분만) - 통합 API 사용
|
||||
async function loadTodayWorkers() {
|
||||
const section = document.getElementById('dailyWorkersSection');
|
||||
const content = document.getElementById('dailyWorkersContent');
|
||||
|
||||
if (!section || !content) {
|
||||
console.log('당일 현황 섹션이 HTML에 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const today = getKoreaToday();
|
||||
const currentUser = getCurrentUser();
|
||||
|
||||
content.innerHTML = '<div class="loading-spinner">📊 내가 입력한 오늘의 작업 현황을 불러오는 중... (통합 API)</div>';
|
||||
section.style.display = 'block';
|
||||
|
||||
// 본인이 입력한 데이터만 조회 (통합 API 사용)
|
||||
let queryParams = `date=${today}`;
|
||||
if (currentUser?.user_id) {
|
||||
queryParams += `&created_by=${currentUser.user_id}`;
|
||||
} else if (currentUser?.id) {
|
||||
queryParams += `&created_by=${currentUser.id}`;
|
||||
}
|
||||
|
||||
console.log(`🔒 본인 입력분만 조회 (통합 API): ${API}/daily-work-reports?${queryParams}`);
|
||||
|
||||
const rawData = await apiCall(`${API}/daily-work-reports?${queryParams}`);
|
||||
console.log('📊 당일 작업 데이터 (통합 API):', rawData);
|
||||
|
||||
let data = [];
|
||||
if (Array.isArray(rawData)) {
|
||||
data = rawData;
|
||||
} else if (rawData?.data) {
|
||||
data = rawData.data;
|
||||
}
|
||||
|
||||
displayMyDailyWorkers(data, today);
|
||||
|
||||
} catch (error) {
|
||||
console.error('당일 작업자 로드 오류:', error);
|
||||
content.innerHTML = `
|
||||
<div class="no-data-message">
|
||||
❌ 오늘의 작업 현황을 불러올 수 없습니다.<br>
|
||||
<small>${error.message}</small>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// 본인 입력 작업자 현황 표시 (수정/삭제 기능 포함)
|
||||
function displayMyDailyWorkers(data, date) {
|
||||
const content = document.getElementById('dailyWorkersContent');
|
||||
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
content.innerHTML = `
|
||||
<div class="no-data-message">
|
||||
📝 내가 오늘(${date}) 입력한 작업이 없습니다.<br>
|
||||
<small>새로운 작업을 추가해보세요!</small>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// 작업자별로 데이터 그룹화
|
||||
const workerGroups = {};
|
||||
data.forEach(work => {
|
||||
const workerName = work.worker_name || '미지정';
|
||||
if (!workerGroups[workerName]) {
|
||||
workerGroups[workerName] = [];
|
||||
}
|
||||
workerGroups[workerName].push(work);
|
||||
});
|
||||
|
||||
const totalWorkers = Object.keys(workerGroups).length;
|
||||
const totalWorks = data.length;
|
||||
|
||||
const headerHtml = `
|
||||
<div class="daily-workers-header">
|
||||
<h4>📊 내가 입력한 오늘(${date}) 작업 현황 - 총 ${totalWorkers}명, ${totalWorks}개 작업</h4>
|
||||
<button class="refresh-btn" onclick="refreshTodayWorkers()">
|
||||
🔄 새로고침
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const workersHtml = Object.entries(workerGroups).map(([workerName, works]) => {
|
||||
const totalHours = works.reduce((sum, work) => {
|
||||
return sum + parseFloat(work.work_hours || 0);
|
||||
}, 0);
|
||||
|
||||
// 개별 작업 항목들 (수정/삭제 버튼 포함)
|
||||
const individualWorksHtml = works.map((work) => {
|
||||
const projectName = work.project_name || '미지정';
|
||||
const workTypeName = work.work_type_name || '미지정';
|
||||
const workStatusName = work.work_status_name || '미지정';
|
||||
const workHours = work.work_hours || 0;
|
||||
const errorTypeName = work.error_type_name || null;
|
||||
const workId = work.id;
|
||||
|
||||
return `
|
||||
<div class="individual-work-item">
|
||||
<div class="work-details-grid">
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">🏗️ 프로젝트</div>
|
||||
<div class="detail-value">${projectName}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">⚙️ 작업종류</div>
|
||||
<div class="detail-value">${workTypeName}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">📊 작업상태</div>
|
||||
<div class="detail-value">${workStatusName}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">⏰ 작업시간</div>
|
||||
<div class="detail-value">${workHours}시간</div>
|
||||
</div>
|
||||
${errorTypeName ? `
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">❌ 에러유형</div>
|
||||
<div class="detail-value">${errorTypeName}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<button class="edit-btn" onclick="editWorkItem('${workId}')">
|
||||
✏️ 수정
|
||||
</button>
|
||||
<button class="delete-btn" onclick="deleteWorkItem('${workId}')">
|
||||
🗑️ 삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="worker-status-item">
|
||||
<div class="worker-header">
|
||||
<div class="worker-name">👤 ${workerName}</div>
|
||||
<div class="worker-total-hours">총 ${totalHours}시간</div>
|
||||
</div>
|
||||
<div class="individual-works-container">
|
||||
${individualWorksHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
content.innerHTML = headerHtml + '<div class="worker-status-grid">' + workersHtml + '</div>';
|
||||
}
|
||||
|
||||
// 작업 항목 수정 함수 (통합 API 사용)
|
||||
async function editWorkItem(workId) {
|
||||
try {
|
||||
console.log('수정할 작업 ID:', workId);
|
||||
|
||||
// 1. 기존 데이터 조회 (통합 API 사용)
|
||||
showMessage('작업 정보를 불러오는 중... (통합 API)', 'loading');
|
||||
|
||||
const workData = await apiCall(`${API}/daily-work-reports/${workId}`);
|
||||
console.log('수정할 작업 데이터 (통합 API):', workData);
|
||||
|
||||
// 2. 수정 모달 표시
|
||||
showEditModal(workData);
|
||||
hideMessage();
|
||||
|
||||
} catch (error) {
|
||||
console.error('작업 정보 조회 오류:', error);
|
||||
showMessage('작업 정보를 불러올 수 없습니다: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 수정 모달 표시
|
||||
function showEditModal(workData) {
|
||||
editingWorkId = workData.id;
|
||||
|
||||
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>
|
||||
${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>
|
||||
${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>
|
||||
${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>
|
||||
${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()">💾 저장</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();
|
||||
}
|
||||
editingWorkId = null;
|
||||
}
|
||||
|
||||
// 수정된 작업 저장 (통합 API 사용)
|
||||
async function saveEditedWork() {
|
||||
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/${editingWorkId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updateData)
|
||||
});
|
||||
|
||||
console.log('✅ 수정 성공 (통합 API):', result);
|
||||
showMessage('✅ 작업이 성공적으로 수정되었습니다!', 'success');
|
||||
|
||||
closeEditModal();
|
||||
refreshTodayWorkers();
|
||||
|
||||
} 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/my-entry/${workId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
console.log('✅ 삭제 성공 (통합 API):', result);
|
||||
showMessage('✅ 작업이 성공적으로 삭제되었습니다!', 'success');
|
||||
|
||||
// 화면 새로고침
|
||||
refreshTodayWorkers();
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 삭제 실패:', error);
|
||||
showMessage('삭제 중 오류가 발생했습니다: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 오늘 현황 새로고침
|
||||
function refreshTodayWorkers() {
|
||||
loadTodayWorkers();
|
||||
}
|
||||
|
||||
// 이벤트 리스너 설정
|
||||
function setupEventListeners() {
|
||||
document.getElementById('nextStep1').addEventListener('click', () => {
|
||||
const dateInput = document.getElementById('reportDate');
|
||||
if (dateInput && dateInput.value) {
|
||||
goToStep(2);
|
||||
} else {
|
||||
showMessage('날짜를 선택해주세요.', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('nextStep2').addEventListener('click', () => {
|
||||
if (selectedWorkers.size > 0) {
|
||||
goToStep(3);
|
||||
addWorkEntry();
|
||||
} else {
|
||||
showMessage('작업자를 선택해주세요.', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('addWorkBtn').addEventListener('click', addWorkEntry);
|
||||
document.getElementById('submitBtn').addEventListener('click', saveWorkReport);
|
||||
}
|
||||
|
||||
// 초기화
|
||||
async function init() {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token || token === 'undefined') {
|
||||
showMessage('로그인이 필요합니다.', 'error');
|
||||
localStorage.removeItem('token');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('reportDate').value = getKoreaToday();
|
||||
|
||||
await loadData();
|
||||
setupEventListeners();
|
||||
loadTodayWorkers();
|
||||
|
||||
console.log('✅ 시스템 초기화 완료 (통합 API 설정 적용)');
|
||||
|
||||
} catch (error) {
|
||||
console.error('초기화 오류:', error);
|
||||
showMessage('초기화 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
|
||||
// 전역 함수로 노출
|
||||
window.removeWorkEntry = removeWorkEntry;
|
||||
window.refreshTodayWorkers = refreshTodayWorkers;
|
||||
window.editWorkItem = editWorkItem;
|
||||
window.deleteWorkItem = deleteWorkItem;
|
||||
window.closeEditModal = closeEditModal;
|
||||
window.saveEditedWork = saveEditedWork;
|
||||
49
fastapi-bridge/static/js/factory-upload.js
Normal file
49
fastapi-bridge/static/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
fastapi-bridge/static/js/factory-view.js
Normal file
38
fastapi-bridge/static/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
fastapi-bridge/static/js/group-leader-dashboard.js
Normal file
103
fastapi-bridge/static/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;
|
||||
144
fastapi-bridge/static/js/load-navbar.js
Normal file
144
fastapi-bridge/static/js/load-navbar.js
Normal file
@@ -0,0 +1,144 @@
|
||||
// js/load-navbar.js
|
||||
import { getUser, clearAuthData } from './auth.js';
|
||||
|
||||
// 역할 이름을 한글로 변환하는 맵
|
||||
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) {
|
||||
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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 네비게이션 바와 관련된 모든 이벤트를 설정합니다.
|
||||
*/
|
||||
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';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 외부 클릭 시 드롭다운 닫기
|
||||
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
fastapi-bridge/static/js/load-sections.js
Normal file
104
fastapi-bridge/static/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
fastapi-bridge/static/js/load-sidebar.js
Normal file
67
fastapi-bridge/static/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>';
|
||||
}
|
||||
});
|
||||
58
fastapi-bridge/static/js/login.js
Normal file
58
fastapi-bridge/static/js/login.js
Normal file
@@ -0,0 +1,58 @@
|
||||
// /js/login.js
|
||||
|
||||
import { login } from './api-helper.js';
|
||||
import { saveAuthData, clearAuthData } from './auth.js';
|
||||
|
||||
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 헬퍼를 통해 로그인 요청
|
||||
const result = await login(username, password);
|
||||
|
||||
if (result.success && result.token) {
|
||||
// 인증 정보 저장
|
||||
saveAuthData(result.token, result.user);
|
||||
|
||||
// 백엔드가 지정한 URL로 리디렉션
|
||||
const redirectUrl = result.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
fastapi-bridge/static/js/manage-issue.js
Normal file
86
fastapi-bridge/static/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
fastapi-bridge/static/js/manage-pipespec.js
Normal file
93
fastapi-bridge/static/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
fastapi-bridge/static/js/manage-project.js
Normal file
108
fastapi-bridge/static/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);
|
||||
104
fastapi-bridge/static/js/manage-task.js
Normal file
104
fastapi-bridge/static/js/manage-task.js
Normal file
@@ -0,0 +1,104 @@
|
||||
// /js/manage-task.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 taskForm = document.getElementById('taskForm');
|
||||
taskForm?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const body = {
|
||||
category: document.getElementById('category').value.trim(),
|
||||
subcategory: document.getElementById('subcategory').value.trim(),
|
||||
task_name: document.getElementById('task_name').value.trim(),
|
||||
description: document.getElementById('description').value.trim()
|
||||
};
|
||||
|
||||
if (!body.category || !body.task_name) {
|
||||
return alert('필수 항목을 입력하세요');
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/tasks`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const result = await res.json();
|
||||
if (res.ok && result.success) {
|
||||
alert('✅ 등록 완료');
|
||||
taskForm.reset();
|
||||
loadTasks();
|
||||
} else {
|
||||
alert('❌ 실패: ' + (result.error || '알 수 없는 오류'));
|
||||
}
|
||||
} catch (err) {
|
||||
alert('🚨 서버 오류: ' + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
async function loadTasks() {
|
||||
const tbody = document.getElementById('taskTableBody');
|
||||
tbody.innerHTML = '<tr><td colspan="6">불러오는 중...</td></tr>';
|
||||
try {
|
||||
const res = await fetch(`${API}/tasks`, {
|
||||
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, [
|
||||
'task_id', 'category', 'subcategory', 'task_name', 'description'
|
||||
], async t => {
|
||||
if (!confirm('삭제하시겠습니까?')) return;
|
||||
try {
|
||||
const delRes = await fetch(`${API}/tasks/${t.task_id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
if (delRes.ok) {
|
||||
alert('✅ 삭제 완료');
|
||||
loadTasks();
|
||||
} else {
|
||||
alert('❌ 삭제 실패');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('🚨 삭제 중 오류: ' + err.message);
|
||||
}
|
||||
});
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
} else {
|
||||
tbody.innerHTML = '<tr><td colspan="6">데이터 형식 오류</td></tr>';
|
||||
}
|
||||
} catch (err) {
|
||||
tbody.innerHTML = '<tr><td colspan="6">로드 실패: ' + err.message + '</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('DOMContentLoaded', loadTasks);
|
||||
288
fastapi-bridge/static/js/manage-user.js
Normal file
288
fastapi-bridge/static/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();
|
||||
});
|
||||
110
fastapi-bridge/static/js/manage-worker.js
Normal file
110
fastapi-bridge/static/js/manage-worker.js
Normal file
@@ -0,0 +1,110 @@
|
||||
// /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 list = await res.json();
|
||||
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
fastapi-bridge/static/js/management-dashboard.js
Normal file
954
fastapi-bridge/static/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.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;
|
||||
122
fastapi-bridge/static/js/my-profile.js
Normal file
122
fastapi-bridge/static/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
fastapi-bridge/static/js/project-analysis-api.js
Normal file
37
fastapi-bridge/static/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
fastapi-bridge/static/js/project-analysis-ui.js
Normal file
170
fastapi-bridge/static/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
fastapi-bridge/static/js/project-analysis.js
Normal file
106
fastapi-bridge/static/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);
|
||||
91
fastapi-bridge/static/js/report-viewer-api.js
Normal file
91
fastapi-bridge/static/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
fastapi-bridge/static/js/report-viewer-export.js
Normal file
72
fastapi-bridge/static/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
fastapi-bridge/static/js/report-viewer-ui.js
Normal file
144
fastapi-bridge/static/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');
|
||||
}
|
||||
93
fastapi-bridge/static/js/user-dashboard.js
Normal file
93
fastapi-bridge/static/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);
|
||||
46
fastapi-bridge/static/js/work-report-api.js
Normal file
46
fastapi-bridge/static/js/work-report-api.js
Normal file
@@ -0,0 +1,46 @@
|
||||
// /js/work-report-api.js
|
||||
import { apiGet, apiPost } from './api-helper.js';
|
||||
|
||||
/**
|
||||
* 작업 보고서 작성을 위해 필요한 초기 데이터(작업자, 프로젝트, 태스크)를 가져옵니다.
|
||||
* Promise.all을 사용하여 병렬로 API를 호출합니다.
|
||||
* @returns {Promise<{workers: Array, projects: Array, tasks: Array}>}
|
||||
*/
|
||||
export async function getInitialData() {
|
||||
try {
|
||||
const [workers, projects, tasks] = await Promise.all([
|
||||
apiGet('/workers'),
|
||||
apiGet('/projects'),
|
||||
apiGet('/tasks')
|
||||
]);
|
||||
|
||||
// 데이터 형식 검증
|
||||
if (!Array.isArray(workers) || !Array.isArray(projects) || !Array.isArray(tasks)) {
|
||||
throw new Error('서버에서 받은 데이터 형식이 올바르지 않습니다.');
|
||||
}
|
||||
|
||||
// 작업자 목록은 ID 기준으로 정렬
|
||||
workers.sort((a, b) => a.worker_id - b.worker_id);
|
||||
|
||||
return { workers, projects, tasks };
|
||||
} catch (error) {
|
||||
console.error('초기 데이터 로딩 중 오류 발생:', error);
|
||||
// 에러를 다시 던져서 호출한 쪽에서 처리할 수 있도록 함
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 작성된 작업 보고서 데이터를 서버에 전송합니다.
|
||||
* @param {Array<object>} reportData - 전송할 작업 보고서 데이터 배열
|
||||
* @returns {Promise<object>} - 서버의 응답 결과
|
||||
*/
|
||||
export async function createWorkReport(reportData) {
|
||||
try {
|
||||
const result = await apiPost('/workreports', reportData);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('작업 보고서 생성 요청 실패:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
79
fastapi-bridge/static/js/work-report-create.js
Normal file
79
fastapi-bridge/static/js/work-report-create.js
Normal file
@@ -0,0 +1,79 @@
|
||||
// /js/work-report-create.js
|
||||
import { renderCalendar } from './calendar.js';
|
||||
import { getInitialData, createWorkReport } from './work-report-api.js';
|
||||
import { initializeReportTable, getReportData } from './work-report-ui.js';
|
||||
|
||||
// 전역 상태 변수
|
||||
let selectedDate = '';
|
||||
|
||||
/**
|
||||
* 날짜가 선택되었을 때 실행되는 콜백 함수.
|
||||
* 초기 데이터를 로드하고 테이블을 렌더링합니다.
|
||||
* @param {string} date - 선택된 날짜 (YYYY-MM-DD 형식)
|
||||
*/
|
||||
async function onDateSelect(date) {
|
||||
selectedDate = date;
|
||||
const tableBody = document.getElementById('reportBody');
|
||||
tableBody.innerHTML = '<tr><td colspan="8" class="text-center">데이터를 불러오는 중...</td></tr>';
|
||||
|
||||
try {
|
||||
const initialData = await getInitialData();
|
||||
initializeReportTable(initialData);
|
||||
} catch (error) {
|
||||
alert('데이터를 불러오는 데 실패했습니다: ' + error.message);
|
||||
tableBody.innerHTML = '<tr><td colspan="8" class="text-center error">오류 발생! 데이터를 불러올 수 없습니다.</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* '전체 등록' 버튼 클릭 시 실행되는 이벤트 핸들러.
|
||||
* 폼 데이터를 서버에 전송합니다.
|
||||
*/
|
||||
async function handleSubmit() {
|
||||
if (!selectedDate) {
|
||||
alert('먼저 달력에서 날짜를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const reportData = getReportData();
|
||||
if (!reportData) {
|
||||
// getReportData 내부에서 이미 alert으로 사용자에게 알림
|
||||
return;
|
||||
}
|
||||
|
||||
// 각 항목에 선택된 날짜 추가
|
||||
const payload = reportData.map(item => ({ ...item, date: selectedDate }));
|
||||
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = '등록 중...';
|
||||
|
||||
try {
|
||||
const result = await createWorkReport(payload);
|
||||
if (result.success) {
|
||||
alert('✅ 작업 보고서가 성공적으로 등록되었습니다!');
|
||||
// 성공 후 폼을 다시 로드하거나, 다른 페이지로 이동 등의 로직 추가 가능
|
||||
onDateSelect(selectedDate); // 현재 날짜의 폼을 다시 로드
|
||||
} else {
|
||||
throw new Error(result.error || '알 수 없는 오류로 등록에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('❌ 등록 실패: ' + error.message);
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = '전체 등록';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 초기화 함수
|
||||
*/
|
||||
function initializePage() {
|
||||
renderCalendar('calendar', onDateSelect);
|
||||
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
submitBtn.addEventListener('click', handleSubmit);
|
||||
}
|
||||
|
||||
// DOM이 로드되면 페이지 초기화를 시작합니다.
|
||||
document.addEventListener('DOMContentLoaded', initializePage);
|
||||
210
fastapi-bridge/static/js/work-report-manage.js
Normal file
210
fastapi-bridge/static/js/work-report-manage.js
Normal file
@@ -0,0 +1,210 @@
|
||||
import { renderCalendar } from '/js/calendar.js';
|
||||
import { API, getAuthHeaders, ensureAuthenticated } from '/js/api-config.js';
|
||||
|
||||
// 인증 확인
|
||||
ensureAuthenticated();
|
||||
|
||||
const calendarEl = document.getElementById('calendar');
|
||||
const reportBody = document.getElementById('reportBody');
|
||||
let selectedDate = '';
|
||||
|
||||
// 캘린더 렌더링
|
||||
renderCalendar('calendar', (dateStr) => {
|
||||
selectedDate = dateStr;
|
||||
loadReports();
|
||||
});
|
||||
|
||||
// 보고서 로딩
|
||||
async function loadReports() {
|
||||
if (!selectedDate) return;
|
||||
reportBody.innerHTML = '<tr><td colspan="8">불러오는 중...</td></tr>';
|
||||
|
||||
try {
|
||||
const [wRes, pRes, tRes, rRes] = await Promise.all([
|
||||
fetch(`${API}/workers`, { headers: getAuthHeaders() }),
|
||||
fetch(`${API}/projects`, { headers: getAuthHeaders() }),
|
||||
fetch(`${API}/tasks`, { headers: getAuthHeaders() }),
|
||||
fetch(`${API}/workreports?start=${selectedDate}&end=${selectedDate}`, { headers: getAuthHeaders() })
|
||||
]);
|
||||
|
||||
if (![wRes, pRes, tRes, rRes].every(res => res.ok)) throw new Error('불러오기 실패');
|
||||
|
||||
const [workers, projects, tasks, reports] = await Promise.all([
|
||||
wRes.json(), pRes.json(), tRes.json(), rRes.json()
|
||||
]);
|
||||
|
||||
// 배열 체크
|
||||
if (!Array.isArray(workers) || !Array.isArray(projects) || !Array.isArray(tasks) || !Array.isArray(reports)) {
|
||||
throw new Error('잘못된 데이터 형식');
|
||||
}
|
||||
|
||||
if (!reports.length) {
|
||||
reportBody.innerHTML = '<tr><td colspan="8">등록된 보고서가 없습니다.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
const nameMap = Object.fromEntries(workers.map(w => [w.worker_id, w.worker_name]));
|
||||
const projMap = Object.fromEntries(projects.map(p => [p.project_id, p.project_name]));
|
||||
const taskMap = Object.fromEntries(tasks.map(t => [t.task_id, `${t.category}:${t.subcategory}`]));
|
||||
|
||||
reportBody.innerHTML = '';
|
||||
reports.forEach((r, i) => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${i + 1}</td>
|
||||
<td>${nameMap[r.worker_id] || r.worker_id}</td>
|
||||
<td><select data-id="project">
|
||||
${projects.map(p =>
|
||||
`<option value="${p.project_id}" ${p.project_id === r.project_id ? 'selected' : ''}>${p.project_name}</option>`
|
||||
).join('')}</select></td>
|
||||
<td><select data-id="task">
|
||||
${tasks.map(t =>
|
||||
`<option value="${t.task_id}" ${t.task_id === r.task_id ? 'selected' : ''}>${t.category}:${t.subcategory}</option>`
|
||||
).join('')}</select></td>
|
||||
<td><input type="number" min="0" step="0.5" value="${r.overtime_hours || ''}" data-id="overtime"></td>
|
||||
<td><select data-id="work_details">
|
||||
${['근무', '연차', '유급', '반차', '반반차', '조퇴', '휴무'].map(opt =>
|
||||
`<option value="${opt}" ${r.work_details === opt ? 'selected' : ''}>${opt}</option>`
|
||||
).join('')}</select></td>
|
||||
<td><input type="text" value="${r.memo || ''}" data-id="memo"></td>
|
||||
<td>
|
||||
<button class="action-btn save-btn">저장</button>
|
||||
<button class="action-btn delete-btn">삭제</button>
|
||||
</td>`;
|
||||
|
||||
// 저장 버튼
|
||||
tr.querySelector('.save-btn').onclick = async () => {
|
||||
// 입력값 검증
|
||||
const projectId = tr.querySelector('[data-id="project"]').value;
|
||||
const taskId = tr.querySelector('[data-id="task"]').value;
|
||||
const overtimeHours = tr.querySelector('[data-id="overtime"]').value;
|
||||
|
||||
if (!projectId || !taskId) {
|
||||
alert('❌ 프로젝트와 작업을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 날짜 형식 처리 - MySQL DATE 형식으로 변환
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return selectedDate;
|
||||
|
||||
// 이미 YYYY-MM-DD 형식인지 확인
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
|
||||
return dateStr;
|
||||
}
|
||||
|
||||
// ISO 형식이나 다른 형식을 YYYY-MM-DD로 변환
|
||||
const date = new Date(dateStr);
|
||||
if (isNaN(date.getTime())) {
|
||||
return selectedDate; // 잘못된 날짜면 선택된 날짜 사용
|
||||
}
|
||||
|
||||
return date.toISOString().split('T')[0]; // YYYY-MM-DD 형식으로 변환
|
||||
};
|
||||
|
||||
const payload = {
|
||||
date: formatDate(r.date), // 날짜 형식 변환
|
||||
worker_id: r.worker_id, // 기존 작업자 ID 유지
|
||||
project_id: Number(projectId),
|
||||
task_id: Number(taskId),
|
||||
overtime_hours: overtimeHours ? Number(overtimeHours) : null,
|
||||
work_details: tr.querySelector('[data-id="work_details"]').value,
|
||||
memo: tr.querySelector('[data-id="memo"]').value.trim() || null
|
||||
};
|
||||
|
||||
// 저장 버튼 상태 변경 (로딩 중)
|
||||
const saveBtn = tr.querySelector('.save-btn');
|
||||
const originalText = saveBtn.textContent;
|
||||
const originalColor = saveBtn.style.backgroundColor;
|
||||
|
||||
saveBtn.textContent = '저장 중...';
|
||||
saveBtn.style.backgroundColor = '#ffc107';
|
||||
saveBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/workreports/${r.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
...getAuthHeaders(),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const result = await res.json();
|
||||
|
||||
if (res.ok && result.success) {
|
||||
// 성공 상태 표시
|
||||
saveBtn.textContent = '✅ 완료';
|
||||
saveBtn.style.backgroundColor = '#28a745';
|
||||
saveBtn.style.color = 'white';
|
||||
|
||||
setTimeout(() => {
|
||||
saveBtn.textContent = originalText;
|
||||
saveBtn.style.backgroundColor = originalColor;
|
||||
saveBtn.style.color = '';
|
||||
saveBtn.disabled = false;
|
||||
}, 2000);
|
||||
|
||||
// alert 대신 조용한 알림
|
||||
console.log('저장 완료:', result);
|
||||
} else {
|
||||
console.error('저장 실패:', result);
|
||||
alert(`❌ 저장 실패: ${result.error || result.message || '알 수 없는 오류'}`);
|
||||
|
||||
// 실패 시 버튼 복원
|
||||
saveBtn.textContent = originalText;
|
||||
saveBtn.style.backgroundColor = originalColor;
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('저장 요청 에러:', err);
|
||||
alert('❌ 저장 요청 실패: ' + err.message);
|
||||
|
||||
// 에러 시 버튼 복원
|
||||
saveBtn.textContent = originalText;
|
||||
saveBtn.style.backgroundColor = originalColor;
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제 버튼
|
||||
tr.querySelector('.delete-btn').onclick = async () => {
|
||||
if (!confirm('정말 삭제하시겠습니까?')) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/workreports/${r.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
tr.remove();
|
||||
// 행 번호 다시 매기기
|
||||
updateRowNumbers();
|
||||
alert('✅ 삭제 완료');
|
||||
} else {
|
||||
const result = await res.json();
|
||||
alert(`❌ 삭제 실패: ${result.error || result.message || '알 수 없는 오류'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('삭제 요청 에러:', err);
|
||||
alert('❌ 삭제 요청 실패: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
reportBody.appendChild(tr);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('데이터 로딩 에러:', err);
|
||||
reportBody.innerHTML = '<tr><td colspan="8">❌ 불러오기 실패: ' + err.message + '</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
// 행 번호 다시 매기기
|
||||
function updateRowNumbers() {
|
||||
reportBody.querySelectorAll('tr').forEach((tr, i) => {
|
||||
const firstTd = tr.querySelector('td:first-child');
|
||||
if (firstTd) firstTd.textContent = i + 1;
|
||||
});
|
||||
}
|
||||
1055
fastapi-bridge/static/js/work-report-review.js
Normal file
1055
fastapi-bridge/static/js/work-report-review.js
Normal file
File diff suppressed because it is too large
Load Diff
141
fastapi-bridge/static/js/work-report-ui.js
Normal file
141
fastapi-bridge/static/js/work-report-ui.js
Normal file
@@ -0,0 +1,141 @@
|
||||
// /js/work-report-ui.js
|
||||
|
||||
const DEFAULT_PROJECT_ID = '13'; // 나중에는 API나 설정에서 받아오는 것이 좋음
|
||||
const DEFAULT_TASK_ID = '15';
|
||||
|
||||
/**
|
||||
* 주어진 데이터를 바탕으로 <select> 요소의 <option>들을 생성합니다.
|
||||
* @param {Array<object>} items - 옵션으로 만들 데이터 배열
|
||||
* @param {string} valueField - <option>의 value 속성에 사용할 필드 이름
|
||||
* @param {string} textField - <option>의 텍스트에 사용할 필드 이름
|
||||
* @returns {string} - 생성된 HTML 옵션 문자열
|
||||
*/
|
||||
function createOptions(items, valueField, textField) {
|
||||
return items.map(item => `<option value="${item[valueField]}">${textField(item)}</option>`).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블의 모든 행 번호를 다시 매깁니다.
|
||||
* @param {HTMLTableSectionElement} tableBody - tbody 요소
|
||||
*/
|
||||
function updateRowNumbers(tableBody) {
|
||||
tableBody.querySelectorAll('tr').forEach((tr, index) => {
|
||||
tr.cells[0].textContent = index + 1;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 하나의 작업 보고서 행(tr)을 생성합니다.
|
||||
* @param {object} worker - 작업자 정보
|
||||
* @param {Array} projects - 전체 프로젝트 목록
|
||||
* @param {Array} tasks - 전체 태스크 목록
|
||||
* @param {number} index - 행 번호
|
||||
* @returns {HTMLTableRowElement} - 생성된 tr 요소
|
||||
*/
|
||||
function createReportRow(worker, projects, tasks, index) {
|
||||
const tr = document.createElement('tr');
|
||||
|
||||
tr.innerHTML = `
|
||||
<td>${index + 1}</td>
|
||||
<td>
|
||||
<input type="hidden" name="worker_id" value="${worker.worker_id}">
|
||||
${worker.worker_name}
|
||||
</td>
|
||||
<td><select name="project_id">${createOptions(projects, 'project_id', p => p.project_name)}</select></td>
|
||||
<td><select name="task_id">${createOptions(tasks, 'task_id', t => `${t.category}:${t.subcategory}`)}</select></td>
|
||||
<td>
|
||||
<select name="overtime">
|
||||
<option value="">없음</option>
|
||||
${[1, 2, 3, 4].map(n => `<option>${n}</option>`).join('')}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<select name="work_type">
|
||||
${['근무', '연차', '유급', '반차', '반반차', '조퇴', '휴무'].map(t => `<option>${t}</option>`).join('')}
|
||||
</select>
|
||||
</td>
|
||||
<td><input type="text" name="memo" placeholder="메모"></td>
|
||||
<td><button type="button" class="remove-btn">x</button></td>
|
||||
`;
|
||||
|
||||
// 이벤트 리스너 설정
|
||||
const workTypeSelect = tr.querySelector('[name="work_type"]');
|
||||
const projectSelect = tr.querySelector('[name="project_id"]');
|
||||
const taskSelect = tr.querySelector('[name="task_id"]');
|
||||
|
||||
workTypeSelect.addEventListener('change', () => {
|
||||
const isDisabled = ['연차', '휴무', '유급'].includes(workTypeSelect.value);
|
||||
projectSelect.disabled = isDisabled;
|
||||
taskSelect.disabled = isDisabled;
|
||||
if (isDisabled) {
|
||||
projectSelect.value = DEFAULT_PROJECT_ID;
|
||||
taskSelect.value = DEFAULT_TASK_ID;
|
||||
}
|
||||
});
|
||||
|
||||
tr.querySelector('.remove-btn').addEventListener('click', () => {
|
||||
tr.remove();
|
||||
updateRowNumbers(tr.parentElement);
|
||||
});
|
||||
|
||||
return tr;
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업 보고서 테이블을 초기화하고 데이터를 채웁니다.
|
||||
* @param {{workers: Array, projects: Array, tasks: Array}} initialData - 초기 데이터
|
||||
*/
|
||||
export function initializeReportTable(initialData) {
|
||||
const tableBody = document.getElementById('reportBody');
|
||||
if (!tableBody) return;
|
||||
|
||||
tableBody.innerHTML = ''; // 기존 내용 초기화
|
||||
const { workers, projects, tasks } = initialData;
|
||||
|
||||
if (!workers || workers.length === 0) {
|
||||
tableBody.innerHTML = '<tr><td colspan="8" class="text-center">등록할 작업자 정보가 없습니다.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
workers.forEach((worker, index) => {
|
||||
const row = createReportRow(worker, projects, tasks, index);
|
||||
tableBody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블에서 폼 데이터를 추출하여 배열로 반환합니다.
|
||||
* @returns {Array<object>|null} - 추출된 데이터 배열 또는 유효성 검사 실패 시 null
|
||||
*/
|
||||
export function getReportData() {
|
||||
const tableBody = document.getElementById('reportBody');
|
||||
const rows = tableBody.querySelectorAll('tr');
|
||||
|
||||
if (rows.length === 0 || (rows.length === 1 && rows[0].cells.length < 2)) {
|
||||
alert('등록할 내용이 없습니다.');
|
||||
return null;
|
||||
}
|
||||
|
||||
const reportData = [];
|
||||
const workerIds = new Set();
|
||||
|
||||
for (const tr of rows) {
|
||||
const workerId = tr.querySelector('[name="worker_id"]').value;
|
||||
if (workerIds.has(workerId)) {
|
||||
alert(`오류: 작업자 '${tr.cells[1].textContent.trim()}'가 중복 등록되었습니다.`);
|
||||
return null;
|
||||
}
|
||||
workerIds.add(workerId);
|
||||
|
||||
reportData.push({
|
||||
worker_id: workerId,
|
||||
project_id: tr.querySelector('[name="project_id"]').value,
|
||||
task_id: tr.querySelector('[name="task_id"]').value,
|
||||
overtime_hours: tr.querySelector('[name="overtime"]').value || 0,
|
||||
work_details: tr.querySelector('[name="work_type"]').value,
|
||||
memo: tr.querySelector('[name="memo"]').value
|
||||
});
|
||||
}
|
||||
|
||||
return reportData;
|
||||
}
|
||||
776
fastapi-bridge/static/js/work-review.js
Normal file
776
fastapi-bridge/static/js/work-review.js
Normal file
@@ -0,0 +1,776 @@
|
||||
// work-review.js - 통합 API 설정 적용 버전
|
||||
|
||||
// =================================================================
|
||||
// 🌐 통합 API 설정 import
|
||||
// =================================================================
|
||||
import { API, getAuthHeaders, apiCall } from '/js/api-config.js';
|
||||
|
||||
// 전역 변수
|
||||
let currentDate = new Date();
|
||||
let selectedDate = null;
|
||||
let selectedDateData = null;
|
||||
let basicData = {
|
||||
workTypes: [],
|
||||
workStatusTypes: [],
|
||||
errorTypes: [],
|
||||
projects: []
|
||||
};
|
||||
|
||||
// 현재 사용자 정보 가져오기
|
||||
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));
|
||||
return payload;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('토큰에서 사용자 정보 추출 실패:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 메시지 표시
|
||||
function showMessage(message, type = 'info') {
|
||||
const container = document.getElementById('message-container');
|
||||
container.innerHTML = `<div class="message ${type}">${message}</div>`;
|
||||
|
||||
if (type !== 'loading') {
|
||||
setTimeout(() => {
|
||||
container.innerHTML = '';
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// 날짜 포맷팅
|
||||
function formatDate(date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// 월 표시 업데이트
|
||||
function updateMonthDisplay() {
|
||||
const monthElement = document.getElementById('currentMonth');
|
||||
const year = currentDate.getFullYear();
|
||||
const month = currentDate.getMonth() + 1;
|
||||
monthElement.textContent = `${year}년 ${month}월`;
|
||||
}
|
||||
|
||||
// 근무 유형 분류
|
||||
function classifyWorkType(totalHours) {
|
||||
if (totalHours === 0) return { type: 'vacation', label: '휴무' };
|
||||
if (totalHours === 2) return { type: 'vacation', label: '조퇴' };
|
||||
if (totalHours === 4) return { type: 'vacation', label: '반차' };
|
||||
if (totalHours === 6) return { type: 'vacation', label: '반반차' };
|
||||
if (totalHours === 8) return { type: 'normal-work', label: '정시근무' };
|
||||
if (totalHours > 8) return { type: 'overtime', label: '잔업' };
|
||||
return { type: 'vacation', label: '기타' };
|
||||
}
|
||||
|
||||
// 캘린더 렌더링 (데이터 로드 없이)
|
||||
function renderCalendar() {
|
||||
const calendar = document.getElementById('calendar');
|
||||
|
||||
// 기존 날짜 셀들 제거 (헤더는 유지)
|
||||
const dayHeaders = calendar.querySelectorAll('.day-header');
|
||||
calendar.innerHTML = '';
|
||||
dayHeaders.forEach(header => calendar.appendChild(header));
|
||||
|
||||
const year = currentDate.getFullYear();
|
||||
const month = currentDate.getMonth();
|
||||
|
||||
// 해당 월의 첫째 날과 마지막 날
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
|
||||
// 첫째 주의 시작 (일요일부터 시작)
|
||||
const startDate = new Date(firstDay);
|
||||
startDate.setDate(startDate.getDate() - firstDay.getDay());
|
||||
|
||||
// 마지막 주의 끝
|
||||
const endDate = new Date(lastDay);
|
||||
endDate.setDate(endDate.getDate() + (6 - lastDay.getDay()));
|
||||
|
||||
// 오늘 날짜
|
||||
const today = new Date();
|
||||
const todayStr = formatDate(today);
|
||||
|
||||
// 날짜 셀 생성
|
||||
let currentCalendarDate = new Date(startDate);
|
||||
|
||||
while (currentCalendarDate <= endDate) {
|
||||
const dateStr = formatDate(currentCalendarDate);
|
||||
const isCurrentMonth = currentCalendarDate.getMonth() === month;
|
||||
const isToday = dateStr === todayStr;
|
||||
const isSelected = selectedDate === dateStr;
|
||||
|
||||
const dayCell = document.createElement('div');
|
||||
dayCell.className = 'day-cell';
|
||||
|
||||
if (!isCurrentMonth) {
|
||||
dayCell.classList.add('other-month');
|
||||
}
|
||||
|
||||
if (isToday) {
|
||||
dayCell.classList.add('today');
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
dayCell.classList.add('selected');
|
||||
}
|
||||
|
||||
// 날짜 번호
|
||||
const dayNumber = document.createElement('div');
|
||||
dayNumber.className = 'day-number';
|
||||
dayNumber.textContent = currentCalendarDate.getDate();
|
||||
dayCell.appendChild(dayNumber);
|
||||
|
||||
// 클릭 이벤트 - 현재 월의 날짜만 클릭 가능
|
||||
if (isCurrentMonth) {
|
||||
dayCell.style.cursor = 'pointer';
|
||||
dayCell.addEventListener('click', () => {
|
||||
selectedDate = dateStr;
|
||||
loadDayData(dateStr);
|
||||
renderCalendar(); // 선택 상태 업데이트를 위해 재렌더링
|
||||
});
|
||||
}
|
||||
|
||||
calendar.appendChild(dayCell);
|
||||
currentCalendarDate.setDate(currentCalendarDate.getDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// 특정 날짜 데이터 로드 (통합 API 사용)
|
||||
async function loadDayData(dateStr) {
|
||||
try {
|
||||
showMessage(`${dateStr} 데이터를 불러오는 중... (통합 API)`, 'loading');
|
||||
|
||||
const data = await apiCall(`${API}/daily-work-reports?date=${dateStr}`);
|
||||
const dataArray = Array.isArray(data) ? data : (data.data || []);
|
||||
|
||||
// 데이터 처리
|
||||
processDayData(dateStr, dataArray);
|
||||
renderDayInfo();
|
||||
|
||||
document.getElementById('message-container').innerHTML = '';
|
||||
|
||||
} catch (error) {
|
||||
console.error('날짜 데이터 로드 실패:', error);
|
||||
showMessage('데이터를 불러올 수 없습니다: ' + error.message, 'error');
|
||||
selectedDateData = null;
|
||||
renderDayInfo();
|
||||
}
|
||||
}
|
||||
|
||||
// 일별 데이터 처리
|
||||
function processDayData(dateStr, works) {
|
||||
const dayData = {
|
||||
date: dateStr,
|
||||
totalHours: 0,
|
||||
workers: new Set(),
|
||||
reviewed: Math.random() > 0.3, // 임시: 70% 확률로 검토 완료
|
||||
details: works
|
||||
};
|
||||
|
||||
works.forEach(work => {
|
||||
dayData.totalHours += parseFloat(work.work_hours || 0);
|
||||
dayData.workers.add(work.worker_name || work.worker_id);
|
||||
});
|
||||
|
||||
const workType = classifyWorkType(dayData.totalHours);
|
||||
dayData.workType = workType.type;
|
||||
dayData.workLabel = workType.label;
|
||||
|
||||
selectedDateData = dayData;
|
||||
}
|
||||
|
||||
// 선택된 날짜 정보 렌더링
|
||||
function renderDayInfo() {
|
||||
const dayInfoContainer = document.getElementById('day-info-container');
|
||||
|
||||
if (!selectedDate) {
|
||||
dayInfoContainer.innerHTML = `
|
||||
<div class="day-info-placeholder">
|
||||
<h3>📅 날짜를 선택하세요</h3>
|
||||
<p>캘린더에서 날짜를 클릭하면 해당 날짜의 작업 정보를 확인할 수 있습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedDateData) {
|
||||
dayInfoContainer.innerHTML = `
|
||||
<div class="day-info-placeholder">
|
||||
<h3>📅 ${selectedDate}</h3>
|
||||
<p>해당 날짜에 등록된 작업이 없습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const data = selectedDateData;
|
||||
|
||||
// 작업자별 상세 정보 생성
|
||||
const workerDetailsHtml = Array.from(data.workers).map(worker => {
|
||||
const workerWorks = data.details.filter(w => (w.worker_name || w.worker_id) === worker);
|
||||
const workerHours = workerWorks.reduce((sum, w) => sum + parseFloat(w.work_hours || 0), 0);
|
||||
|
||||
const workerWorkItemsHtml = workerWorks.map(work => `
|
||||
<div class="work-item-detail">
|
||||
<div class="work-item-info">
|
||||
<strong>${work.project_name || '프로젝트'}</strong> - ${work.work_hours}시간<br>
|
||||
<small>작업: ${work.work_type_name || '미지정'} | 상태: ${work.work_status_name || '미지정'}</small>
|
||||
${work.error_type_name ? `<br><small style="color: #dc3545;">에러: ${work.error_type_name}</small>` : ''}
|
||||
</div>
|
||||
<div class="work-item-actions">
|
||||
<button class="edit-work-btn" onclick="editWorkItem('${work.id}')">✏️ 수정</button>
|
||||
<button class="delete-work-btn" onclick="deleteWorkItem('${work.id}')">🗑️ 삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
return `
|
||||
<div class="worker-detail-section">
|
||||
<div class="worker-header-detail">
|
||||
<strong>👤 ${worker}</strong> - 총 ${workerHours}시간
|
||||
<button class="delete-worker-btn" onclick="deleteWorkerAllWorks('${selectedDate}', '${worker}')">
|
||||
🗑️ 전체삭제
|
||||
</button>
|
||||
</div>
|
||||
<div class="worker-work-items">
|
||||
${workerWorkItemsHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
dayInfoContainer.innerHTML = `
|
||||
<div class="day-info-content">
|
||||
<div class="day-info-header">
|
||||
<h3>📅 ${selectedDate} 작업 정보</h3>
|
||||
<div class="day-info-actions">
|
||||
<button class="review-toggle ${data.reviewed ? 'reviewed' : ''}" onclick="toggleReview()">
|
||||
${data.reviewed ? '✅ 검토완료' : '⏳ 검토하기'}
|
||||
</button>
|
||||
<button class="refresh-day-btn" onclick="refreshCurrentDay()">
|
||||
🔄 새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="day-summary">
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">총 작업시간:</span>
|
||||
<span class="summary-value">${data.totalHours}시간</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">근무 유형:</span>
|
||||
<span class="summary-value ${data.workType}">${data.workLabel}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">작업자 수:</span>
|
||||
<span class="summary-value">${data.workers.size}명</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">검토 상태:</span>
|
||||
<span class="summary-value ${data.reviewed ? 'reviewed' : 'unreviewed'}">
|
||||
${data.reviewed ? '✅ 검토완료' : '⏳ 미검토'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="workers-detail-container">
|
||||
<h4>👥 작업자별 상세</h4>
|
||||
${workerDetailsHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 검토 상태 토글
|
||||
function toggleReview() {
|
||||
if (selectedDateData) {
|
||||
selectedDateData.reviewed = !selectedDateData.reviewed;
|
||||
renderDayInfo();
|
||||
|
||||
// TODO: 실제로는 여기서 API 호출해서 DB에 저장해야 함
|
||||
console.log(`검토 상태 변경: ${selectedDate} - ${selectedDateData.reviewed ? '검토완료' : '미검토'}`);
|
||||
|
||||
showMessage(`검토 상태가 ${selectedDateData.reviewed ? '완료' : '미완료'}로 변경되었습니다.`, 'success');
|
||||
}
|
||||
}
|
||||
|
||||
// 현재 날짜 새로고침
|
||||
function refreshCurrentDay() {
|
||||
if (selectedDate) {
|
||||
loadDayData(selectedDate);
|
||||
}
|
||||
}
|
||||
|
||||
// 🛠️ 작업 항목 수정 함수 (통합 API 사용)
|
||||
async function editWorkItem(workId) {
|
||||
try {
|
||||
console.log('수정할 작업 ID:', workId);
|
||||
|
||||
if (!selectedDateData) {
|
||||
showMessage('작업 데이터를 찾을 수 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const workData = selectedDateData.details.find(work => work.id == workId);
|
||||
if (!workData) {
|
||||
showMessage('수정할 작업 데이터를 찾을 수 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 기본 데이터가 없으면 로드
|
||||
if (basicData.workTypes.length === 0) {
|
||||
showMessage('기본 데이터를 불러오는 중... (통합 API)', 'loading');
|
||||
await loadBasicData();
|
||||
}
|
||||
|
||||
showEditModal(workData);
|
||||
document.getElementById('message-container').innerHTML = '';
|
||||
|
||||
} catch (error) {
|
||||
console.error('작업 정보 조회 오류:', error);
|
||||
showMessage('작업 정보를 불러올 수 없습니다: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 🛠️ 수정 모달 표시 (개선된 버전)
|
||||
function showEditModal(workData) {
|
||||
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" required>
|
||||
<option value="">프로젝트 선택</option>
|
||||
${basicData.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" required>
|
||||
<option value="">작업 유형 선택</option>
|
||||
${basicData.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" required>
|
||||
<option value="">업무 상태 선택</option>
|
||||
${basicData.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>
|
||||
${basicData.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" required>
|
||||
</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) {
|
||||
showMessage('프로젝트를 선택해주세요.', 'error');
|
||||
document.getElementById('editProject').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!workTypeId) {
|
||||
showMessage('작업 유형을 선택해주세요.', 'error');
|
||||
document.getElementById('editWorkType').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!workStatusId) {
|
||||
showMessage('업무 상태를 선택해주세요.', 'error');
|
||||
document.getElementById('editWorkStatus').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!workHours || workHours <= 0) {
|
||||
showMessage('작업 시간을 올바르게 입력해주세요.', 'error');
|
||||
document.getElementById('editWorkHours').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (workStatusId === '2' && !errorTypeId) {
|
||||
showMessage('에러 상태인 경우 에러 유형을 선택해주세요.', 'error');
|
||||
document.getElementById('editErrorType').focus();
|
||||
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 saveBtn = document.querySelector('.btn-success');
|
||||
const originalText = saveBtn.textContent;
|
||||
saveBtn.textContent = '저장 중...';
|
||||
saveBtn.disabled = true;
|
||||
|
||||
const result = await apiCall(`${API}/daily-work-reports/my-entry/${workId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updateData)
|
||||
});
|
||||
|
||||
console.log('✅ 수정 성공 (통합 API):', result);
|
||||
showMessage('✅ 작업이 성공적으로 수정되었습니다!', 'success');
|
||||
|
||||
closeEditModal();
|
||||
refreshCurrentDay(); // 현재 날짜 데이터 새로고침
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 수정 실패:', error);
|
||||
showMessage('수정 중 오류가 발생했습니다: ' + error.message, 'error');
|
||||
|
||||
// 버튼 복원
|
||||
const saveBtn = document.querySelector('.btn-success');
|
||||
if (saveBtn) {
|
||||
saveBtn.textContent = '💾 저장';
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🗑️ 작업 항목 삭제 (통합 API 사용)
|
||||
async function deleteWorkItem(workId) {
|
||||
// 확인 대화상자
|
||||
const confirmDelete = await showConfirmDialog(
|
||||
'작업 삭제 확인',
|
||||
'정말로 이 작업을 삭제하시겠습니까?',
|
||||
'삭제된 작업은 복구할 수 없습니다.'
|
||||
);
|
||||
|
||||
if (!confirmDelete) return;
|
||||
|
||||
try {
|
||||
console.log('삭제할 작업 ID:', workId);
|
||||
|
||||
showMessage('작업을 삭제하는 중... (통합 API)', 'loading');
|
||||
|
||||
const result = await apiCall(`${API}/daily-work-reports/my-entry/${workId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
console.log('✅ 삭제 성공 (통합 API):', result);
|
||||
showMessage('✅ 작업이 성공적으로 삭제되었습니다!', 'success');
|
||||
|
||||
refreshCurrentDay(); // 현재 날짜 데이터 새로고침
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 삭제 실패:', error);
|
||||
showMessage('삭제 중 오류가 발생했습니다: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 🗑️ 작업자의 모든 작업 삭제 (통합 API 사용)
|
||||
async function deleteWorkerAllWorks(date, workerName) {
|
||||
// 확인 대화상자
|
||||
const confirmDelete = await showConfirmDialog(
|
||||
'전체 작업 삭제 확인',
|
||||
`정말로 ${workerName}님의 ${date} 모든 작업을 삭제하시겠습니까?`,
|
||||
'삭제된 작업들은 복구할 수 없습니다.'
|
||||
);
|
||||
|
||||
if (!confirmDelete) return;
|
||||
|
||||
try {
|
||||
if (!selectedDateData) return;
|
||||
|
||||
const workerWorks = selectedDateData.details.filter(w => (w.worker_name || w.worker_id) === workerName);
|
||||
|
||||
if (workerWorks.length === 0) {
|
||||
showMessage('삭제할 작업이 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
showMessage(`${workerName}님의 작업들을 삭제하는 중... (통합 API)`, 'loading');
|
||||
|
||||
// 순차적으로 삭제 (병렬 처리하면 서버 부하 발생 가능)
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (const work of workerWorks) {
|
||||
try {
|
||||
await apiCall(`${API}/daily-work-reports/my-entry/${work.id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
console.error(`작업 ${work.id} 삭제 실패:`, error);
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (failCount === 0) {
|
||||
showMessage(`✅ ${workerName}님의 모든 작업(${successCount}개)이 삭제되었습니다!`, 'success');
|
||||
} else {
|
||||
showMessage(`⚠️ ${successCount}개 삭제 완료, ${failCount}개 삭제 실패`, 'warning');
|
||||
}
|
||||
|
||||
refreshCurrentDay(); // 현재 날짜 데이터 새로고침
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 전체 삭제 실패:', error);
|
||||
showMessage('작업 삭제 중 오류가 발생했습니다: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 확인 대화상자 표시
|
||||
function showConfirmDialog(title, message, warning) {
|
||||
return new Promise((resolve) => {
|
||||
const modalHtml = `
|
||||
<div class="confirm-modal" id="confirmModal">
|
||||
<div class="confirm-modal-content">
|
||||
<div class="confirm-modal-header">
|
||||
<h3>⚠️ ${title}</h3>
|
||||
</div>
|
||||
<div class="confirm-modal-body">
|
||||
<p><strong>${message}</strong></p>
|
||||
<p style="color: #dc3545; font-size: 0.9rem;">${warning}</p>
|
||||
</div>
|
||||
<div class="confirm-modal-footer">
|
||||
<button class="btn btn-secondary" onclick="resolveConfirm(false)">취소</button>
|
||||
<button class="btn btn-danger" onclick="resolveConfirm(true)">삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
|
||||
// 전역 함수로 resolve 함수 노출
|
||||
window.resolveConfirm = (result) => {
|
||||
const modal = document.getElementById('confirmModal');
|
||||
if (modal) modal.remove();
|
||||
delete window.resolveConfirm;
|
||||
resolve(result);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// 기본 데이터 로드 (통합 API 사용)
|
||||
async function loadBasicData() {
|
||||
try {
|
||||
console.log('🔗 통합 API 설정을 사용한 기본 데이터 로딩...');
|
||||
|
||||
const promises = [
|
||||
// 프로젝트 로드
|
||||
apiCall(`${API}/projects`)
|
||||
.then(data => Array.isArray(data) ? data : (data.projects || []))
|
||||
.catch(() => []),
|
||||
|
||||
// 작업 유형 로드
|
||||
apiCall(`${API}/daily-work-reports/work-types`)
|
||||
.then(data => Array.isArray(data) ? data : [
|
||||
{id: 1, name: 'Base'},
|
||||
{id: 2, name: 'Vessel'},
|
||||
{id: 3, name: 'Piping'}
|
||||
])
|
||||
.catch(() => [
|
||||
{id: 1, name: 'Base'},
|
||||
{id: 2, name: 'Vessel'},
|
||||
{id: 3, name: 'Piping'}
|
||||
]),
|
||||
|
||||
// 업무 상태 유형 로드
|
||||
apiCall(`${API}/daily-work-reports/work-status-types`)
|
||||
.then(data => Array.isArray(data) ? data : [
|
||||
{id: 1, name: '정규'},
|
||||
{id: 2, name: '에러'}
|
||||
])
|
||||
.catch(() => [
|
||||
{id: 1, name: '정규'},
|
||||
{id: 2, name: '에러'}
|
||||
]),
|
||||
|
||||
// 에러 유형 로드
|
||||
apiCall(`${API}/daily-work-reports/error-types`)
|
||||
.then(data => Array.isArray(data) ? data : [
|
||||
{id: 1, name: '설계미스'},
|
||||
{id: 2, name: '외주작업 불량'},
|
||||
{id: 3, name: '입고지연'},
|
||||
{id: 4, name: '작업 불량'}
|
||||
])
|
||||
.catch(() => [
|
||||
{id: 1, name: '설계미스'},
|
||||
{id: 2, name: '외주작업 불량'},
|
||||
{id: 3, name: '입고지연'},
|
||||
{id: 4, name: '작업 불량'}
|
||||
])
|
||||
];
|
||||
|
||||
const [projects, workTypes, workStatusTypes, errorTypes] = await Promise.all(promises);
|
||||
|
||||
basicData = {
|
||||
projects,
|
||||
workTypes,
|
||||
workStatusTypes,
|
||||
errorTypes
|
||||
};
|
||||
|
||||
console.log('✅ 기본 데이터 로드 완료 (통합 API):', basicData);
|
||||
} catch (error) {
|
||||
console.error('기본 데이터 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 이벤트 리스너 설정
|
||||
function setupEventListeners() {
|
||||
document.getElementById('prevMonth').addEventListener('click', () => {
|
||||
currentDate.setMonth(currentDate.getMonth() - 1);
|
||||
updateMonthDisplay();
|
||||
selectedDate = null;
|
||||
selectedDateData = null;
|
||||
renderCalendar();
|
||||
renderDayInfo();
|
||||
});
|
||||
|
||||
document.getElementById('nextMonth').addEventListener('click', () => {
|
||||
currentDate.setMonth(currentDate.getMonth() + 1);
|
||||
updateMonthDisplay();
|
||||
selectedDate = null;
|
||||
selectedDateData = null;
|
||||
renderCalendar();
|
||||
renderDayInfo();
|
||||
});
|
||||
|
||||
// 오늘 날짜로 이동 버튼 추가
|
||||
document.getElementById('goToday')?.addEventListener('click', () => {
|
||||
const today = new Date();
|
||||
currentDate = new Date(today);
|
||||
updateMonthDisplay();
|
||||
renderCalendar();
|
||||
|
||||
// 오늘 날짜 자동 선택
|
||||
const todayStr = formatDate(today);
|
||||
selectedDate = todayStr;
|
||||
loadDayData(todayStr);
|
||||
});
|
||||
}
|
||||
|
||||
// 전역 함수로 노출
|
||||
window.toggleReview = toggleReview;
|
||||
window.refreshCurrentDay = refreshCurrentDay;
|
||||
window.editWorkItem = editWorkItem;
|
||||
window.deleteWorkItem = deleteWorkItem;
|
||||
window.deleteWorkerAllWorks = deleteWorkerAllWorks;
|
||||
window.closeEditModal = closeEditModal;
|
||||
window.saveEditedWork = saveEditedWork;
|
||||
|
||||
// 초기화
|
||||
async function init() {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token || token === 'undefined') {
|
||||
showMessage('로그인이 필요합니다.', 'error');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
updateMonthDisplay();
|
||||
setupEventListeners();
|
||||
renderCalendar();
|
||||
renderDayInfo();
|
||||
|
||||
// 기본 데이터 미리 로드
|
||||
await loadBasicData();
|
||||
|
||||
console.log('✅ 검토 페이지 초기화 완료 (통합 API 설정 적용)');
|
||||
|
||||
} catch (error) {
|
||||
console.error('초기화 오류:', error);
|
||||
showMessage('초기화 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
BIN
fastapi-bridge/static/logo 2.png
Normal file
BIN
fastapi-bridge/static/logo 2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
fastapi-bridge/static/logo.png
Normal file
BIN
fastapi-bridge/static/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
44
fastapi-bridge/static/pages/admin/admin dashboard.html
Normal file
44
fastapi-bridge/static/pages/admin/admin dashboard.html
Normal file
@@ -0,0 +1,44 @@
|
||||
<section>
|
||||
<h2>📄 작업 보고서</h2>
|
||||
<ul>
|
||||
<li><a href="/pages/work-reports/create.html">작업보고서 입력</a></li>
|
||||
<li><a href="/pages/work-reports/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-list.html">공장 목록 보기</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>📊 이슈 리포트</h2>
|
||||
<ul>
|
||||
<li><a href="/pages/issue-reports/daily-issue.html">일일 이슈 보고</a></li>
|
||||
<li><a href="/pages/issue-reports/issue-summary.html">이슈 현황 요약</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
181
fastapi-bridge/static/pages/admin/attendance-validation.html
Normal file
181
fastapi-bridge/static/pages/admin/attendance-validation.html
Normal file
@@ -0,0 +1,181 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>근태 검증 관리 시스템 | (주)테크니컬코리아</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="/css/attendance-validation.css">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="max-w-7xl mx-auto p-6">
|
||||
<!-- 뒤로가기 버튼 -->
|
||||
<a href="javascript:history.back()" class="back-btn">
|
||||
← 뒤로가기
|
||||
</a>
|
||||
|
||||
<!-- 헤더 -->
|
||||
<div class="page-header">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="text-4xl">👥</span>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white">근태 검증 관리</h1>
|
||||
<p class="text-lg text-white/90 mt-2">작업자별 근무시간을 검증하고 관리합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-white/80 bg-white/20 px-4 py-2 rounded-lg">
|
||||
🔒 Admin 전용
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 메시지 영역 -->
|
||||
<div id="message-container"></div>
|
||||
|
||||
<!-- 로딩 화면 -->
|
||||
<div id="loadingScreen" class="hidden">
|
||||
<div class="loading-card">
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="loading-spinner"></div>
|
||||
<span class="ml-3 text-gray-600">데이터를 불러오는 중...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<div id="mainContent">
|
||||
<!-- 캘린더 섹션 -->
|
||||
<div class="main-card">
|
||||
<!-- 캘린더 헤더 -->
|
||||
<div class="calendar-header">
|
||||
<button id="prevMonth" class="nav-btn">
|
||||
<span class="text-xl">◀</span>
|
||||
</button>
|
||||
<h2 id="currentMonthYear" class="text-2xl font-bold text-gray-800">
|
||||
2025년 6월
|
||||
</h2>
|
||||
<button id="nextMonth" class="nav-btn">
|
||||
<span class="text-xl">▶</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 월간 요약 정보 -->
|
||||
<div class="summary-section">
|
||||
<h3 class="summary-title">이번 달 요약</h3>
|
||||
<div class="summary-grid">
|
||||
<div class="summary-card normal">
|
||||
<div id="normalCount" class="summary-number">0</div>
|
||||
<div class="summary-label">✅ 정상</div>
|
||||
</div>
|
||||
<div class="summary-card warning">
|
||||
<div id="reviewCount" class="summary-number">0</div>
|
||||
<div class="summary-label">⚠️ 검토필요</div>
|
||||
</div>
|
||||
<div class="summary-card error">
|
||||
<div id="missingCount" class="summary-number">0</div>
|
||||
<div class="summary-label">❌ 미입력</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 요일 헤더 -->
|
||||
<div class="weekday-header">
|
||||
<div class="weekday sunday">일</div>
|
||||
<div class="weekday">월</div>
|
||||
<div class="weekday">화</div>
|
||||
<div class="weekday">수</div>
|
||||
<div class="weekday">목</div>
|
||||
<div class="weekday">금</div>
|
||||
<div class="weekday saturday">토</div>
|
||||
</div>
|
||||
|
||||
<!-- 캘린더 본체 -->
|
||||
<div id="calendarGrid" class="calendar-grid">
|
||||
<!-- 동적으로 생성됨 -->
|
||||
</div>
|
||||
|
||||
<!-- 범례 -->
|
||||
<div class="legend">
|
||||
<div class="legend-item">
|
||||
<div class="legend-dot normal"></div>
|
||||
<span>정상</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-dot warning"></div>
|
||||
<span>검토필요</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-dot error"></div>
|
||||
<span>미입력</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 성능 상태 표시 (개발용) -->
|
||||
<div id="performanceStatus" class="text-xs text-gray-500 text-center mt-4 hidden">
|
||||
<div class="bg-gray-100 rounded-lg p-2">
|
||||
🔄 순차 호출: <span id="activeReq">0</span>개 처리 중 |
|
||||
📦 캐시: <span id="cacheCount">0</span>개 저장됨 |
|
||||
⏳ 대기: <span id="queueCount">0</span>개 |
|
||||
🚫 429 에러 방지 (2초 딜레이)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업자 리스트 섹션 -->
|
||||
<div id="workersList" class="main-card">
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">🔄</div>
|
||||
<h3 class="empty-title">날짜를 클릭해주세요</h3>
|
||||
<p class="empty-description">
|
||||
캘린더에서 날짜를 클릭하면 해당 날짜의 작업자 검증 내역을 확인할 수 있습니다.<br>
|
||||
<strong>순차 호출 방식</strong>으로 안정적이지만 약 5초의 로딩 시간이 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 수정 모달 -->
|
||||
<div id="editModal" class="modal hidden">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>✏️ 근무시간 수정</h3>
|
||||
<button class="close-btn" onclick="closeEditModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>👤 작업자</label>
|
||||
<input type="text" id="editWorkerName" class="form-input" readonly>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>📊 현재 상태</label>
|
||||
<input type="text" id="editWorkerStatus" class="form-input" readonly>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>⏰ 근무시간 (시간)</label>
|
||||
<input type="number" id="editWorkHours" class="form-input"
|
||||
min="0" max="24" step="0.5" placeholder="근무시간을 입력하세요">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>📝 수정 사유</label>
|
||||
<textarea id="editReason" class="form-textarea"
|
||||
placeholder="수정 사유를 입력하세요 (선택사항)"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" onclick="closeEditModal()">취소</button>
|
||||
<button class="btn-primary" onclick="saveEditedWork()">💾 저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script src="/js/attendance-validation.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
35
fastapi-bridge/static/pages/admin/dashboard.html
Normal file
35
fastapi-bridge/static/pages/admin/dashboard.html
Normal file
@@ -0,0 +1,35 @@
|
||||
<section>
|
||||
<h2>📄 작업 보고서</h2>
|
||||
<ul>
|
||||
<li><a href="/pages/work/work-report-create.html">작업보고서 입력</a></li>
|
||||
<li><a href="/pages/work/work-report-manage.html">작업보고서 수정/삭제</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>📊 출근/공수 관리</h2>
|
||||
<ul>
|
||||
<li><a href="/pages/attendance/attendance.html">출근부</a></li>
|
||||
<li><a href="/pages/attendance/project-labor-summary.html">프로젝트별 공수 계산</a></li>
|
||||
<li><a href="/pages/attendance/monthly-labor-report.html">월간 공수 보고서</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>관리콘솔</h2>
|
||||
<ul>
|
||||
<li><a href="/pages/admin/manage-all.html">📋 기본정보 관리</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>🏭 공장 정보</h2>
|
||||
<ul>
|
||||
<li><a href="/pages/factory/factory-map-upload.html">공장 지도 업로드</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>🗂 기타 관리</h2>
|
||||
<p>프로젝트 및 작업자 관련 기능은 추후 확장 예정</p>
|
||||
</section>
|
||||
23
fastapi-bridge/static/pages/admin/factory-upload.html
Normal file
23
fastapi-bridge/static/pages/admin/factory-upload.html
Normal file
@@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>공장 지도 등록</title>
|
||||
<link rel="stylesheet" href="/css/admin.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="navbar-placeholder"></div>
|
||||
<div class="container">
|
||||
<h2>공장 지도 등록</h2>
|
||||
<form id="uploadForm">
|
||||
<input type="text" name="factory_name" placeholder="공장명" required>
|
||||
<input type="text" name="address" placeholder="주소" required>
|
||||
<textarea name="description" placeholder="설명" required></textarea>
|
||||
<input type="file" name="map_image" accept="image/*" required>
|
||||
<button type="submit">등록</button>
|
||||
</form>
|
||||
</div>
|
||||
<script src="/js/load-navbar.js"></script>
|
||||
<script src="/js/admin/factory-upload.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
667
fastapi-bridge/static/pages/admin/manage-daily-work.html
Normal file
667
fastapi-bridge/static/pages/admin/manage-daily-work.html
Normal file
@@ -0,0 +1,667 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Calendar, Clock, Users, AlertTriangle, CheckCircle, Edit3, Filter } from 'lucide-react';
|
||||
|
||||
const AttendanceValidationPage = () => {
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const [selectedDate, setSelectedDate] = useState(null);
|
||||
const [attendanceData, setAttendanceData] = useState({});
|
||||
const [selectedDateWorkers, setSelectedDateWorkers] = useState([]);
|
||||
const [filter, setFilter] = useState('all'); // all, needsReview, normal, missing
|
||||
const [monthlyData, setMonthlyData] = useState({ workReports: [], dailyReports: [] });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isAuthorized, setIsAuthorized] = useState(true); // TODO: 실제 권한 체크
|
||||
|
||||
// 월이 변경될 때마다 해당 월의 전체 데이터 로드
|
||||
useEffect(() => {
|
||||
loadMonthlyData();
|
||||
}, [currentDate]);
|
||||
|
||||
const loadMonthlyData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const year = currentDate.getFullYear();
|
||||
const month = currentDate.getMonth() + 1;
|
||||
|
||||
console.log(`${year}년 ${month}월 데이터 로딩 중...`);
|
||||
const data = await fetchMonthlyData(year, month);
|
||||
setMonthlyData(data);
|
||||
|
||||
} catch (error) {
|
||||
console.error('월간 데이터 로딩 실패:', error);
|
||||
// 실패 시 빈 데이터로 설정
|
||||
setMonthlyData({ workReports: [], dailyReports: [] });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 실제 API 호출 함수들
|
||||
const fetchWorkReports = async (date) => {
|
||||
try {
|
||||
const response = await fetch(`/api/workreports/date/${date}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('WorkReports API 호출 오류:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDailyWorkReports = async (date) => {
|
||||
try {
|
||||
const response = await fetch(`/api/daily-work-reports/date/${date}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('DailyWorkReports API 호출 오류:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// 월간 데이터 가져오기 (캘린더용)
|
||||
const fetchMonthlyData = async (year, month) => {
|
||||
const start = `${year}-${month.toString().padStart(2, '0')}-01`;
|
||||
const end = `${year}-${month.toString().padStart(2, '0')}-31`;
|
||||
|
||||
try {
|
||||
const [workReports, dailyReports] = await Promise.all([
|
||||
fetch(`/api/workreports?start=${start}&end=${end}`, {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
}).then(res => res.json()),
|
||||
fetch(`/api/daily-work-reports/search?start_date=${start}&end_date=${end}`, {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
}).then(res => res.json())
|
||||
]);
|
||||
|
||||
return { workReports, dailyReports: dailyReports.reports || [] };
|
||||
} catch (error) {
|
||||
console.error('월간 데이터 가져오기 오류:', error);
|
||||
return { workReports: [], dailyReports: [] };
|
||||
}
|
||||
};
|
||||
|
||||
// 목업 데이터 (개발/테스트용)
|
||||
const mockWorkReports = {
|
||||
'2025-06-16': [
|
||||
{ worker_id: 1, worker_name: '김철수', overtime_hours: 1, status: 'normal' },
|
||||
{ worker_id: 2, worker_name: '이영희', overtime_hours: 0, status: 'half_day' },
|
||||
{ worker_id: 3, worker_name: '박민수', overtime_hours: 0, status: 'vacation' },
|
||||
],
|
||||
'2025-06-17': [
|
||||
{ worker_id: 1, worker_name: '김철수', overtime_hours: 2, status: 'normal' },
|
||||
{ worker_id: 2, worker_name: '이영희', overtime_hours: 0, status: 'normal' },
|
||||
{ worker_id: 4, worker_name: '정수현', overtime_hours: 0, status: 'early_leave' },
|
||||
],
|
||||
'2025-06-19': [
|
||||
{ worker_id: 1, worker_name: '김철수', overtime_hours: 1, status: 'normal' },
|
||||
{ worker_id: 2, worker_name: '이영희', overtime_hours: 0, status: 'half_day' },
|
||||
{ worker_id: 3, worker_name: '박민수', overtime_hours: 0, status: 'vacation' },
|
||||
{ worker_id: 4, worker_name: '정수현', overtime_hours: 0, status: 'normal' },
|
||||
]
|
||||
};
|
||||
|
||||
const mockDailyReports = {
|
||||
'2025-06-16': [
|
||||
{ worker_id: 1, worker_name: '김철수', work_hours: 9 },
|
||||
{ worker_id: 2, worker_name: '이영희', work_hours: 4 },
|
||||
{ worker_id: 3, worker_name: '박민수', work_hours: 0 },
|
||||
],
|
||||
'2025-06-17': [
|
||||
{ worker_id: 1, worker_name: '김철수', work_hours: 10 },
|
||||
{ worker_id: 2, worker_name: '이영희', work_hours: 8 },
|
||||
{ worker_id: 4, worker_name: '정수현', work_hours: 6 },
|
||||
],
|
||||
'2025-06-19': [
|
||||
{ worker_id: 1, worker_name: '김철수', work_hours: 9 },
|
||||
{ worker_id: 2, worker_name: '이영희', work_hours: 4 },
|
||||
{ worker_id: 3, worker_name: '박민수', work_hours: 0 },
|
||||
// 정수현 데이터 누락 - 미입력 상태
|
||||
]
|
||||
};
|
||||
|
||||
// 시간 계산 함수
|
||||
const calculateExpectedHours = (status, overtime_hours = 0) => {
|
||||
const baseHours = {
|
||||
'normal': 8,
|
||||
'half_day': 4,
|
||||
'early_leave': 6,
|
||||
'quarter_day': 2,
|
||||
'vacation': 0,
|
||||
'sick_leave': 0
|
||||
};
|
||||
return (baseHours[status] || 8) + (overtime_hours || 0);
|
||||
};
|
||||
|
||||
// 날짜별 상태 계산 (월간 데이터 기반)
|
||||
const calculateDateStatus = (dateStr) => {
|
||||
const workReports = monthlyData.workReports.filter(wr => wr.date === dateStr);
|
||||
const dailyReports = monthlyData.dailyReports.filter(dr => dr.report_date === dateStr);
|
||||
|
||||
if (workReports.length === 0 && dailyReports.length === 0) {
|
||||
return 'no-data';
|
||||
}
|
||||
|
||||
if (workReports.length === 0 || dailyReports.length === 0) {
|
||||
return 'missing';
|
||||
}
|
||||
|
||||
// 작업자별 시간 집계
|
||||
const dailyGrouped = dailyReports.reduce((acc, dr) => {
|
||||
if (!acc[dr.worker_id]) {
|
||||
acc[dr.worker_id] = 0;
|
||||
}
|
||||
acc[dr.worker_id] += parseFloat(dr.work_hours || 0);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// 불일치 검사
|
||||
const hasDiscrepancy = workReports.some(wr => {
|
||||
const reportedHours = dailyGrouped[wr.worker_id] || 0;
|
||||
const expectedHours = calculateExpectedHours('normal', wr.overtime_hours || 0);
|
||||
return Math.abs(reportedHours - expectedHours) > 0;
|
||||
});
|
||||
|
||||
return hasDiscrepancy ? 'needs-review' : 'normal';
|
||||
};
|
||||
|
||||
// 권한 체크 (실제 구현 시)
|
||||
const checkPermission = () => {
|
||||
// TODO: 실제 권한 체크 로직
|
||||
const userRole = localStorage.getItem('userRole') || 'user';
|
||||
return userRole === 'admin' || userRole === 'manager';
|
||||
};
|
||||
|
||||
// 권한 없음 UI
|
||||
if (!isAuthorized) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto p-6 bg-gray-50 min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-6xl mb-4">🔒</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">접근 권한이 없습니다</h1>
|
||||
<p className="text-gray-600">이 페이지는 관리자(Admin) 이상만 접근 가능합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 로딩 중 UI
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto p-6 bg-gray-50 min-h-screen">
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
<span className="ml-3 text-gray-600">데이터를 불러오는 중...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 캘린더 생성
|
||||
const generateCalendar = () => {
|
||||
const year = currentDate.getFullYear();
|
||||
const month = currentDate.getMonth();
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
const startDate = new Date(firstDay);
|
||||
startDate.setDate(startDate.getDate() - firstDay.getDay());
|
||||
|
||||
const calendar = [];
|
||||
const current = new Date(startDate);
|
||||
|
||||
for (let week = 0; week < 6; week++) {
|
||||
const weekDays = [];
|
||||
for (let day = 0; day < 7; day++) {
|
||||
const dateStr = current.toISOString().split('T')[0];
|
||||
const isCurrentMonth = current.getMonth() === month;
|
||||
const status = isCurrentMonth ? calculateDateStatus(dateStr) : 'no-data';
|
||||
|
||||
weekDays.push({
|
||||
date: new Date(current),
|
||||
dateStr,
|
||||
isCurrentMonth,
|
||||
status
|
||||
});
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
calendar.push(weekDays);
|
||||
}
|
||||
return calendar;
|
||||
};
|
||||
|
||||
// 실제 API를 사용한 작업자 데이터 조합
|
||||
const getWorkersForDate = async (dateStr) => {
|
||||
try {
|
||||
// 실제 API 호출
|
||||
const [workReports, dailyReports] = await Promise.all([
|
||||
fetchWorkReports(dateStr),
|
||||
fetchDailyWorkReports(dateStr)
|
||||
]);
|
||||
|
||||
console.log('API 응답:', { workReports, dailyReports });
|
||||
|
||||
const workerMap = new Map();
|
||||
|
||||
// WorkReports 데이터 추가 (생산지원팀 입력)
|
||||
workReports.forEach(wr => {
|
||||
workerMap.set(wr.worker_id, {
|
||||
worker_id: wr.worker_id,
|
||||
worker_name: wr.worker_name,
|
||||
overtime_hours: wr.overtime_hours || 0,
|
||||
status: 'normal', // 실제 테이블 구조에 맞게 수정 필요
|
||||
expected_hours: calculateExpectedHours('normal', wr.overtime_hours),
|
||||
reported_hours: null,
|
||||
hasWorkReport: true,
|
||||
hasDailyReport: false
|
||||
});
|
||||
});
|
||||
|
||||
// DailyReports 데이터 추가 (그룹장 입력) - 작업자별 총 시간 집계
|
||||
const dailyGrouped = dailyReports.reduce((acc, dr) => {
|
||||
if (!acc[dr.worker_id]) {
|
||||
acc[dr.worker_id] = {
|
||||
worker_id: dr.worker_id,
|
||||
worker_name: dr.worker_name,
|
||||
total_work_hours: 0
|
||||
};
|
||||
}
|
||||
acc[dr.worker_id].total_work_hours += parseFloat(dr.work_hours || 0);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
Object.values(dailyGrouped).forEach(dr => {
|
||||
if (workerMap.has(dr.worker_id)) {
|
||||
const worker = workerMap.get(dr.worker_id);
|
||||
worker.reported_hours = dr.total_work_hours;
|
||||
worker.hasDailyReport = true;
|
||||
} else {
|
||||
workerMap.set(dr.worker_id, {
|
||||
worker_id: dr.worker_id,
|
||||
worker_name: dr.worker_name,
|
||||
overtime_hours: 0,
|
||||
status: 'normal',
|
||||
expected_hours: 8,
|
||||
reported_hours: dr.total_work_hours,
|
||||
hasWorkReport: false,
|
||||
hasDailyReport: true
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(workerMap.values()).map(worker => ({
|
||||
...worker,
|
||||
difference: worker.reported_hours !== null ? worker.reported_hours - worker.expected_hours : -worker.expected_hours,
|
||||
validationStatus: getValidationStatus(worker)
|
||||
}));
|
||||
|
||||
} catch (error) {
|
||||
console.error('데이터 조합 오류:', error);
|
||||
// 오류 시 목업 데이터 사용
|
||||
return getWorkersForDateMock(dateStr);
|
||||
}
|
||||
};
|
||||
|
||||
// 목업 데이터용 함수 (개발/테스트)
|
||||
const getWorkersForDateMock = (dateStr) => {
|
||||
const workReports = mockWorkReports[dateStr] || [];
|
||||
const dailyReports = mockDailyReports[dateStr] || [];
|
||||
|
||||
const workerMap = new Map();
|
||||
|
||||
// WorkReports 데이터 추가
|
||||
workReports.forEach(wr => {
|
||||
workerMap.set(wr.worker_id, {
|
||||
worker_id: wr.worker_id,
|
||||
worker_name: wr.worker_name,
|
||||
overtime_hours: wr.overtime_hours,
|
||||
status: wr.status,
|
||||
expected_hours: calculateExpectedHours(wr.status, wr.overtime_hours),
|
||||
reported_hours: null,
|
||||
hasWorkReport: true,
|
||||
hasDailyReport: false
|
||||
});
|
||||
});
|
||||
|
||||
// DailyReports 데이터 추가
|
||||
dailyReports.forEach(dr => {
|
||||
if (workerMap.has(dr.worker_id)) {
|
||||
const worker = workerMap.get(dr.worker_id);
|
||||
worker.reported_hours = dr.work_hours;
|
||||
worker.hasDailyReport = true;
|
||||
} else {
|
||||
workerMap.set(dr.worker_id, {
|
||||
worker_id: dr.worker_id,
|
||||
worker_name: dr.worker_name,
|
||||
overtime_hours: 0,
|
||||
status: 'normal',
|
||||
expected_hours: 8,
|
||||
reported_hours: dr.work_hours,
|
||||
hasWorkReport: false,
|
||||
hasDailyReport: true
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(workerMap.values()).map(worker => ({
|
||||
...worker,
|
||||
difference: worker.reported_hours !== null ? worker.reported_hours - worker.expected_hours : -worker.expected_hours,
|
||||
validationStatus: getValidationStatus(worker)
|
||||
}));
|
||||
};
|
||||
|
||||
const getValidationStatus = (worker) => {
|
||||
if (!worker.hasWorkReport || !worker.hasDailyReport) return 'missing';
|
||||
if (Math.abs(worker.difference) > 0) return 'needs-review';
|
||||
return 'normal';
|
||||
};
|
||||
|
||||
// 작업자 수정 핸들러
|
||||
const handleEditWorker = (worker) => {
|
||||
// TODO: 수정 모달 또는 인라인 편집 구현
|
||||
const newHours = prompt(
|
||||
`${worker.worker_name}의 근무시간을 수정하세요.\n현재: ${worker.reported_hours || 0}시간`,
|
||||
worker.reported_hours || 0
|
||||
);
|
||||
|
||||
if (newHours !== null && !isNaN(newHours)) {
|
||||
updateWorkerHours(worker.worker_id, parseFloat(newHours));
|
||||
}
|
||||
};
|
||||
|
||||
// 작업자 시간 업데이트 (실제 API 호출)
|
||||
const updateWorkerHours = async (workerId, newHours) => {
|
||||
try {
|
||||
// TODO: 실제 수정 API 호출
|
||||
console.log(`작업자 ${workerId}의 시간을 ${newHours}시간으로 수정`);
|
||||
|
||||
// 임시: 로컬 상태 업데이트
|
||||
setSelectedDateWorkers(prev =>
|
||||
prev.map(worker =>
|
||||
worker.worker_id === workerId
|
||||
? {
|
||||
...worker,
|
||||
reported_hours: newHours,
|
||||
difference: newHours - worker.expected_hours,
|
||||
validationStatus: Math.abs(newHours - worker.expected_hours) > 0 ? 'needs-review' : 'normal'
|
||||
}
|
||||
: worker
|
||||
)
|
||||
);
|
||||
|
||||
alert('수정이 완료되었습니다.');
|
||||
|
||||
} catch (error) {
|
||||
console.error('수정 실패:', error);
|
||||
alert('수정 중 오류가 발생했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 날짜 클릭 핸들러 (실제 API 호출)
|
||||
const handleDateClick = async (dateInfo) => {
|
||||
if (!dateInfo.isCurrentMonth) return;
|
||||
|
||||
setSelectedDate(dateInfo.dateStr);
|
||||
|
||||
try {
|
||||
// 로딩 상태 표시
|
||||
setSelectedDateWorkers([]);
|
||||
|
||||
// 실제 API에서 데이터 가져오기
|
||||
const workers = await getWorkersForDate(dateInfo.dateStr);
|
||||
setSelectedDateWorkers(workers);
|
||||
|
||||
} catch (error) {
|
||||
console.error('날짜별 데이터 로딩 오류:', error);
|
||||
// 오류 시 목업 데이터 사용
|
||||
const workers = getWorkersForDateMock(dateInfo.dateStr);
|
||||
setSelectedDateWorkers(workers);
|
||||
}
|
||||
};
|
||||
|
||||
// 필터링된 작업자 목록
|
||||
const filteredWorkers = selectedDateWorkers.filter(worker => {
|
||||
if (filter === 'all') return true;
|
||||
if (filter === 'needsReview') return worker.validationStatus === 'needs-review';
|
||||
if (filter === 'normal') return worker.validationStatus === 'normal';
|
||||
if (filter === 'missing') return worker.validationStatus === 'missing';
|
||||
return true;
|
||||
});
|
||||
|
||||
// 상태별 아이콘 및 색상
|
||||
const getStatusIcon = (status) => {
|
||||
switch (status) {
|
||||
case 'normal': return <CheckCircle className="w-4 h-4 text-green-500" />;
|
||||
case 'needs-review': return <AlertTriangle className="w-4 h-4 text-yellow-500" />;
|
||||
case 'missing': return <Clock className="w-4 h-4 text-red-500" />;
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'normal': return 'bg-green-100 text-green-800';
|
||||
case 'needs-review': return 'bg-yellow-100 text-yellow-800';
|
||||
case 'missing': return 'bg-red-100 text-red-800';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const calendar = generateCalendar();
|
||||
const monthNames = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'];
|
||||
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto p-6 bg-gray-50 min-h-screen">
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Users className="w-6 h-6 text-blue-600" />
|
||||
<h1 className="text-2xl font-bold text-gray-900">근태 검증 관리</h1>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
Admin 전용 페이지
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* 캘린더 섹션 */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
{/* 캘린더 헤더 */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
const newDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - 1);
|
||||
setCurrentDate(newDate);
|
||||
}}
|
||||
className="p-2 hover:bg-gray-100 rounded-md"
|
||||
disabled={loading}
|
||||
>
|
||||
◀
|
||||
</button>
|
||||
<h2 className="text-xl font-semibold">
|
||||
{currentDate.getFullYear()}년 {monthNames[currentDate.getMonth()]}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
const newDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1);
|
||||
setCurrentDate(newDate);
|
||||
}}
|
||||
className="p-2 hover:bg-gray-100 rounded-md"
|
||||
disabled={loading}
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 월간 요약 정보 */}
|
||||
<div className="mb-4 p-3 bg-gray-50 rounded-lg">
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div className="text-center">
|
||||
<div className="text-green-600 font-semibold">
|
||||
{calendar.flat().filter(d => d.isCurrentMonth && d.status === 'normal').length}
|
||||
</div>
|
||||
<div className="text-gray-600">정상</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-yellow-600 font-semibold">
|
||||
{calendar.flat().filter(d => d.isCurrentMonth && d.status === 'needs-review').length}
|
||||
</div>
|
||||
<div className="text-gray-600">검토필요</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-red-600 font-semibold">
|
||||
{calendar.flat().filter(d => d.isCurrentMonth && d.status === 'missing').length}
|
||||
</div>
|
||||
<div className="text-gray-600">미입력</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 요일 헤더 */}
|
||||
<div className="grid grid-cols-7 gap-1 mb-2">
|
||||
{dayNames.map(day => (
|
||||
<div key={day} className="p-2 text-center text-sm font-medium text-gray-500">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 캘린더 본체 */}
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{calendar.flat().map((dateInfo, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleDateClick(dateInfo)}
|
||||
className={`
|
||||
p-2 text-sm rounded-md h-12 relative transition-colors
|
||||
${dateInfo.isCurrentMonth ? 'text-gray-900' : 'text-gray-400'}
|
||||
${selectedDate === dateInfo.dateStr ? 'bg-blue-100 border-2 border-blue-500' : 'hover:bg-gray-50'}
|
||||
${dateInfo.status === 'needs-review' ? 'bg-yellow-50 border border-yellow-200' : ''}
|
||||
${dateInfo.status === 'missing' ? 'bg-red-50 border border-red-200' : ''}
|
||||
${dateInfo.status === 'normal' ? 'bg-green-50 border border-green-200' : ''}
|
||||
`}
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<span>{dateInfo.date.getDate()}</span>
|
||||
{dateInfo.isCurrentMonth && dateInfo.status !== 'no-data' && (
|
||||
<div className="absolute top-1 right-1">
|
||||
{dateInfo.status === 'needs-review' && <div className="w-2 h-2 bg-yellow-500 rounded-full"></div>}
|
||||
{dateInfo.status === 'missing' && <div className="w-2 h-2 bg-red-500 rounded-full"></div>}
|
||||
{dateInfo.status === 'normal' && <div className="w-2 h-2 bg-green-500 rounded-full"></div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 범례 */}
|
||||
<div className="flex items-center justify-center space-x-4 mt-4 text-xs">
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
|
||||
<span>정상</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="w-3 h-3 bg-yellow-500 rounded-full"></div>
|
||||
<span>검토필요</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="w-3 h-3 bg-red-500 rounded-full"></div>
|
||||
<span>미입력</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 작업자 리스트 섹션 */}
|
||||
<div className="lg:col-span-1">
|
||||
{selectedDate ? (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">
|
||||
📅 {selectedDate}
|
||||
</h3>
|
||||
<select
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-md px-2 py-1"
|
||||
>
|
||||
<option value="all">전체</option>
|
||||
<option value="needsReview">검토필요</option>
|
||||
<option value="normal">정상</option>
|
||||
<option value="missing">미입력</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{filteredWorkers.map(worker => (
|
||||
<div key={worker.worker_id} className={`p-3 rounded-lg border ${getStatusColor(worker.validationStatus)}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium">{worker.worker_name}</span>
|
||||
{getStatusIcon(worker.validationStatus)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span>그룹장 입력:</span>
|
||||
<span className="font-mono">
|
||||
{worker.reported_hours !== null ? `${worker.reported_hours}시간` : '미입력'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>시스템 계산:</span>
|
||||
<span className="font-mono">{worker.expected_hours}시간</span>
|
||||
</div>
|
||||
{worker.difference !== 0 && (
|
||||
<div className="flex justify-between font-semibold">
|
||||
<span>차이:</span>
|
||||
<span className={`font-mono ${worker.difference > 0 ? 'text-red-600' : 'text-blue-600'}`}>
|
||||
{worker.difference > 0 ? '+' : ''}{worker.difference}시간
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{worker.validationStatus === 'needs-review' && (
|
||||
<button
|
||||
onClick={() => handleEditWorker(worker)}
|
||||
className="mt-2 w-full text-xs bg-blue-600 text-white px-2 py-1 rounded-md hover:bg-blue-700 flex items-center justify-center space-x-1"
|
||||
>
|
||||
<Edit3 className="w-3 h-3" />
|
||||
<span>수정</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredWorkers.length === 0 && (
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
해당 조건의 작업자가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4 text-center text-gray-500">
|
||||
<Calendar className="w-12 h-12 mx-auto mb-3 text-gray-300" />
|
||||
<p>날짜를 선택하면</p>
|
||||
<p>작업자 검증 내역을 확인할 수 있습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttendanceValidationPage;
|
||||
62
fastapi-bridge/static/pages/admin/manage-issue.html
Normal file
62
fastapi-bridge/static/pages/admin/manage-issue.html
Normal file
@@ -0,0 +1,62 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>이슈 유형 관리 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/main-layout.css">
|
||||
<link rel="stylesheet" href="/css/admin.css">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-layout">
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<div id="content-container">
|
||||
<div class="page-header">
|
||||
<h1>⚙️ 이슈 유형 관리</h1>
|
||||
<p class="subtitle">프로젝트에서 발생하는 이슈 유형을 관리합니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>새 이슈 유형 등록</h3>
|
||||
<form id="issueTypeForm" class="form-horizontal">
|
||||
<div class="form-row">
|
||||
<input type="text" id="category" placeholder="카테고리" required>
|
||||
<input type="text" id="subcategory" placeholder="서브카테고리" required>
|
||||
<button type="submit" class="btn btn-primary">등록</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>등록된 이슈 유형</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>카테고리</th>
|
||||
<th>서브카테고리</th>
|
||||
<th>작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="issueTypeTableBody">
|
||||
<tr><td colspan="4" class="text-center">불러오는 중...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module" src="/js/manage-issue.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
62
fastapi-bridge/static/pages/admin/manage-pipespec.html
Normal file
62
fastapi-bridge/static/pages/admin/manage-pipespec.html
Normal file
@@ -0,0 +1,62 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>파이프 스펙 관리 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/main-layout.css">
|
||||
<link rel="stylesheet" href="/css/admin.css" />
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-layout">
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<div id="content-container">
|
||||
<div class="page-header">
|
||||
<h1>🧪 파이프 스펙 관리</h1>
|
||||
<p class="subtitle">배관 사양 정보를 등록하고 관리합니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>새 파이프 스펙 등록</h3>
|
||||
<form id="specForm" class="form-horizontal">
|
||||
<div class="form-row">
|
||||
<input type="text" id="material" placeholder="재질 (예: STS304)" required />
|
||||
<input type="text" id="diameter_in" placeholder="직경 (예: 1, 1/2)" required />
|
||||
<input type="text" id="schedule" placeholder="스케줄 (예: SCH40)" required />
|
||||
<button type="submit" class="btn btn-primary">등록</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>등록된 파이프 스펙</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>스펙</th>
|
||||
<th>작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="specTableBody">
|
||||
<tr><td colspan="3" class="text-center">불러오는 중...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module" src="/js/manage-pipespec.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
76
fastapi-bridge/static/pages/admin/manage-project.html
Normal file
76
fastapi-bridge/static/pages/admin/manage-project.html
Normal file
@@ -0,0 +1,76 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>프로젝트 관리 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/main-layout.css">
|
||||
<link rel="stylesheet" href="/css/admin.css">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-layout">
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<div id="content-container">
|
||||
<div class="page-header">
|
||||
<h1>🏗 프로젝트 관리</h1>
|
||||
<p class="subtitle">진행 중인 프로젝트를 등록하고 관리합니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>새 프로젝트 등록</h3>
|
||||
<form id="projectForm" class="form-vertical">
|
||||
<div class="form-row">
|
||||
<input type="text" id="job_no" placeholder="공사번호" required>
|
||||
<input type="text" id="project_name" placeholder="프로젝트명" required>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<input type="date" id="contract_date" placeholder="계약일">
|
||||
<input type="date" id="due_date" placeholder="납기일">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<input type="text" id="delivery_method" placeholder="납품방식">
|
||||
<input type="text" id="site" placeholder="현장명">
|
||||
<input type="text" id="pm" placeholder="담당 PM">
|
||||
<button type="submit" class="btn btn-primary">등록</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>등록된 프로젝트</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>공사번호</th>
|
||||
<th>프로젝트명</th>
|
||||
<th>계약일</th>
|
||||
<th>납기일</th>
|
||||
<th>납품방식</th>
|
||||
<th>현장</th>
|
||||
<th>PM</th>
|
||||
<th>작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="projectTableBody">
|
||||
<tr><td colspan="9" class="text-center">불러오는 중...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module" src="/js/manage-project.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
68
fastapi-bridge/static/pages/admin/manage-task.html
Normal file
68
fastapi-bridge/static/pages/admin/manage-task.html
Normal file
@@ -0,0 +1,68 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>작업 항목 관리 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/main-layout.css">
|
||||
<link rel="stylesheet" href="/css/admin.css">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-layout">
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<div id="content-container">
|
||||
<div class="page-header">
|
||||
<h1>🛠 작업 항목 관리</h1>
|
||||
<p class="subtitle">프로젝트에서 수행되는 작업 항목을 관리합니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>새 작업 항목 등록</h3>
|
||||
<form id="taskForm" class="form-vertical">
|
||||
<div class="form-row">
|
||||
<input type="text" id="category" placeholder="카테고리" required>
|
||||
<input type="text" id="subcategory" placeholder="서브카테고리" required>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<input type="text" id="task_name" placeholder="작업명" required>
|
||||
<input type="text" id="description" placeholder="설명">
|
||||
<button type="submit" class="btn btn-primary">등록</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>등록된 작업 항목</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>카테고리</th>
|
||||
<th>서브카테고리</th>
|
||||
<th>작업명</th>
|
||||
<th>설명</th>
|
||||
<th>작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="taskTableBody">
|
||||
<tr><td colspan="6" class="text-center">불러오는 중...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module" src="/js/manage-task.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
106
fastapi-bridge/static/pages/admin/manage-user.html
Normal file
106
fastapi-bridge/static/pages/admin/manage-user.html
Normal file
@@ -0,0 +1,106 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>👤 사용자 관리 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/main-layout.css">
|
||||
<link rel="stylesheet" href="/css/admin.css">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-layout">
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<div id="content-container">
|
||||
<div class="page-header">
|
||||
<h1>👤 사용자 관리</h1>
|
||||
<p class="subtitle">시스템 사용자를 등록하고 관리합니다.</p>
|
||||
</div>
|
||||
|
||||
<!-- 내 비밀번호 변경 -->
|
||||
<div class="card">
|
||||
<h3>🔐 내 비밀번호 변경</h3>
|
||||
<form id="myPasswordForm" class="form-horizontal">
|
||||
<div class="form-row">
|
||||
<input type="password" id="currentPassword" placeholder="현재 비밀번호" required />
|
||||
<input type="password" id="newPassword" placeholder="새 비밀번호" required />
|
||||
<input type="password" id="confirmPassword" placeholder="새 비밀번호 확인" required />
|
||||
<button type="submit" class="btn btn-warning">내 비밀번호 변경</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 등록 폼 -->
|
||||
<div class="card">
|
||||
<h3>새 사용자 등록</h3>
|
||||
<form id="userForm" class="form-horizontal">
|
||||
<div class="form-row">
|
||||
<input type="text" id="username" placeholder="아이디" required />
|
||||
<input type="password" id="password" placeholder="비밀번호" required />
|
||||
<input type="text" id="name" placeholder="이름" required />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<select id="access_level" required>
|
||||
<option value="">권한 선택</option>
|
||||
<option value="worker">작업자</option>
|
||||
<option value="group_leader">그룹장</option>
|
||||
<option value="support_team">지원팀</option>
|
||||
<option value="admin">관리자</option>
|
||||
<option value="system">시스템</option>
|
||||
</select>
|
||||
<select id="worker_id">
|
||||
<option value="">작업자 연결 (선택)</option>
|
||||
</select>
|
||||
<button type="submit" class="btn btn-primary">등록</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 사용자 비밀번호 변경 (시스템 권한자만) -->
|
||||
<div class="card" id="systemPasswordChangeCard" style="display: none;">
|
||||
<h3>🔑 사용자 비밀번호 변경 (시스템 권한자 전용)</h3>
|
||||
<form id="userPasswordForm" class="form-horizontal">
|
||||
<div class="form-row">
|
||||
<select id="targetUserId" required>
|
||||
<option value="">사용자 선택</option>
|
||||
</select>
|
||||
<input type="password" id="targetNewPassword" placeholder="새 비밀번호" required />
|
||||
<button type="submit" class="btn btn-danger">비밀번호 변경</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 사용자 목록 -->
|
||||
<div class="card">
|
||||
<h3>등록된 사용자</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>아이디</th>
|
||||
<th>이름</th>
|
||||
<th>권한</th>
|
||||
<th>연결 작업자</th>
|
||||
<th>작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="userTableBody">
|
||||
<tr><td colspan="6" class="text-center">불러오는 중...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module" src="/js/manage-user.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
62
fastapi-bridge/static/pages/admin/manage-worker.html
Normal file
62
fastapi-bridge/static/pages/admin/manage-worker.html
Normal file
@@ -0,0 +1,62 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>작업자 관리 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/main-layout.css">
|
||||
<link rel="stylesheet" href="/css/admin.css">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-layout">
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<div id="content-container">
|
||||
<div class="page-header">
|
||||
<h1>👷 작업자 관리</h1>
|
||||
<p class="subtitle">프로젝트에 참여하는 작업자를 관리합니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>새 작업자 등록</h3>
|
||||
<form id="workerForm" class="form-horizontal">
|
||||
<div class="form-row">
|
||||
<input type="text" id="workerName" placeholder="작업자명" required>
|
||||
<input type="text" id="position" placeholder="직책">
|
||||
<button type="submit" class="btn btn-primary">등록</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>등록된 작업자</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>이름</th>
|
||||
<th>직책</th>
|
||||
<th>작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="workerTableBody">
|
||||
<tr><td colspan="4" class="text-center">불러오는 중...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module" src="/js/manage-worker.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
109
fastapi-bridge/static/pages/admin/work-review.html
Normal file
109
fastapi-bridge/static/pages/admin/work-review.html
Normal file
@@ -0,0 +1,109 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>작업 검토 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/main-layout.css">
|
||||
<link rel="stylesheet" href="/css/work-review.css">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-layout-with-navbar">
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<div class="review-container">
|
||||
<!-- 뒤로가기 버튼 -->
|
||||
<a href="javascript:history.back()" class="back-btn">
|
||||
← 뒤로가기
|
||||
</a>
|
||||
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="page-header">
|
||||
<h1>📋 작업 검토 대시보드</h1>
|
||||
<p class="subtitle">캘린더에서 날짜를 클릭하여 해당 날짜의 작업 현황을 확인하고 검토하세요</p>
|
||||
</div>
|
||||
|
||||
<!-- 메시지 영역 -->
|
||||
<div id="message-container"></div>
|
||||
|
||||
<!-- 컨트롤 패널 -->
|
||||
<div class="control-panel">
|
||||
<div class="month-navigation">
|
||||
<button class="nav-btn" id="prevMonth">‹ 이전</button>
|
||||
<div class="current-month" id="currentMonth">2025년 6월</div>
|
||||
<button class="nav-btn" id="nextMonth">다음 ›</button>
|
||||
</div>
|
||||
<div class="control-actions">
|
||||
<button class="today-btn" id="goToday">📅 오늘</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 사용법 안내 -->
|
||||
<div class="usage-guide">
|
||||
<h3>📖 사용법</h3>
|
||||
<div class="guide-grid">
|
||||
<div class="guide-item">
|
||||
<div class="guide-icon">👆</div>
|
||||
<div class="guide-text">
|
||||
<strong>날짜 클릭</strong><br>
|
||||
캘린더에서 날짜를 클릭하면 해당 날짜의 작업 정보를 확인할 수 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
<div class="guide-item">
|
||||
<div class="guide-icon">✏️</div>
|
||||
<div class="guide-text">
|
||||
<strong>작업 수정</strong><br>
|
||||
각 작업 항목의 수정 버튼을 클릭하여 프로젝트, 작업 유형, 시간 등을 수정할 수 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
<div class="guide-item">
|
||||
<div class="guide-icon">🗑️</div>
|
||||
<div class="guide-text">
|
||||
<strong>작업 삭제</strong><br>
|
||||
개별 작업 또는 작업자의 모든 작업을 삭제할 수 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
<div class="guide-item">
|
||||
<div class="guide-icon">✅</div>
|
||||
<div class="guide-text">
|
||||
<strong>검토 완료</strong><br>
|
||||
작업 검토가 완료되면 검토 완료 버튼을 클릭하여 상태를 변경할 수 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 캘린더 -->
|
||||
<div class="calendar-container">
|
||||
<h3 style="margin-bottom: 1rem;">📅 캘린더</h3>
|
||||
<div class="calendar-grid" id="calendar">
|
||||
<!-- 요일 헤더 -->
|
||||
<div class="day-header">일</div>
|
||||
<div class="day-header">월</div>
|
||||
<div class="day-header">화</div>
|
||||
<div class="day-header">수</div>
|
||||
<div class="day-header">목</div>
|
||||
<div class="day-header">금</div>
|
||||
<div class="day-header">토</div>
|
||||
<!-- 날짜 셀들이 여기에 동적으로 추가됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 선택된 날짜 정보 -->
|
||||
<div class="day-info-panel">
|
||||
<div id="day-info-container">
|
||||
<!-- 날짜별 정보가 여기에 동적으로 추가됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 스크립트 -->
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script src="/js/work-review.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
890
fastapi-bridge/static/pages/analysis/daily_work_analysis.html
Normal file
890
fastapi-bridge/static/pages/analysis/daily_work_analysis.html
Normal file
@@ -0,0 +1,890 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>작업 현황 분석</title>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/3.9.1/chart.min.js"></script>
|
||||
<style>
|
||||
* {
|
||||
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.6;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 28px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.date-selector {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.date-selector input {
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: rgba(255,255,255,0.2);
|
||||
color: white;
|
||||
border: 1px solid rgba(255,255,255,0.3);
|
||||
padding: 8px 16px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: rgba(255,255,255,0.3);
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
padding: 25px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
text-align: center;
|
||||
transition: transform 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 5px 20px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.stat-change {
|
||||
font-size: 12px;
|
||||
margin-top: 5px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 15px;
|
||||
}
|
||||
|
||||
.stat-change.positive {
|
||||
background: #e8f5e8;
|
||||
color: #2e7d2e;
|
||||
}
|
||||
|
||||
.stat-change.negative {
|
||||
background: #ffeaea;
|
||||
color: #c53030;
|
||||
}
|
||||
|
||||
.charts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 15px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.chart-canvas {
|
||||
position: relative;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
padding: 15px 20px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 12px 15px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.table th {
|
||||
background: #f8f9fa;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.table tr:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-completed {
|
||||
background: #e8f5e8;
|
||||
color: #2e7d2e;
|
||||
}
|
||||
|
||||
.status-progress {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin: 10px 0;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.debug-info {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
margin: 10px 0;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.charts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.date-selector {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>📊 일일 작업 현황 분석</h1>
|
||||
<p>실시간 작업 현황과 주요 지표를 확인하세요</p>
|
||||
<div class="date-selector">
|
||||
<label>조회 기간:</label>
|
||||
<input type="date" id="startDate">
|
||||
<span>~</span>
|
||||
<input type="date" id="endDate">
|
||||
<button class="btn" onclick="loadData()">조회</button>
|
||||
<button class="btn" onclick="setToday()">오늘</button>
|
||||
<button class="btn" onclick="setThisWeek()">이번주</button>
|
||||
<button class="btn" onclick="setThisMonth()">이번달</button>
|
||||
<button class="btn" onclick="toggleDebug()">디버그</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="errorContainer"></div>
|
||||
<div id="debugInfo" class="debug-info"></div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-number" id="totalHours">0</div>
|
||||
<div class="stat-label">총 작업시간</div>
|
||||
<div class="stat-change positive" id="hoursChange">+0%</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-number" id="totalReports">0</div>
|
||||
<div class="stat-label">보고서 건수</div>
|
||||
<div class="stat-change positive" id="reportsChange">+0%</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-number" id="activeProjects">0</div>
|
||||
<div class="stat-label">진행 프로젝트</div>
|
||||
<div class="stat-change" id="projectsChange">+0%</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-number" id="errorRate">0%</div>
|
||||
<div class="stat-label">에러율</div>
|
||||
<div class="stat-change negative" id="errorChange">+0%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="charts-grid">
|
||||
<div class="chart-container">
|
||||
<div class="chart-title">📈 일별 작업시간 추이</div>
|
||||
<div class="chart-canvas">
|
||||
<canvas id="dailyHoursChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<div class="chart-title">👥 작업자별 작업량</div>
|
||||
<div class="chart-canvas">
|
||||
<canvas id="workerChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="charts-grid">
|
||||
<div class="chart-container">
|
||||
<div class="chart-title">🏗️ 프로젝트별 투입시간</div>
|
||||
<div class="chart-canvas">
|
||||
<canvas id="projectChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<div class="chart-title">⚙️ 작업 유형별 분포</div>
|
||||
<div class="chart-canvas">
|
||||
<canvas id="workTypeChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<div class="table-header">🔍 최근 작업 현황</div>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>날짜</th>
|
||||
<th>작업자</th>
|
||||
<th>프로젝트</th>
|
||||
<th>작업유형</th>
|
||||
<th>작업시간</th>
|
||||
<th>상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="recentWorkTable">
|
||||
<tr>
|
||||
<td colspan="6" class="loading">데이터를 불러오는 중...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 전역 변수
|
||||
let API_URL = 'http://192.168.0.3:3005';
|
||||
let dailyChart, workerChart, projectChart, workTypeChart;
|
||||
let debugMode = false;
|
||||
|
||||
// 디버그 함수
|
||||
function log(message, data = null) {
|
||||
console.log(message, data);
|
||||
if (debugMode) {
|
||||
const debugDiv = document.getElementById('debugInfo');
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
debugDiv.innerHTML += `[${timestamp}] ${message}${data ? ': ' + JSON.stringify(data, null, 2) : ''}<br>`;
|
||||
debugDiv.scrollTop = debugDiv.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDebug() {
|
||||
debugMode = !debugMode;
|
||||
const debugDiv = document.getElementById('debugInfo');
|
||||
debugDiv.style.display = debugMode ? 'block' : 'none';
|
||||
if (debugMode) {
|
||||
debugDiv.innerHTML = '=== 디버그 모드 활성화 ===<br>';
|
||||
}
|
||||
}
|
||||
|
||||
function showError(message, details = null) {
|
||||
const errorContainer = document.getElementById('errorContainer');
|
||||
errorContainer.innerHTML = `
|
||||
<div class="error-message">
|
||||
<strong>오류:</strong> ${message}
|
||||
${details ? `<br><small>${details}</small>` : ''}
|
||||
</div>
|
||||
`;
|
||||
log('ERROR: ' + message, details);
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
document.getElementById('errorContainer').innerHTML = '';
|
||||
}
|
||||
|
||||
// API 설정 초기화
|
||||
async function initializeAPI() {
|
||||
log('API 초기화 시작');
|
||||
|
||||
// 기존 API 설정 파일 시도
|
||||
try {
|
||||
const module = await import('/js/api-config.js');
|
||||
if (module.API && module.API.BASE_URL) {
|
||||
API_URL = module.API.BASE_URL;
|
||||
log('API config 파일에서 설정 로드', API_URL);
|
||||
} else {
|
||||
log('API config 파일에서 BASE_URL을 찾을 수 없음');
|
||||
}
|
||||
} catch (error) {
|
||||
log('API config 파일 로드 실패, 기본값 사용', error.message);
|
||||
}
|
||||
|
||||
// API 연결 테스트
|
||||
await testAPIConnection();
|
||||
}
|
||||
|
||||
// API 연결 테스트 함수
|
||||
async function testAPIConnection() {
|
||||
const testUrls = [
|
||||
{ url: 'http://192.168.0.3:3005/api', hasApi: false }, // 직접 연결 우선
|
||||
{ url: 'http://192.168.0.3:3001', hasApi: true } // nginx 프록시
|
||||
];
|
||||
|
||||
for (const testConfig of testUrls) {
|
||||
try {
|
||||
const healthUrl = testConfig.hasApi
|
||||
? `${testConfig.url}/health`
|
||||
: `${testConfig.url}/health`;
|
||||
|
||||
log(`API 연결 테스트: ${healthUrl}`);
|
||||
const response = await fetch(healthUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.text();
|
||||
// HTML 응답이면 실패로 간주
|
||||
if (data.includes('<!DOCTYPE html>')) {
|
||||
log(`❌ HTML 응답 (로그인 페이지): ${healthUrl}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
API_URL = testConfig.hasApi ? testConfig.url : testConfig.url;
|
||||
log(`✅ API 연결 성공: ${API_URL}`);
|
||||
return;
|
||||
} else {
|
||||
log(`❌ API 연결 실패 (${response.status}): ${healthUrl}`);
|
||||
}
|
||||
} catch (error) {
|
||||
log(`❌ API 연결 오류: ${testConfig.url}`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 모든 URL 실패 시 직접 연결 사용
|
||||
API_URL = 'http://192.168.0.3:3005/api';
|
||||
log(`⚠️ 모든 연결 실패, 직접 연결 사용: ${API_URL}`);
|
||||
}
|
||||
|
||||
// 차트 초기화
|
||||
function initializeCharts() {
|
||||
log('차트 초기화 시작');
|
||||
|
||||
try {
|
||||
// 일별 작업시간 차트
|
||||
const dailyCtx = document.getElementById('dailyHoursChart').getContext('2d');
|
||||
dailyChart = new Chart(dailyCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
label: '작업시간',
|
||||
data: [],
|
||||
borderColor: '#667eea',
|
||||
backgroundColor: 'rgba(102, 126, 234, 0.1)',
|
||||
tension: 0.4,
|
||||
fill: true
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: '시간'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 작업자별 차트
|
||||
const workerCtx = document.getElementById('workerChart').getContext('2d');
|
||||
workerChart = new Chart(workerCtx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
data: [],
|
||||
backgroundColor: [
|
||||
'#667eea',
|
||||
'#764ba2',
|
||||
'#f093fb',
|
||||
'#f5576c',
|
||||
'#4facfe',
|
||||
'#43e97b'
|
||||
]
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 프로젝트별 차트
|
||||
const projectCtx = document.getElementById('projectChart').getContext('2d');
|
||||
projectChart = new Chart(projectCtx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
label: '투입시간',
|
||||
data: [],
|
||||
backgroundColor: '#667eea'
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: '시간'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 작업유형별 차트
|
||||
const workTypeCtx = document.getElementById('workTypeChart').getContext('2d');
|
||||
workTypeChart = new Chart(workTypeCtx, {
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
data: [],
|
||||
backgroundColor: [
|
||||
'#667eea',
|
||||
'#764ba2',
|
||||
'#f093fb',
|
||||
'#f5576c',
|
||||
'#4facfe',
|
||||
'#43e97b',
|
||||
'#f6d55c',
|
||||
'#ed4a7b'
|
||||
]
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
log('차트 초기화 완료');
|
||||
} catch (error) {
|
||||
log('차트 초기화 실패', error);
|
||||
showError('차트 초기화에 실패했습니다.', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 날짜 설정 함수들
|
||||
function setToday() {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
document.getElementById('startDate').value = today;
|
||||
document.getElementById('endDate').value = today;
|
||||
log('오늘 날짜 설정', today);
|
||||
loadData();
|
||||
}
|
||||
|
||||
function setThisWeek() {
|
||||
const today = new Date();
|
||||
const monday = new Date(today.setDate(today.getDate() - today.getDay() + 1));
|
||||
const sunday = new Date(today.setDate(today.getDate() - today.getDay() + 7));
|
||||
|
||||
const mondayStr = monday.toISOString().split('T')[0];
|
||||
const sundayStr = sunday.toISOString().split('T')[0];
|
||||
|
||||
document.getElementById('startDate').value = mondayStr;
|
||||
document.getElementById('endDate').value = sundayStr;
|
||||
log('이번주 날짜 설정', { start: mondayStr, end: sundayStr });
|
||||
loadData();
|
||||
}
|
||||
|
||||
function setThisMonth() {
|
||||
const today = new Date();
|
||||
const firstDay = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
const lastDay = new Date(today.getFullYear(), today.getMonth() + 1, 0);
|
||||
|
||||
const firstDayStr = firstDay.toISOString().split('T')[0];
|
||||
const lastDayStr = lastDay.toISOString().split('T')[0];
|
||||
|
||||
document.getElementById('startDate').value = firstDayStr;
|
||||
document.getElementById('endDate').value = lastDayStr;
|
||||
log('이번달 날짜 설정', { start: firstDayStr, end: lastDayStr });
|
||||
loadData();
|
||||
}
|
||||
|
||||
// 토큰 확인 함수
|
||||
function checkToken() {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
showError('로그인이 필요합니다.');
|
||||
setTimeout(() => {
|
||||
location.href = '/login';
|
||||
}, 2000);
|
||||
return null;
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
// API 호출 헬퍼 함수
|
||||
async function makeAPICall(url, options = {}) {
|
||||
log('API 호출', url);
|
||||
|
||||
const token = checkToken();
|
||||
if (!token) return null;
|
||||
|
||||
const defaultOptions = {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
};
|
||||
|
||||
const finalOptions = { ...defaultOptions, ...options };
|
||||
log('요청 옵션', finalOptions);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, finalOptions);
|
||||
log('응답 상태', { status: response.status, statusText: response.statusText });
|
||||
|
||||
const responseText = await response.text();
|
||||
log('응답 내용 (텍스트)', responseText);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${responseText}`);
|
||||
}
|
||||
|
||||
const data = JSON.parse(responseText);
|
||||
log('파싱된 데이터', data);
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
log('API 호출 실패', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 데이터 로딩
|
||||
async function loadData() {
|
||||
clearError();
|
||||
log('데이터 로딩 시작');
|
||||
|
||||
const startDate = document.getElementById('startDate').value;
|
||||
const endDate = document.getElementById('endDate').value;
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
showError('조회 기간을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
log('조회 기간', { startDate, endDate });
|
||||
|
||||
try {
|
||||
// 기본 통계 조회
|
||||
log('기본 통계 조회 시작');
|
||||
const statsUrl = `${API_URL}/work-analysis/stats?start=${startDate}&end=${endDate}`;
|
||||
const statsData = await makeAPICall(statsUrl);
|
||||
|
||||
if (statsData) {
|
||||
updateStats(statsData.data || statsData);
|
||||
}
|
||||
|
||||
// 일별 추이 데이터
|
||||
log('일별 추이 조회 시작');
|
||||
const dailyUrl = `${API_URL}/work-analysis/daily-trend?start=${startDate}&end=${endDate}`;
|
||||
const dailyData = await makeAPICall(dailyUrl);
|
||||
|
||||
if (dailyData) {
|
||||
updateDailyChart(dailyData.data || dailyData);
|
||||
}
|
||||
|
||||
// 작업자별 데이터
|
||||
log('작업자별 통계 조회 시작');
|
||||
const workerUrl = `${API_URL}/work-analysis/worker-stats?start=${startDate}&end=${endDate}`;
|
||||
const workerData = await makeAPICall(workerUrl);
|
||||
|
||||
if (workerData) {
|
||||
updateWorkerChart(workerData.data || workerData);
|
||||
}
|
||||
|
||||
// 프로젝트별 데이터
|
||||
log('프로젝트별 통계 조회 시작');
|
||||
const projectUrl = `${API_URL}/work-analysis/project-stats?start=${startDate}&end=${endDate}`;
|
||||
const projectData = await makeAPICall(projectUrl);
|
||||
|
||||
if (projectData) {
|
||||
updateProjectChart(projectData.data || projectData);
|
||||
}
|
||||
|
||||
// 작업유형별 데이터
|
||||
log('작업유형별 통계 조회 시작');
|
||||
const workTypeUrl = `${API_URL}/work-analysis/worktype-stats?start=${startDate}&end=${endDate}`;
|
||||
const workTypeData = await makeAPICall(workTypeUrl);
|
||||
|
||||
if (workTypeData) {
|
||||
updateWorkTypeChart(workTypeData.data || workTypeData);
|
||||
}
|
||||
|
||||
// 최근 작업 현황
|
||||
log('최근 작업 현황 조회 시작');
|
||||
const recentUrl = `${API_URL}/work-analysis/recent-work?start=${startDate}&end=${endDate}&limit=10`;
|
||||
const recentData = await makeAPICall(recentUrl);
|
||||
|
||||
if (recentData) {
|
||||
updateRecentWorkTable(recentData.data || recentData);
|
||||
}
|
||||
|
||||
log('모든 데이터 로딩 완료');
|
||||
|
||||
} catch (error) {
|
||||
log('데이터 로딩 오류', error);
|
||||
|
||||
if (error.message.includes('403')) {
|
||||
showError('접근 권한이 없습니다. 관리자에게 문의하세요.');
|
||||
} else if (error.message.includes('401')) {
|
||||
showError('로그인이 만료되었습니다. 다시 로그인해주세요.');
|
||||
setTimeout(() => {
|
||||
location.href = '/login';
|
||||
}, 2000);
|
||||
} else {
|
||||
showError('데이터를 불러오는 중 오류가 발생했습니다.', error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 통계 업데이트
|
||||
function updateStats(stats) {
|
||||
log('통계 업데이트', stats);
|
||||
|
||||
document.getElementById('totalHours').textContent = (stats.totalHours || 0).toFixed(1);
|
||||
document.getElementById('totalReports').textContent = stats.totalReports || 0;
|
||||
document.getElementById('activeProjects').textContent = stats.activeProjects || 0;
|
||||
document.getElementById('errorRate').textContent = (stats.errorRate || 0).toFixed(1) + '%';
|
||||
|
||||
// 변화율 표시 (임시 데이터)
|
||||
document.getElementById('hoursChange').textContent = '+12.5%';
|
||||
document.getElementById('reportsChange').textContent = '+8.3%';
|
||||
document.getElementById('projectsChange').textContent = '+2';
|
||||
document.getElementById('errorChange').textContent = '-0.5%';
|
||||
}
|
||||
|
||||
// 차트 업데이트 함수들
|
||||
function updateDailyChart(data) {
|
||||
log('일별 차트 업데이트', data);
|
||||
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
log('일별 차트 데이터 없음');
|
||||
return;
|
||||
}
|
||||
|
||||
dailyChart.data.labels = data.map(item => item.date);
|
||||
dailyChart.data.datasets[0].data = data.map(item => parseFloat(item.hours) || 0);
|
||||
dailyChart.update();
|
||||
}
|
||||
|
||||
function updateWorkerChart(data) {
|
||||
log('작업자 차트 업데이트', data);
|
||||
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
log('작업자 차트 데이터 없음');
|
||||
return;
|
||||
}
|
||||
|
||||
workerChart.data.labels = data.map(item => item.workerName || `작업자 ${item.worker_id}`);
|
||||
workerChart.data.datasets[0].data = data.map(item => parseFloat(item.totalHours) || 0);
|
||||
workerChart.update();
|
||||
}
|
||||
|
||||
function updateProjectChart(data) {
|
||||
log('프로젝트 차트 업데이트', data);
|
||||
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
log('프로젝트 차트 데이터 없음');
|
||||
return;
|
||||
}
|
||||
|
||||
projectChart.data.labels = data.map(item => item.projectName || `프로젝트 ${item.project_id}`);
|
||||
projectChart.data.datasets[0].data = data.map(item => parseFloat(item.totalHours) || 0);
|
||||
projectChart.update();
|
||||
}
|
||||
|
||||
function updateWorkTypeChart(data) {
|
||||
log('작업유형 차트 업데이트', data);
|
||||
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
log('작업유형 차트 데이터 없음');
|
||||
return;
|
||||
}
|
||||
|
||||
workTypeChart.data.labels = data.map(item => item.workTypeName || `작업유형 ${item.work_type_id}`);
|
||||
workTypeChart.data.datasets[0].data = data.map(item => parseFloat(item.totalHours) || 0);
|
||||
workTypeChart.update();
|
||||
}
|
||||
|
||||
// 테이블 업데이트
|
||||
function updateRecentWorkTable(data) {
|
||||
log('테이블 업데이트', data);
|
||||
|
||||
const tbody = document.getElementById('recentWorkTable');
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="text-align: center; color: #666;">조회된 데이터가 없습니다.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = data.map(item => `
|
||||
<tr>
|
||||
<td>${item.report_date}</td>
|
||||
<td>작업자 ${item.worker_id}</td>
|
||||
<td>프로젝트 ${item.project_id}</td>
|
||||
<td>작업유형 ${item.work_type_id}</td>
|
||||
<td>${item.work_hours}시간</td>
|
||||
<td><span class="status-badge ${getStatusClass(item.work_status_id)}">${getStatusText(item.work_status_id)}</span></td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function getStatusClass(statusId) {
|
||||
switch(statusId) {
|
||||
case 1: return 'status-completed';
|
||||
case 2: return 'status-progress';
|
||||
default: return 'status-pending';
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusText(statusId) {
|
||||
switch(statusId) {
|
||||
case 1: return '완료';
|
||||
case 2: return '진행중';
|
||||
default: return '대기';
|
||||
}
|
||||
}
|
||||
|
||||
// 페이지 초기화
|
||||
async function initializePage() {
|
||||
log('페이지 초기화 시작');
|
||||
|
||||
try {
|
||||
await initializeAPI();
|
||||
initializeCharts();
|
||||
setToday(); // 오늘 날짜로 초기 설정 및 데이터 로드
|
||||
|
||||
log('페이지 초기화 완료');
|
||||
} catch (error) {
|
||||
log('페이지 초기화 실패', error);
|
||||
showError('페이지 초기화에 실패했습니다.', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', initializePage);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user