feat: 3-System 분리 프로젝트 초기 코드 작성

TK-FB(공장관리+신고)와 M-Project(부적합관리)를 3개 독립 시스템으로
분리하기 위한 전체 코드 구조 작성.
- SSO 인증 서비스 (bcrypt + pbkdf2 이중 해시 지원)
- System 1: 공장관리 (TK-FB 기반, 신고 코드 제거)
- System 2: 신고 (TK-FB에서 workIssue 코드 추출)
- System 3: 부적합관리 (M-Project 기반)
- Gateway 포털 (path-based 라우팅)
- 통합 docker-compose.yml 및 배포 스크립트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-02-09 14:40:11 +09:00
commit 550633b89d
824 changed files with 1071683 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
FROM nginx:alpine
# 정적 파일 복사
COPY . /usr/share/nginx/html/
# Nginx 설정 파일 복사 (선택사항)
# COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

File diff suppressed because it is too large Load Diff

View 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": "하주현"
}
]
}
]

View File

@@ -0,0 +1,135 @@
<!-- components/mobile-nav.html -->
<!-- 모바일 하단 네비게이션 -->
<nav class="mobile-bottom-nav" id="mobileBottomNav">
<a href="/pages/dashboard.html" class="mobile-nav-item" data-page="dashboard">
<span class="mobile-nav-icon">🏠</span>
<span class="mobile-nav-label"></span>
</a>
<a href="/pages/work/tbm.html" class="mobile-nav-item" data-page="tbm">
<span class="mobile-nav-icon">📋</span>
<span class="mobile-nav-label">TBM</span>
</a>
<a href="/pages/work/report-create.html" class="mobile-nav-item" data-page="report">
<span class="mobile-nav-icon">📝</span>
<span class="mobile-nav-label">작업보고</span>
</a>
<a href="/pages/attendance/checkin.html" class="mobile-nav-item" data-page="checkin">
<span class="mobile-nav-icon"></span>
<span class="mobile-nav-label">출근</span>
</a>
<button class="mobile-nav-item" id="mobileMoreBtn">
<span class="mobile-nav-icon"></span>
<span class="mobile-nav-label">메뉴</span>
</button>
</nav>
<style>
/* 모바일 하단 네비게이션 */
.mobile-bottom-nav {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 64px;
background: #ffffff;
border-top: 1px solid #e5e7eb;
box-shadow: 0 -2px 10px rgba(0,0,0,0.1);
z-index: 1000;
padding-bottom: env(safe-area-inset-bottom);
}
@media (max-width: 768px) {
.mobile-bottom-nav {
display: flex;
align-items: center;
justify-content: space-around;
}
/* 바디 패딩 추가 */
body {
padding-bottom: calc(64px + env(safe-area-inset-bottom)) !important;
}
}
.mobile-nav-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
height: 100%;
text-decoration: none;
color: #6b7280;
background: none;
border: none;
font-family: inherit;
cursor: pointer;
transition: color 0.2s;
padding: 0.5rem;
-webkit-tap-highlight-color: transparent;
}
.mobile-nav-item:active {
background: #f3f4f6;
}
.mobile-nav-item.active {
color: #2563eb;
}
.mobile-nav-icon {
font-size: 1.5rem;
line-height: 1;
margin-bottom: 0.25rem;
}
.mobile-nav-label {
font-size: 0.6875rem;
font-weight: 500;
line-height: 1;
}
/* 활성 상태 */
.mobile-nav-item.active .mobile-nav-icon {
transform: scale(1.1);
}
.mobile-nav-item.active .mobile-nav-label {
font-weight: 600;
}
</style>
<script>
(function() {
// 현재 페이지 하이라이트
const currentPath = window.location.pathname;
const navItems = document.querySelectorAll('.mobile-nav-item[data-page]');
navItems.forEach(item => {
const href = item.getAttribute('href');
if (href && currentPath.includes(href.replace('/pages/', '').replace('.html', ''))) {
item.classList.add('active');
}
});
// 대시보드 페이지 체크
if (currentPath.includes('dashboard')) {
document.querySelector('[data-page="dashboard"]')?.classList.add('active');
}
// 더보기 버튼 - 사이드바 열기
const moreBtn = document.getElementById('mobileMoreBtn');
if (moreBtn) {
moreBtn.addEventListener('click', () => {
const sidebar = document.getElementById('sidebarNav');
const overlay = document.getElementById('sidebarOverlay');
if (sidebar) {
sidebar.classList.add('mobile-open');
overlay?.classList.add('show');
document.body.classList.add('sidebar-mobile-open');
}
});
}
})();
</script>

View File

@@ -0,0 +1,733 @@
<!-- components/navbar.html -->
<!-- 최신 대시보드 헤더 -->
<header class="dashboard-header">
<div class="header-content">
<div class="header-left">
<!-- 모바일 메뉴 버튼 -->
<button class="mobile-menu-btn" id="mobileMenuBtn" aria-label="메뉴 열기">
</button>
<div class="brand">
<img src="/img/logo.png" alt="테크니컬코리아" class="brand-logo">
<div class="brand-text">
<h1 class="brand-title">테크니컬코리아</h1>
<p class="brand-subtitle">생산팀 포털</p>
</div>
</div>
</div>
<div class="header-center">
<div class="datetime-weather-box">
<div class="date-time-section">
<span class="date-value" id="dateValue">--월 --일 (--)</span>
<span class="time-value" id="timeValue">--시 --분 --초</span>
</div>
<div class="weather-section" id="weatherSection">
<span class="weather-icon" id="weatherIcon">🌤️</span>
<span class="weather-temp" id="weatherTemp">--°C</span>
<span class="weather-desc" id="weatherDesc">날씨 로딩중</span>
</div>
</div>
</div>
<div class="header-right">
<!-- 알림 버튼 -->
<div class="notification-wrapper" id="notificationWrapper">
<button class="notification-btn" id="notificationBtn">
<svg class="notification-icon-svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path>
<path d="M13.73 21a2 2 0 0 1-3.46 0"></path>
</svg>
<span class="notification-badge" id="notificationBadge" style="display:none;">0</span>
</button>
<div class="notification-dropdown" id="notificationDropdown">
<div class="notification-header">
<h4>알림</h4>
<a href="/pages/admin/notifications.html" class="view-all-link">모두 보기</a>
</div>
<div class="notification-list" id="notificationList">
<div class="notification-empty">새 알림이 없습니다.</div>
</div>
</div>
</div>
<a href="/pages/dashboard.html" id="dashboardBtn" class="dashboard-btn">
<span class="btn-icon">📊</span>
<span class="btn-text">대시보드</span>
</a>
<a href="/pages/safety/report.html" class="report-btn">
<span class="btn-icon">&#9888;</span>
<span class="btn-text">신고</span>
</a>
<div class="user-profile" id="userProfile">
<div class="user-avatar">
<span class="avatar-text" id="userInitial"></span>
</div>
<div class="user-info">
<span class="user-name" id="userName">사용자</span>
<span class="user-role" id="userRole">작업자</span>
</div>
<div class="profile-menu" id="profileMenu">
<a href="/pages/profile/info.html" class="menu-item">
<span class="menu-icon">👤</span>
내 프로필
</a>
<a href="/pages/profile/password.html" class="menu-item">
<span class="menu-icon">🔐</span>
비밀번호 변경
</a>
<a href="/pages/admin/accounts.html" class="menu-item admin-only">
<span class="menu-icon">⚙️</span>
관리자 설정
</a>
<button class="menu-item logout-btn" id="logoutBtn">
<span class="menu-icon">🚪</span>
로그아웃
</button>
</div>
</div>
</div>
</div>
</header>
<style>
/* 최신 대시보드 헤더 스타일 */
.dashboard-header {
background: var(--header-gradient);
color: var(--text-inverse);
padding: var(--space-4) var(--space-6);
box-shadow: var(--shadow-lg);
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 200;
height: 80px;
display: flex;
align-items: center;
}
/* 헤더 높이만큼 본문 여백 추가 */
body {
padding-top: 80px;
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0 var(--space-4);
}
.header-left .brand {
display: flex;
align-items: center;
gap: var(--space-3);
}
.header-right {
display: flex;
align-items: center;
gap: var(--space-4);
}
.brand-logo {
width: 48px;
height: 48px;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
}
.brand-title {
font-size: var(--text-2xl);
font-weight: var(--font-bold);
margin: 0;
line-height: 1.2;
}
.brand-subtitle {
font-size: var(--text-sm);
opacity: 0.9;
margin: 0;
font-weight: var(--font-normal);
}
/* 날짜/시간/날씨 박스 */
.datetime-weather-box {
display: flex;
align-items: center;
gap: var(--space-4);
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: var(--radius-xl);
padding: var(--space-2) var(--space-5);
}
.date-time-section {
display: flex;
flex-direction: column;
align-items: center;
padding-right: var(--space-4);
border-right: 1px solid rgba(255, 255, 255, 0.2);
}
.date-value {
font-size: var(--text-sm);
opacity: 0.9;
margin-bottom: 2px;
}
.time-value {
font-size: var(--text-xl);
font-weight: var(--font-bold);
font-family: 'Courier New', monospace;
}
.weather-section {
display: flex;
align-items: center;
gap: var(--space-2);
}
.weather-icon {
font-size: 1.75rem;
}
.weather-temp {
font-size: var(--text-lg);
font-weight: var(--font-bold);
}
.weather-desc {
font-size: var(--text-sm);
opacity: 0.9;
}
.header-right .user-profile {
position: relative;
display: flex;
align-items: center;
gap: var(--space-3);
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: var(--radius-full);
padding: var(--space-2) var(--space-4);
cursor: pointer;
transition: var(--transition-normal);
}
.user-profile:hover {
background: rgba(255, 255, 255, 0.2);
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: var(--radius-full);
background: var(--primary-200);
display: flex;
align-items: center;
justify-content: center;
font-weight: var(--font-bold);
color: var(--primary-900);
}
.user-info {
display: flex;
flex-direction: column;
}
.user-name {
font-size: var(--text-sm);
font-weight: var(--font-semibold);
line-height: 1.2;
}
.user-role {
font-size: var(--text-xs);
opacity: 0.8;
}
.profile-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: var(--space-2);
background: linear-gradient(135deg, var(--bg-primary), var(--bg-secondary));
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl);
border: 2px solid rgba(14, 165, 233, 0.2);
min-width: 220px;
opacity: 0;
visibility: hidden;
transform: translateY(-10px);
transition: var(--transition-slow);
z-index: 1000;
backdrop-filter: blur(20px);
}
.user-profile:hover .profile-menu,
.profile-menu:hover {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.menu-item {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-5);
color: var(--text-secondary);
text-decoration: none;
border: none;
background: transparent;
width: 100%;
text-align: left;
font-size: var(--text-sm);
font-weight: var(--font-medium);
cursor: pointer;
transition: var(--transition-slow);
border-radius: var(--radius-md);
margin: var(--space-1);
font-family: inherit;
}
.menu-item:hover {
background: linear-gradient(135deg, var(--gray-100), var(--gray-200));
color: var(--text-primary);
transform: translateX(2px);
}
.menu-item:first-child {
border-radius: var(--radius-md);
margin-top: var(--space-2);
}
.menu-item:last-child {
border-radius: var(--radius-md);
margin-bottom: var(--space-2);
}
.menu-icon {
font-size: var(--text-lg);
width: 1.5rem;
text-align: center;
opacity: 0.8;
}
.menu-item:hover .menu-icon {
opacity: 1;
}
.logout-btn {
color: var(--error-500) !important;
border-top: 1px solid var(--border-light);
margin-top: var(--space-2);
padding-top: var(--space-3);
font-weight: var(--font-semibold);
}
.logout-btn:hover {
background: linear-gradient(135deg, var(--error-50), #fee2e2) !important;
color: var(--error-700) !important;
}
/* 대시보드 버튼 */
.dashboard-btn {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-5);
background: rgba(255, 255, 255, 0.2);
color: white;
text-decoration: none;
border-radius: var(--radius-lg);
font-weight: var(--font-semibold);
font-size: var(--text-sm);
transition: var(--transition-slow);
border: 1px solid rgba(255, 255, 255, 0.3);
backdrop-filter: blur(10px);
}
.dashboard-btn:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.dashboard-btn .btn-icon {
font-size: var(--text-lg);
}
.dashboard-btn .btn-text {
font-family: inherit;
}
/* 신고 버튼 */
.report-btn {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-5);
background: rgba(239, 68, 68, 0.9);
color: white;
text-decoration: none;
border-radius: var(--radius-lg);
font-weight: var(--font-semibold);
font-size: var(--text-sm);
transition: var(--transition-slow);
border: 1px solid rgba(255, 255, 255, 0.3);
backdrop-filter: blur(10px);
}
.report-btn:hover {
background: rgba(220, 38, 38, 1);
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.report-btn .btn-icon {
font-size: var(--text-lg);
}
.report-btn .btn-text {
font-family: inherit;
}
/* 알림 버튼 스타일 */
.notification-wrapper {
position: relative;
}
.notification-btn {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
background: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: var(--radius-lg);
cursor: pointer;
transition: var(--transition-normal);
position: relative;
}
.notification-btn:hover {
background: rgba(255, 255, 255, 0.25);
transform: translateY(-2px);
}
.notification-icon-svg {
width: 20px;
height: 20px;
color: white;
}
.notification-btn.has-notifications {
animation: pulse-btn 2s infinite;
}
@keyframes pulse-btn {
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
50% { box-shadow: 0 0 0 8px rgba(239, 68, 68, 0); }
}
.notification-badge {
position: absolute;
top: -4px;
right: -4px;
min-width: 18px;
height: 18px;
padding: 0 5px;
background: var(--error-500);
color: white;
font-size: 11px;
font-weight: var(--font-bold);
border-radius: var(--radius-full);
display: flex;
align-items: center;
justify-content: center;
animation: pulse-badge 2s infinite;
}
@keyframes pulse-badge {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.1); }
}
.notification-dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: var(--space-2);
width: 320px;
max-height: 400px;
background: var(--bg-primary);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl);
border: 1px solid var(--border-light);
opacity: 0;
visibility: hidden;
transform: translateY(-10px);
transition: var(--transition-normal);
z-index: 1000;
overflow: hidden;
}
.notification-dropdown.show {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.notification-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border-light);
background: var(--bg-secondary);
}
.notification-header h4 {
margin: 0;
font-size: var(--text-base);
font-weight: var(--font-semibold);
color: var(--text-primary);
}
.view-all-link {
font-size: var(--text-sm);
color: var(--primary-500);
text-decoration: none;
font-weight: var(--font-medium);
}
.view-all-link:hover {
text-decoration: underline;
}
.notification-list {
max-height: 320px;
overflow-y: auto;
}
.notification-item {
display: flex;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border-light);
cursor: pointer;
transition: var(--transition-fast);
}
.notification-item:hover {
background: var(--bg-secondary);
}
.notification-item.unread {
background: rgba(14, 165, 233, 0.05);
}
.notification-item.unread::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: var(--primary-500);
}
.notification-item {
position: relative;
}
.notification-item-icon {
width: 36px;
height: 36px;
border-radius: var(--radius-full);
background: var(--warning-100);
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
flex-shrink: 0;
}
.notification-item-icon.repair {
background: var(--warning-100);
}
.notification-item-content {
flex: 1;
min-width: 0;
}
.notification-item-title {
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--text-primary);
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.notification-item-desc {
font-size: var(--text-xs);
color: var(--text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.notification-item-time {
font-size: var(--text-xs);
color: var(--text-tertiary);
white-space: nowrap;
}
.notification-empty {
padding: var(--space-6);
text-align: center;
color: var(--text-tertiary);
font-size: var(--text-sm);
}
/* 모바일 메뉴 버튼 */
.mobile-menu-btn {
display: none;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
background: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 10px;
cursor: pointer;
color: white;
font-size: 1.5rem;
transition: all 0.2s;
margin-right: var(--space-3);
flex-shrink: 0;
}
.mobile-menu-btn:hover,
.mobile-menu-btn:active {
background: rgba(255, 255, 255, 0.25);
}
/* 반응형 디자인 */
@media (max-width: 1024px) {
.mobile-menu-btn {
display: flex;
}
}
@media (max-width: 768px) {
.dashboard-header {
padding: var(--space-2) var(--space-3);
height: 64px;
}
body {
padding-top: 64px;
}
.header-content {
padding: 0 var(--space-2);
}
.brand-logo {
width: 36px;
height: 36px;
}
.brand-title {
font-size: var(--text-base);
}
.brand-subtitle {
display: none;
}
.header-center {
display: none;
}
.user-info {
display: none;
}
.user-avatar {
width: 36px;
height: 36px;
}
.dashboard-btn .btn-text,
.report-btn .btn-text {
display: none;
}
.dashboard-btn,
.report-btn {
padding: var(--space-2);
width: 40px;
height: 40px;
justify-content: center;
}
.dashboard-btn .btn-icon,
.report-btn .btn-icon {
margin: 0;
}
.notification-btn {
width: 40px;
height: 40px;
}
.notification-dropdown {
position: fixed;
top: 64px;
left: var(--space-3);
right: var(--space-3);
width: auto;
}
.header-right {
gap: var(--space-2);
}
.mobile-menu-btn {
width: 40px;
height: 40px;
margin-right: var(--space-2);
}
.user-profile {
padding: var(--space-1) var(--space-2);
}
.profile-menu {
position: fixed;
top: 64px;
right: var(--space-3);
left: auto;
width: 200px;
}
}
/* 모바일 사이드바 열릴 때 바디 스크롤 방지 */
body.sidebar-mobile-open {
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,49 @@
<!-- ✅ /components/sections/admin-sections.html -->
<meta charset="UTF-8">
<section>
<h2>📄 작업 보고서</h2>
<ul>
<li><a href="/pages/work-reports/work-report-create.html">작업보고서 입력</a></li>
<li><a href="/pages/work-reports/work-report-manage.html">작업보고서 관리</a></li>
</ul>
</section>
<section>
<h2>📊 출근/공수 관리</h2>
<ul>
<li><a href="/pages/common/attendance.html">출근부</a></li>
<li><a href="/pages/work-reports/project-labor-summary.html">프로젝트별 공수 계산</a></li>
<li><a href="/pages/work-reports/monthly-labor-report.html">월간 공수 보고서</a></li>
</ul>
</section>
<section>
<h2>🔧 시스템 관리</h2>
<ul>
<li><a href="/pages/admin/manage-user.html">👤 사용자 관리</a></li>
<li><a href="/pages/admin/manage-project.html">📁 프로젝트 관리</a></li>
<li><a href="/pages/admin/manage-worker.html">👷 작업자 관리</a></li>
<li><a href="/pages/admin/manage-task.html">📋 작업 유형 관리</a></li>
<li><a href="/pages/admin/manage-issue.html">🚨 이슈 유형 관리</a></li>
<li><a href="/pages/admin/manage-pipespec.html">🔧 배관 스펙 관리</a></li>
</ul>
</section>
<section>
<h2>🏭 공장 정보</h2>
<ul>
<li><a href="/pages/common/factory-upload.html">공장 정보 등록</a></li>
<li><a href="/pages/common/factory-view.html">공장 목록 보기</a></li>
</ul>
</section>
<section>
<h2>📊 이슈 리포트</h2>
<ul>
<li><a href="/pages/issue-reports/daily-issue-report.html">일일 이슈 보고</a></li>
<li><a href="/pages/issue-reports/issue-summary.html">이슈 현황 요약</a></li>
<li><a href="/pages/analysis/daily_work_analysis.html">작업 정보 페이지</a></li>
<li><a href="/pages/analysis/work-report-analytics.html" class="admin-only-link">📊 작업보고서 종합분석 <span class="admin-badge">ADMIN</span></a></li>
</ul>
</section>

View File

@@ -0,0 +1,479 @@
<!-- components/sidebar-nav.html -->
<!-- 카테고리별 사이드 네비게이션 메뉴 -->
<aside class="sidebar-nav" id="sidebarNav">
<div class="sidebar-toggle" id="sidebarToggle">
<span class="toggle-icon">&#9776;</span>
</div>
<nav class="sidebar-menu">
<!-- 대시보드 -->
<a href="/pages/dashboard.html" class="nav-item" data-page-key="dashboard">
<span class="nav-icon">&#127968;</span>
<span class="nav-text">대시보드</span>
</a>
<!-- 작업 관리 -->
<div class="nav-category" data-category="work">
<button class="nav-category-header">
<span class="nav-icon">&#128221;</span>
<span class="nav-text">작업 관리</span>
<span class="nav-arrow">&#9662;</span>
</button>
<div class="nav-category-items">
<a href="/pages/work/tbm.html" class="nav-item" data-page-key="work.tbm">
<span class="nav-text">TBM 관리</span>
</a>
<a href="/pages/work/report-create.html" class="nav-item" data-page-key="work.report_create">
<span class="nav-text">작업보고서 작성</span>
</a>
<a href="/pages/work/analysis.html" class="nav-item admin-only" data-page-key="work.analysis">
<span class="nav-text">작업 분석</span>
</a>
<a href="/pages/work/nonconformity.html" class="nav-item" data-page-key="work.nonconformity">
<span class="nav-text">부적합 현황</span>
</a>
</div>
</div>
<!-- 공장 관리 -->
<div class="nav-category" data-category="factory">
<button class="nav-category-header">
<span class="nav-icon">&#127981;</span>
<span class="nav-text">공장 관리</span>
<span class="nav-arrow">&#9662;</span>
</button>
<div class="nav-category-items">
<a href="/pages/admin/repair-management.html" class="nav-item" data-page-key="factory.repair_management">
<span class="nav-text">시설설비 관리</span>
</a>
<a href="/pages/inspection/daily-patrol.html" class="nav-item" data-page-key="inspection.daily_patrol">
<span class="nav-text">일일순회점검</span>
</a>
<a href="/pages/attendance/checkin.html" class="nav-item" data-page-key="inspection.checkin">
<span class="nav-text">출근 체크</span>
</a>
<a href="/pages/attendance/work-status.html" class="nav-item" data-page-key="inspection.work_status">
<span class="nav-text">근무 현황</span>
</a>
</div>
</div>
<!-- 안전 관리 -->
<div class="nav-category" data-category="safety">
<button class="nav-category-header">
<span class="nav-icon">&#128737;</span>
<span class="nav-text">안전 관리</span>
<span class="nav-arrow">&#9662;</span>
</button>
<div class="nav-category-items">
<a href="/pages/safety/report-status.html" class="nav-item" data-page-key="safety.report_status">
<span class="nav-text">안전신고 현황</span>
</a>
<a href="/pages/safety/visit-request.html" class="nav-item" data-page-key="safety.visit_request">
<span class="nav-text">출입 신청</span>
</a>
<a href="/pages/safety/management.html" class="nav-item admin-only" data-page-key="safety.management">
<span class="nav-text">안전 관리</span>
</a>
<a href="/pages/safety/checklist-manage.html" class="nav-item admin-only" data-page-key="safety.checklist_manage">
<span class="nav-text">체크리스트 관리</span>
</a>
</div>
</div>
<!-- 근태 관리 -->
<div class="nav-category" data-category="attendance">
<button class="nav-category-header">
<span class="nav-icon">&#128197;</span>
<span class="nav-text">근태 관리</span>
<span class="nav-arrow">&#9662;</span>
</button>
<div class="nav-category-items">
<a href="/pages/attendance/my-vacation-info.html" class="nav-item" data-page-key="attendance.my_vacation_info">
<span class="nav-text">내 연차 정보</span>
</a>
<a href="/pages/attendance/monthly.html" class="nav-item" data-page-key="attendance.monthly">
<span class="nav-text">월간 근태</span>
</a>
<a href="/pages/attendance/vacation-request.html" class="nav-item" data-page-key="attendance.vacation_request">
<span class="nav-text">휴가 신청</span>
</a>
<a href="/pages/attendance/vacation-management.html" class="nav-item admin-only" data-page-key="attendance.vacation_management">
<span class="nav-text">휴가 관리</span>
</a>
<a href="/pages/attendance/vacation-allocation.html" class="nav-item admin-only" data-page-key="attendance.vacation_allocation">
<span class="nav-text">휴가 발생 입력</span>
</a>
<a href="/pages/attendance/annual-overview.html" class="nav-item admin-only" data-page-key="attendance.annual_overview">
<span class="nav-text">연간 휴가 현황</span>
</a>
</div>
</div>
<!-- 시스템 관리 (관리자 전용) -->
<div class="nav-category admin-only" data-category="admin">
<button class="nav-category-header">
<span class="nav-icon">&#9881;</span>
<span class="nav-text">시스템 관리</span>
<span class="nav-arrow">&#9662;</span>
</button>
<div class="nav-category-items">
<a href="/pages/admin/accounts.html" class="nav-item" data-page-key="admin.accounts">
<span class="nav-text">계정 관리</span>
</a>
<a href="/pages/admin/workers.html" class="nav-item" data-page-key="admin.workers">
<span class="nav-text">작업자 관리</span>
</a>
<a href="/pages/admin/projects.html" class="nav-item" data-page-key="admin.projects">
<span class="nav-text">프로젝트 관리</span>
</a>
<a href="/pages/admin/tasks.html" class="nav-item" data-page-key="admin.tasks">
<span class="nav-text">작업 관리</span>
</a>
<a href="/pages/admin/workplaces.html" class="nav-item" data-page-key="admin.workplaces">
<span class="nav-text">작업장 관리</span>
</a>
<a href="/pages/admin/equipments.html" class="nav-item" data-page-key="admin.equipments">
<span class="nav-text">설비 관리</span>
</a>
<a href="/pages/admin/issue-categories.html" class="nav-item" data-page-key="admin.issue_categories">
<span class="nav-text">신고 카테고리 관리</span>
</a>
<a href="/pages/admin/attendance-report.html" class="nav-item" data-page-key="admin.attendance_report">
<span class="nav-text">출퇴근-보고서 대조</span>
</a>
</div>
</div>
</nav>
</aside>
<style>
/* 사이드바 기본 스타일 */
.sidebar-nav {
position: fixed;
left: 0;
top: 80px; /* 헤더 높이만큼 아래로 */
height: calc(100vh - 80px);
width: 260px;
background: linear-gradient(180deg, #1e293b 0%, #0f172a 100%);
color: #e2e8f0;
z-index: 99; /* 헤더보다 낮게 */
transition: transform 0.3s ease, width 0.3s ease;
display: flex;
flex-direction: column;
box-shadow: 4px 0 20px rgba(0, 0, 0, 0.15);
}
.sidebar-nav.collapsed {
width: 60px;
}
.sidebar-nav.collapsed .nav-text,
.sidebar-nav.collapsed .nav-arrow {
display: none;
}
.sidebar-nav.collapsed .nav-category-items {
display: none !important;
}
/* 토글 버튼 */
.sidebar-toggle {
display: flex;
align-items: center;
justify-content: center;
height: 60px;
background: rgba(255, 255, 255, 0.05);
cursor: pointer;
transition: background 0.2s;
}
.sidebar-toggle:hover {
background: rgba(255, 255, 255, 0.1);
}
.toggle-icon {
font-size: 1.5rem;
color: #94a3b8;
}
/* 메뉴 스타일 */
.sidebar-menu {
flex: 1;
overflow-y: auto;
padding: 1rem 0;
}
/* 네비게이션 아이템 */
.nav-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1.25rem;
color: #cbd5e1;
text-decoration: none;
transition: all 0.2s;
border-left: 3px solid transparent;
}
.nav-item:hover {
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
.nav-item.active {
background: rgba(14, 165, 233, 0.15);
color: #38bdf8;
border-left-color: #38bdf8;
}
.nav-icon {
font-size: 1.25rem;
width: 24px;
text-align: center;
flex-shrink: 0;
}
.nav-text {
font-size: 0.9rem;
font-weight: 500;
white-space: nowrap;
}
/* 카테고리 헤더 */
.nav-category-header {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.875rem 1.25rem;
background: none;
border: none;
color: #94a3b8;
cursor: pointer;
transition: all 0.2s;
text-align: left;
font-family: inherit;
font-size: 0.85rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.nav-category-header:hover {
color: #e2e8f0;
background: rgba(255, 255, 255, 0.05);
}
.nav-arrow {
margin-left: auto;
font-size: 0.75rem;
transition: transform 0.2s;
}
.nav-category.expanded .nav-arrow {
transform: rotate(180deg);
}
/* 카테고리 아이템 */
.nav-category-items {
display: none;
padding-left: 0.5rem;
}
.nav-category.expanded .nav-category-items {
display: block;
}
.nav-category-items .nav-item {
padding-left: 2.5rem;
font-size: 0.875rem;
}
/* 관리자 전용 숨김 */
.admin-only {
display: none;
}
.admin-only.visible {
display: flex;
}
.nav-category.admin-only.visible {
display: block;
}
/* 스크롤바 스타일 */
.sidebar-menu::-webkit-scrollbar {
width: 6px;
}
.sidebar-menu::-webkit-scrollbar-track {
background: transparent;
}
.sidebar-menu::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
}
.sidebar-menu::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
/* 메인 콘텐츠 여백 */
body.has-sidebar .dashboard-container,
body.has-sidebar .work-report-container,
body.has-sidebar .analysis-container,
body.has-sidebar > .dashboard-main {
margin-left: 260px;
transition: margin-left 0.3s ease;
}
/* page-container 사용 시: page-container에만 margin 적용 (main-content 중복 방지) */
body.has-sidebar .page-container {
margin-left: 260px;
transition: margin-left 0.3s ease;
}
/* page-container 없이 main-content만 있는 경우 */
body.has-sidebar > .main-content {
margin-left: 260px;
transition: margin-left 0.3s ease;
}
body.has-sidebar.sidebar-collapsed .dashboard-container,
body.has-sidebar.sidebar-collapsed .work-report-container,
body.has-sidebar.sidebar-collapsed .analysis-container,
body.has-sidebar.sidebar-collapsed > .dashboard-main {
margin-left: 60px;
}
body.has-sidebar.sidebar-collapsed .page-container {
margin-left: 60px;
}
body.has-sidebar.sidebar-collapsed > .main-content {
margin-left: 60px;
}
/* 반응형 - 모바일 */
@media (max-width: 1024px) {
.sidebar-nav {
transform: translateX(-100%);
width: 280px;
top: 0;
height: 100vh;
z-index: 1001;
}
.sidebar-nav.mobile-open {
transform: translateX(0);
}
/* 모바일 헤더 */
.sidebar-toggle {
justify-content: space-between;
padding: 0 1rem;
height: 64px;
background: rgba(255, 255, 255, 0.1);
}
.sidebar-toggle::after {
content: '메뉴';
font-size: 1rem;
font-weight: 600;
color: #e2e8f0;
}
.sidebar-nav .toggle-icon {
font-size: 1.5rem;
}
.sidebar-nav.mobile-open .toggle-icon {
font-size: 1.5rem;
}
.sidebar-nav.mobile-open .toggle-icon::before {
content: '✕';
}
.sidebar-nav.mobile-open .toggle-icon span {
display: none;
}
/* 오버레이 배경 */
.sidebar-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
backdrop-filter: blur(2px);
}
.sidebar-overlay.show {
display: block;
}
body.has-sidebar .dashboard-container,
body.has-sidebar .page-container,
body.has-sidebar > .main-content,
body.has-sidebar .work-report-container,
body.has-sidebar .analysis-container,
body.has-sidebar > .dashboard-main {
margin-left: 0;
}
/* 메뉴 아이템 모바일 최적화 */
.nav-item {
padding: 1rem 1.25rem;
font-size: 1rem;
}
.nav-category-items .nav-item {
padding-left: 3rem;
}
.nav-category-header {
padding: 1rem 1.25rem;
font-size: 0.9rem;
}
.nav-icon {
font-size: 1.5rem;
}
}
/* 모바일 메뉴 버튼 (헤더용) */
.mobile-menu-btn {
display: none;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
background: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 10px;
cursor: pointer;
color: white;
font-size: 1.5rem;
transition: all 0.2s;
}
.mobile-menu-btn:hover {
background: rgba(255, 255, 255, 0.25);
}
@media (max-width: 1024px) {
.mobile-menu-btn {
display: flex;
}
}
</style>
<!-- 모바일 오버레이 -->
<div class="sidebar-overlay" id="sidebarOverlay"></div>

View 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>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,50 @@
body {
font-family: 'Malgun Gothic', sans-serif;
margin: 0;
background: #f0f2f5;
}
.main-layout {
display: flex;
}
#sidebar-container {
width: 250px;
background: #1a237e;
color: white;
min-height: 100vh;
}
#content-container {
flex: 1;
padding: 30px;
}
h1, h2 {
color: #1976d2;
}
a {
color: #1976d2;
text-decoration: none;
}
/* Admin 권한 배지 스타일 */
.admin-badge {
background: linear-gradient(135deg, #ff6b35 0%, #f7931e 100%);
color: white;
font-size: 0.7rem;
font-weight: bold;
padding: 2px 6px;
border-radius: 10px;
margin-left: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
box-shadow: 0 2px 4px rgba(255,107,53,0.3);
display: inline-block;
vertical-align: middle;
}
.admin-only-link {
position: relative;
}
.admin-only-link:hover .admin-badge {
background: linear-gradient(135deg, #ff5722 0%, #ff9800 100%);
box-shadow: 0 3px 6px rgba(255,107,53,0.4);
}

View File

@@ -0,0 +1,348 @@
/**
* annual-vacation-overview.css
* 연간 연차 현황 페이지 스타일
*/
.page-container {
min-height: 100vh;
background: var(--color-bg-primary);
}
.main-content {
padding: 2rem 0;
}
.content-wrapper {
max-width: 1400px;
margin: 0 auto;
padding: 0 2rem;
}
/* 페이지 헤더 */
.page-header {
margin-bottom: 2rem;
}
.page-title {
font-size: 2rem;
font-weight: 700;
color: var(--color-text-primary);
margin-bottom: 0.5rem;
}
.page-description {
font-size: 1rem;
color: var(--color-text-secondary);
margin: 0;
}
/* 필터 섹션 */
.filter-section {
margin-bottom: 2rem;
}
.filter-controls {
display: flex;
gap: 1rem;
align-items: flex-end;
flex-wrap: wrap;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 200px;
}
.form-group label {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text-primary);
}
.form-select {
padding: 0.625rem 1rem;
border: 1px solid var(--color-border);
border-radius: 8px;
background: white;
font-size: 0.875rem;
color: var(--color-text-primary);
transition: all 0.2s;
}
.form-select:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
/* 탭 네비게이션 */
.tabs-section {
margin-bottom: 2rem;
}
.tabs-nav {
display: flex;
gap: 0.5rem;
border-bottom: 2px solid var(--color-border);
padding: 0;
margin: 0;
}
.tab-btn {
padding: 1rem 2rem;
border: none;
background: none;
color: var(--color-text-secondary);
font-size: 1rem;
font-weight: 600;
cursor: pointer;
position: relative;
transition: all 0.2s;
border-radius: 8px 8px 0 0;
}
.tab-btn:hover {
color: var(--color-primary);
background: rgba(59, 130, 246, 0.05);
}
.tab-btn.active {
color: var(--color-primary);
background: white;
}
.tab-btn.active::after {
content: '';
position: absolute;
bottom: -2px;
left: 0;
right: 0;
height: 2px;
background: var(--color-primary);
}
/* 탭 컨텐츠 */
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
/* 월 선택 컨트롤 */
.month-controls {
display: flex;
gap: 1rem;
align-items: center;
}
.month-controls .form-select {
min-width: 120px;
}
/* 차트 섹션 */
.chart-section {
margin-bottom: 2rem;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid var(--color-border);
}
.card-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--color-text-primary);
margin: 0;
}
.chart-controls {
display: flex;
gap: 0.5rem;
}
.btn-outline {
background: white;
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
}
.btn-outline:hover {
background: var(--color-bg-secondary);
border-color: var(--color-primary);
color: var(--color-primary);
}
.btn-outline.active {
background: var(--color-primary);
border-color: var(--color-primary);
color: white;
}
.chart-container {
position: relative;
height: 500px;
padding: 1.5rem;
}
/* 테이블 섹션 */
.table-section {
margin-bottom: 2rem;
}
.table-responsive {
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.data-table thead {
background: var(--color-bg-secondary);
}
.data-table th {
padding: 1rem;
text-align: left;
font-weight: 600;
color: var(--color-text-primary);
border-bottom: 2px solid var(--color-border);
white-space: nowrap;
}
.data-table tbody tr {
border-bottom: 1px solid var(--color-border);
transition: background 0.2s;
}
.data-table tbody tr:hover {
background: var(--color-bg-secondary);
}
.data-table td {
padding: 1rem;
color: var(--color-text-primary);
}
.loading-state {
padding: 3rem 1rem !important;
text-align: center;
}
.loading-state .spinner {
margin: 0 auto 1rem;
width: 40px;
height: 40px;
border: 4px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
.loading-state p {
margin: 0;
color: var(--color-text-secondary);
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* 사용률 프로그레스 바 */
.usage-rate-cell {
display: flex;
align-items: center;
gap: 0.5rem;
}
.progress-bar {
flex: 1;
height: 8px;
background: var(--color-bg-secondary);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
.progress-fill.low {
background: linear-gradient(90deg, #10b981 0%, #059669 100%);
}
.progress-fill.medium {
background: linear-gradient(90deg, #f59e0b 0%, #d97706 100%);
}
.progress-fill.high {
background: linear-gradient(90deg, #ef4444 0%, #dc2626 100%);
}
.usage-rate-text {
font-weight: 600;
min-width: 45px;
text-align: right;
}
/* 반응형 */
@media (max-width: 768px) {
.content-wrapper {
padding: 0 1rem;
}
.page-title {
font-size: 1.5rem;
}
.tabs-nav {
flex-direction: column;
gap: 0;
}
.tab-btn {
border-radius: 0;
}
.filter-controls {
flex-direction: column;
align-items: stretch;
}
.form-group {
width: 100%;
min-width: auto;
}
.chart-container {
height: 400px;
}
.card-header {
flex-direction: column;
gap: 1rem;
align-items: flex-start;
}
.chart-controls {
width: 100%;
}
.chart-controls button {
flex: 1;
}
}

View 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;
}
}

View 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; }

View File

@@ -0,0 +1,300 @@
/* Common CSS - 공통 스타일 */
/* ========== 통일된 헤더 스타일 ========== */
.work-report-container {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.work-report-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
text-align: center;
padding: 2rem 1.5rem;
margin-bottom: 0;
}
.work-report-header h1 {
font-size: clamp(1.5rem, 4vw, 2.5rem);
font-weight: 700;
margin: 0 0 0.75rem 0;
text-shadow: 0 0.125rem 0.25rem rgba(0,0,0,0.3);
word-wrap: break-word;
overflow-wrap: break-word;
}
.work-report-header .subtitle {
font-size: clamp(0.875rem, 2vw, 1.1rem);
opacity: 0.9;
margin: 0;
font-weight: 300;
word-wrap: break-word;
overflow-wrap: break-word;
max-width: 90%;
margin-left: auto;
margin-right: auto;
}
.work-report-main {
background: #f8f9fa;
min-height: calc(100vh - 12rem);
padding-top: 2rem;
}
.back-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: rgba(255, 255, 255, 0.9);
color: #495057;
text-decoration: none;
border-radius: 0.5rem;
font-weight: 500;
margin: 0 1.5rem 1.5rem 1.5rem;
transition: all 0.3s ease;
box-shadow: 0 0.125rem 0.25rem rgba(0,0,0,0.1);
white-space: nowrap;
}
.back-button:hover {
background: white;
color: #007bff;
transform: translateY(-0.0625rem);
box-shadow: 0 0.25rem 0.5rem rgba(0,0,0,0.15);
}
/* 반응형 헤더 */
@media (max-width: 768px) {
.work-report-header {
padding: 1.5rem 1rem;
}
.work-report-header h1 {
margin-bottom: 0.5rem;
}
.back-button {
margin: 0 1rem 1rem 1rem;
padding: 0.625rem 1.25rem;
font-size: 0.875rem;
}
}
@media (max-width: 480px) {
.work-report-header {
padding: 1.25rem 0.75rem;
}
.work-report-header .subtitle {
font-size: 0.8125rem;
}
.back-button {
margin: 0 0.75rem 0.75rem 0.75rem;
padding: 0.5rem 1rem;
font-size: 0.8125rem;
}
}
/* Reset and Base Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
background-color: #f8fafc;
}
/* Typography */
h1, h2, h3, h4, h5, h6 {
font-weight: 600;
line-height: 1.25;
margin-bottom: 0.5rem;
}
h1 { font-size: 2rem; }
h2 { font-size: 1.5rem; }
h3 { font-size: 1.25rem; }
h4 { font-size: 1.125rem; }
h5 { font-size: 1rem; }
h6 { font-size: 0.875rem; }
/* ========== 헤더 액션 버튼 ========== */
.header-actions {
display: flex;
align-items: center;
gap: 1rem;
margin-right: 1rem;
}
.dashboard-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1.2rem;
background: rgba(255, 255, 255, 0.15);
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
text-decoration: none;
cursor: pointer;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
}
.dashboard-btn:hover {
background: rgba(255, 255, 255, 0.25);
border-color: rgba(255, 255, 255, 0.5);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.dashboard-btn .btn-icon {
font-size: 1rem;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem 1rem;
border: none;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
}
.btn-primary {
background-color: #3b82f6;
color: white;
}
.btn-primary:hover {
background-color: #2563eb;
}
.btn-secondary {
background-color: #6b7280;
color: white;
}
.btn-secondary:hover {
background-color: #4b5563;
}
/* Utilities */
.text-center { text-align: center; }
.text-left { text-align: left; }
.text-right { text-align: right; }
.mb-1 { margin-bottom: 0.25rem; }
.mb-2 { margin-bottom: 0.5rem; }
.mb-3 { margin-bottom: 0.75rem; }
.mb-4 { margin-bottom: 1rem; }
.mb-5 { margin-bottom: 1.25rem; }
.mb-6 { margin-bottom: 1.5rem; }
.mt-1 { margin-top: 0.25rem; }
.mt-2 { margin-top: 0.5rem; }
.mt-3 { margin-top: 0.75rem; }
.mt-4 { margin-top: 1rem; }
.mt-5 { margin-top: 1.25rem; }
.mt-6 { margin-top: 1.5rem; }
.p-1 { padding: 0.25rem; }
.p-2 { padding: 0.5rem; }
.p-3 { padding: 0.75rem; }
.p-4 { padding: 1rem; }
.p-5 { padding: 1.25rem; }
.p-6 { padding: 1.5rem; }
/* Container */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
}
/* Cards */
.card {
background: white;
border-radius: 0.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
border: 1px solid #e5e7eb;
}
.card-header {
padding: 1rem;
border-bottom: 1px solid #e5e7eb;
}
.card-body {
padding: 1rem;
}
/* Forms */
.form-group {
margin-bottom: 1rem;
}
.form-label {
display: block;
font-weight: 500;
margin-bottom: 0.25rem;
color: #374151;
}
.form-control {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.875rem;
transition: border-color 0.2s ease;
}
.form-control:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
/* Loading */
.loading {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid #f3f3f3;
border-top: 3px solid #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Responsive */
@media (max-width: 768px) {
.container {
padding: 0 0.5rem;
}
h1 { font-size: 1.5rem; }
h2 { font-size: 1.25rem; }
h3 { font-size: 1.125rem; }
}

View 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;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,477 @@
/* ✅ design-system.css - 한글 기반 모던 디자인 시스템 */
/* ========== 색상 시스템 ========== */
:root {
/* 주요 브랜드 색상 (하늘색 계열) */
--primary-50: #f0f9ff;
--primary-100: #e0f2fe;
--primary-200: #bae6fd;
--primary-300: #7dd3fc;
--primary-400: #38bdf8;
--primary-500: #0ea5e9;
--primary-600: #0284c7;
--primary-700: #0369a1;
--primary-800: #075985;
--primary-900: #0c4a6e;
/* 헤더 그라디언트 */
--header-gradient: linear-gradient(135deg, #0ea5e9 0%, #38bdf8 50%, #7dd3fc 100%);
/* 보조 색상 */
--secondary-50: #f3e5f5;
--secondary-100: #e1bee7;
--secondary-200: #ce93d8;
--secondary-300: #ba68c8;
--secondary-400: #ab47bc;
--secondary-500: #9c27b0;
--secondary-600: #8e24aa;
--secondary-700: #7b1fa2;
--secondary-800: #6a1b9a;
--secondary-900: #4a148c;
/* 그레이 스케일 */
--gray-50: #fafafa;
--gray-100: #f5f5f5;
--gray-200: #eeeeee;
--gray-300: #e0e0e0;
--gray-400: #bdbdbd;
--gray-500: #9e9e9e;
--gray-600: #757575;
--gray-700: #616161;
--gray-800: #424242;
--gray-900: #212121;
/* 상태 색상 */
--success-50: #e8f5e8;
--success-500: #4caf50;
--success-700: #388e3c;
--warning-50: #fff8e1;
--warning-500: #ff9800;
--warning-700: #f57c00;
--error-50: #ffebee;
--error-500: #f44336;
--error-700: #d32f2f;
--info-50: #e1f5fe;
--info-500: #03a9f4;
--info-700: #0288d1;
/* 따뜻한 중성 색상 (베이지/크림) */
--warm-50: #fafaf9; /* 매우 밝은 크림 */
--warm-100: #f5f5f4; /* 밝은 크림 */
--warm-200: #e7e5e4; /* 베이지 */
--warm-300: #d6d3d1; /* 중간 베이지 */
--warm-400: #a8a29e; /* 진한 베이지 */
--warm-500: #78716c; /* 그레이 베이지 */
/* 부드러운 작업 상태 색상 (눈이 편한 톤) */
--status-success-bg: #dcfce7; /* 부드러운 초록 배경 */
--status-success-text: #16a34a; /* 부드러운 초록 텍스트 */
--status-info-bg: #e0f2fe; /* 부드러운 하늘색 배경 */
--status-info-text: #0284c7; /* 부드러운 하늘색 텍스트 */
--status-warning-bg: #fef3c7; /* 부드러운 노랑 배경 */
--status-warning-text: #ca8a04; /* 부드러운 노랑 텍스트 */
--status-error-bg: #fee2e2; /* 부드러운 빨강 배경 */
--status-error-text: #dc2626; /* 부드러운 빨강 텍스트 */
--status-critical-bg: #fecaca; /* 진한 빨강 배경 */
--status-critical-text: #b91c1c; /* 진한 빨강 텍스트 */
--status-vacation-bg: #fed7aa; /* 부드러운 주황 배경 */
--status-vacation-text: #ea580c; /* 부드러운 주황 텍스트 */
/* 배경 색상 */
--bg-primary: #ffffff;
--bg-secondary: #f8fafc;
--bg-tertiary: #f1f5f9;
--bg-overlay: rgba(0, 0, 0, 0.5);
/* 텍스트 색상 */
--text-primary: #1a202c;
--text-secondary: #4a5568;
--text-tertiary: #718096;
--text-inverse: #ffffff;
/* 경계선 */
--border-light: #e2e8f0;
--border-medium: #cbd5e0;
--border-dark: #a0aec0;
/* 그림자 */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
/* 반경 */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-full: 9999px;
/* 간격 */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
--space-20: 80px;
--space-24: 96px;
/* 폰트 크기 */
--text-xs: 12px;
--text-sm: 14px;
--text-base: 16px;
--text-lg: 18px;
--text-xl: 20px;
--text-2xl: 24px;
--text-3xl: 30px;
--text-4xl: 36px;
--text-5xl: 48px;
/* 폰트 두께 */
--font-light: 300;
--font-normal: 400;
--font-medium: 500;
--font-semibold: 600;
--font-bold: 700;
--font-extrabold: 800;
/* 애니메이션 */
--transition-fast: 150ms ease-in-out;
--transition-normal: 250ms ease-in-out;
--transition-slow: 350ms ease-in-out;
}
/* ========== 기본 리셋 ========== */
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
font-family: 'Pretendard', 'Malgun Gothic', 'Apple SD Gothic Neo', system-ui, sans-serif;
font-size: var(--text-base);
line-height: 1.6;
color: var(--text-primary);
background-color: var(--bg-secondary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ========== 타이포그래피 ========== */
.text-xs { font-size: var(--text-xs); }
.text-sm { font-size: var(--text-sm); }
.text-base { font-size: var(--text-base); }
.text-lg { font-size: var(--text-lg); }
.text-xl { font-size: var(--text-xl); }
.text-2xl { font-size: var(--text-2xl); }
.text-3xl { font-size: var(--text-3xl); }
.text-4xl { font-size: var(--text-4xl); }
.text-5xl { font-size: var(--text-5xl); }
.font-light { font-weight: var(--font-light); }
.font-normal { font-weight: var(--font-normal); }
.font-medium { font-weight: var(--font-medium); }
.font-semibold { font-weight: var(--font-semibold); }
.font-bold { font-weight: var(--font-bold); }
.font-extrabold { font-weight: var(--font-extrabold); }
.text-primary { color: var(--text-primary); }
.text-secondary { color: var(--text-secondary); }
.text-tertiary { color: var(--text-tertiary); }
.text-inverse { color: var(--text-inverse); }
/* ========== 카드 컴포넌트 ========== */
.card {
background: var(--bg-primary);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-light);
transition: var(--transition-normal);
}
.card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
.card-header {
padding: var(--space-6);
border-bottom: 1px solid var(--border-light);
}
.card-body {
padding: var(--space-6);
}
.card-footer {
padding: var(--space-6);
border-top: 1px solid var(--border-light);
background: var(--bg-tertiary);
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
}
/* ========== 버튼 컴포넌트 ========== */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
font-size: var(--text-sm);
font-weight: var(--font-medium);
border-radius: var(--radius-md);
border: none;
cursor: pointer;
transition: var(--transition-fast);
text-decoration: none;
white-space: nowrap;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--primary-500);
color: var(--text-inverse);
}
.btn-primary:hover:not(:disabled) {
background: var(--primary-600);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.btn-secondary {
background: var(--gray-100);
color: var(--text-primary);
border: 1px solid var(--border-medium);
}
.btn-secondary:hover:not(:disabled) {
background: var(--gray-200);
}
.btn-success {
background: var(--success-500);
color: var(--text-inverse);
}
.btn-success:hover:not(:disabled) {
background: var(--success-700);
}
.btn-warning {
background: var(--warning-500);
color: var(--text-inverse);
}
.btn-warning:hover:not(:disabled) {
background: var(--warning-700);
}
.btn-error {
background: var(--error-500);
color: var(--text-inverse);
}
.btn-error:hover:not(:disabled) {
background: var(--error-700);
}
.btn-sm {
padding: var(--space-2) var(--space-3);
font-size: var(--text-xs);
}
.btn-lg {
padding: var(--space-4) var(--space-6);
font-size: var(--text-lg);
}
/* ========== 배지 컴포넌트 ========== */
.badge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
font-size: var(--text-xs);
font-weight: var(--font-medium);
border-radius: var(--radius-full);
white-space: nowrap;
}
.badge-primary {
background: var(--primary-100);
color: var(--primary-800);
}
.badge-success {
background: var(--success-50);
color: var(--success-700);
}
.badge-warning {
background: var(--warning-50);
color: var(--warning-700);
}
.badge-error {
background: var(--error-50);
color: var(--error-700);
}
.badge-gray {
background: var(--gray-100);
color: var(--gray-700);
}
/* ========== 상태 표시기 ========== */
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: var(--radius-full);
margin-right: var(--space-2);
}
.status-dot.active {
background: var(--success-500);
box-shadow: 0 0 0 2px var(--success-100);
}
.status-dot.inactive {
background: var(--gray-400);
}
.status-dot.warning {
background: var(--warning-500);
box-shadow: 0 0 0 2px var(--warning-100);
}
.status-dot.error {
background: var(--error-500);
box-shadow: 0 0 0 2px var(--error-100);
}
/* ========== 그리드 시스템 ========== */
.grid {
display: grid;
gap: var(--space-6);
}
.grid-cols-1 { grid-template-columns: repeat(1, 1fr); }
.grid-cols-2 { grid-template-columns: repeat(2, 1fr); }
.grid-cols-3 { grid-template-columns: repeat(3, 1fr); }
.grid-cols-4 { grid-template-columns: repeat(4, 1fr); }
@media (max-width: 768px) {
.grid-cols-2,
.grid-cols-3,
.grid-cols-4 {
grid-template-columns: 1fr;
}
}
/* ========== 플렉스 유틸리티 ========== */
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.items-start { align-items: flex-start; }
.items-end { align-items: flex-end; }
.justify-center { justify-content: center; }
.justify-between { justify-content: space-between; }
.justify-start { justify-content: flex-start; }
.justify-end { justify-content: flex-end; }
.gap-1 { gap: var(--space-1); }
.gap-2 { gap: var(--space-2); }
.gap-3 { gap: var(--space-3); }
.gap-4 { gap: var(--space-4); }
.gap-6 { gap: var(--space-6); }
/* ========== 간격 유틸리티 ========== */
.p-1 { padding: var(--space-1); }
.p-2 { padding: var(--space-2); }
.p-3 { padding: var(--space-3); }
.p-4 { padding: var(--space-4); }
.p-6 { padding: var(--space-6); }
.p-8 { padding: var(--space-8); }
.m-1 { margin: var(--space-1); }
.m-2 { margin: var(--space-2); }
.m-3 { margin: var(--space-3); }
.m-4 { margin: var(--space-4); }
.m-6 { margin: var(--space-6); }
.m-8 { margin: var(--space-8); }
.mb-2 { margin-bottom: var(--space-2); }
.mb-4 { margin-bottom: var(--space-4); }
.mb-6 { margin-bottom: var(--space-6); }
.mt-4 { margin-top: var(--space-4); }
.mt-6 { margin-top: var(--space-6); }
/* ========== 반응형 유틸리티 ========== */
@media (max-width: 640px) {
.sm\:hidden { display: none; }
.sm\:text-sm { font-size: var(--text-sm); }
.sm\:p-4 { padding: var(--space-4); }
}
@media (max-width: 768px) {
.md\:hidden { display: none; }
.md\:flex-col { flex-direction: column; }
}
@media (max-width: 1024px) {
.lg\:hidden { display: none; }
}
/* ========== 애니메이션 ========== */
.fade-in {
animation: fadeIn var(--transition-normal) ease-in-out;
}
.slide-up {
animation: slideUp var(--transition-normal) ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ========== 로딩 스피너 ========== */
.spinner {
width: 20px;
height: 20px;
border: 2px solid var(--gray-200);
border-top: 2px solid var(--primary-500);
border-radius: var(--radius-full);
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}

View File

@@ -0,0 +1,509 @@
/* equipment-detail.css - 설비 상세 페이지 스타일 */
/* 헤더 */
.eq-detail-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.eq-detail-header .page-title-section {
display: flex;
align-items: flex-start;
gap: 1rem;
}
.btn-back {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.5rem 0.75rem;
background: #f3f4f6;
border: 1px solid #d1d5db;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
color: #374151;
transition: all 0.15s ease;
}
.btn-back:hover {
background: #e5e7eb;
}
.back-arrow {
font-size: 1.1rem;
}
.eq-header-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.eq-header-meta {
font-size: 0.875rem;
color: #6b7280;
}
.eq-status-badge {
padding: 0.375rem 0.75rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
}
.eq-status-badge.active { background: #d1fae5; color: #065f46; }
.eq-status-badge.maintenance { background: #fef3c7; color: #92400e; }
.eq-status-badge.repair_needed { background: #fee2e2; color: #991b1b; }
.eq-status-badge.inactive { background: #e5e7eb; color: #374151; }
.eq-status-badge.external { background: #dbeafe; color: #1e40af; }
.eq-status-badge.repair_external { background: #ede9fe; color: #5b21b6; }
/* 기본 정보 카드 */
.eq-info-card {
background: white;
border-radius: 12px;
padding: 1.25rem;
margin-bottom: 1.5rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.eq-info-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
}
.eq-info-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.eq-info-label {
font-size: 0.75rem;
color: #6b7280;
font-weight: 500;
}
.eq-info-value {
font-size: 0.9375rem;
color: #111827;
}
/* 섹션 */
.eq-section {
background: white;
border-radius: 12px;
padding: 1.25rem;
margin-bottom: 1.5rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.eq-section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.eq-section-title {
font-size: 1rem;
font-weight: 600;
color: #111827;
margin: 0;
}
/* 사진 그리드 */
.eq-photo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 0.75rem;
}
.eq-photo-item {
position: relative;
aspect-ratio: 1;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
}
.eq-photo-item img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.2s ease;
}
.eq-photo-item:hover img {
transform: scale(1.05);
}
.eq-photo-delete {
position: absolute;
top: 4px;
right: 4px;
width: 24px;
height: 24px;
background: rgba(239, 68, 68, 0.9);
border: none;
border-radius: 50%;
color: white;
font-size: 14px;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.eq-photo-item:hover .eq-photo-delete {
opacity: 1;
}
.eq-photo-empty {
grid-column: 1 / -1;
text-align: center;
padding: 2rem;
color: #9ca3af;
font-size: 0.875rem;
}
/* 위치 정보 */
.eq-location-card {
display: flex;
gap: 1.5rem;
align-items: flex-start;
}
.eq-location-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.eq-location-row {
display: flex;
gap: 0.5rem;
}
.eq-location-label {
font-size: 0.875rem;
color: #6b7280;
min-width: 80px;
}
.eq-location-value {
font-size: 0.875rem;
color: #111827;
font-weight: 500;
}
.eq-location-value.eq-moved {
color: #dc2626;
}
.eq-map-preview {
width: 200px;
height: 150px;
background: #f3f4f6;
border-radius: 8px;
overflow: hidden;
position: relative;
}
.eq-map-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.eq-map-marker {
position: absolute;
width: 12px;
height: 12px;
background: #dc2626;
border: 2px solid white;
border-radius: 50%;
transform: translate(-50%, -50%);
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
}
/* 액션 버튼 */
.eq-action-buttons {
display: flex;
gap: 0.75rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.btn-action {
flex: 1;
min-width: 140px;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.875rem 1rem;
border-radius: 10px;
font-size: 0.9375rem;
font-weight: 600;
border: none;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-action .btn-icon {
font-size: 1.25rem;
}
.btn-move {
background: #dbeafe;
color: #1e40af;
}
.btn-move:hover { background: #bfdbfe; }
.btn-repair {
background: #fef3c7;
color: #92400e;
}
.btn-repair:hover { background: #fde68a; }
.btn-export {
background: #ede9fe;
color: #5b21b6;
}
.btn-export:hover { background: #ddd6fe; }
/* 이력 리스트 */
.eq-history-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.eq-history-item {
display: flex;
gap: 1rem;
padding: 0.875rem;
background: #f9fafb;
border-radius: 8px;
align-items: flex-start;
}
.eq-history-date {
font-size: 0.8125rem;
color: #6b7280;
white-space: nowrap;
min-width: 80px;
}
.eq-history-content {
flex: 1;
}
.eq-history-title {
font-size: 0.875rem;
font-weight: 500;
color: #111827;
margin-bottom: 0.25rem;
}
.eq-history-detail {
font-size: 0.8125rem;
color: #6b7280;
}
.eq-history-status {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 12px;
font-weight: 500;
}
.eq-history-status.pending { background: #fef3c7; color: #92400e; }
.eq-history-status.in_progress { background: #dbeafe; color: #1e40af; }
.eq-history-status.completed { background: #d1fae5; color: #065f46; }
.eq-history-status.exported { background: #ede9fe; color: #5b21b6; }
.eq-history-status.returned { background: #d1fae5; color: #065f46; }
.eq-history-empty {
text-align: center;
padding: 1.5rem;
color: #9ca3af;
font-size: 0.875rem;
}
.eq-history-action {
padding: 0.375rem 0.75rem;
background: #10b981;
color: white;
border: none;
border-radius: 6px;
font-size: 0.75rem;
cursor: pointer;
}
.eq-history-action:hover {
background: #059669;
}
/* 모달 스타일 추가 */
.photo-preview-container {
margin-top: 1rem;
text-align: center;
}
.photo-preview {
max-width: 100%;
max-height: 300px;
border-radius: 8px;
}
.move-step {
min-height: 200px;
}
.move-instruction {
font-size: 0.875rem;
color: #6b7280;
margin-bottom: 1rem;
text-align: center;
}
.move-map-container {
width: 100%;
height: 300px;
background: #f3f4f6;
border-radius: 8px;
margin-bottom: 1rem;
position: relative;
overflow: hidden;
}
.move-map-container img {
width: 100%;
height: 100%;
object-fit: contain;
cursor: crosshair;
}
.move-marker {
position: absolute;
width: 20px;
height: 20px;
background: #dc2626;
border: 3px solid white;
border-radius: 50%;
transform: translate(-50%, -50%);
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
pointer-events: none;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
width: 18px;
height: 18px;
}
.repair-photo-previews {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.5rem;
}
.repair-photo-preview {
width: 60px;
height: 60px;
border-radius: 6px;
object-fit: cover;
}
/* 사진 확대 보기 */
.photo-view-container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
cursor: pointer;
}
.photo-view-close {
position: absolute;
top: 20px;
right: 20px;
background: none;
border: none;
color: white;
font-size: 2rem;
cursor: pointer;
}
.photo-view-image {
max-width: 90%;
max-height: 90%;
object-fit: contain;
}
/* 버튼 스타일 */
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
}
.btn-outline {
background: transparent;
border: 1px solid #d1d5db;
color: #374151;
}
.btn-outline:hover {
background: #f3f4f6;
}
/* 반응형 */
@media (max-width: 768px) {
.eq-detail-header {
flex-direction: column;
}
.eq-location-card {
flex-direction: column;
}
.eq-map-preview {
width: 100%;
height: 200px;
}
.eq-action-buttons {
flex-direction: column;
}
.btn-action {
min-width: auto;
}
.eq-info-grid {
grid-template-columns: repeat(2, 1fr);
}
}

View File

@@ -0,0 +1,361 @@
/* equipment-management.css */
/* 설비 관리 페이지 전용 스타일 */
/* 통계 요약 섹션 */
.eq-stats-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.eq-stat-card {
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
border-radius: 12px;
padding: 1.25rem;
border: 1px solid #e2e8f0;
transition: all 0.2s ease;
}
.eq-stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.eq-stat-card.highlight {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
border: none;
}
.eq-stat-card.highlight .eq-stat-label {
color: rgba(255, 255, 255, 0.85);
}
.eq-stat-label {
font-size: 0.8rem;
color: #64748b;
margin-bottom: 0.5rem;
font-weight: 500;
}
.eq-stat-value {
font-size: 1.75rem;
font-weight: 700;
line-height: 1.2;
}
.eq-stat-sub {
font-size: 0.75rem;
color: #94a3b8;
margin-top: 0.25rem;
}
.eq-stat-card.highlight .eq-stat-sub {
color: rgba(255, 255, 255, 0.7);
}
/* 필터 섹션 개선 */
.eq-filter-section {
background: #f8fafc;
border-radius: 12px;
padding: 1rem 1.25rem;
margin-bottom: 1.5rem;
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: flex-end;
}
.eq-filter-group {
display: flex;
flex-direction: column;
gap: 0.35rem;
min-width: 140px;
}
.eq-filter-group label {
font-size: 0.75rem;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.eq-filter-group .form-control {
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
border-radius: 8px;
border: 1px solid #e2e8f0;
background: white;
}
.eq-filter-group .form-control:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.eq-search-group {
flex: 1;
min-width: 200px;
}
.eq-search-group .form-control {
width: 100%;
}
/* 테이블 개선 */
.eq-table-container {
background: white;
border-radius: 12px;
border: 1px solid #e2e8f0;
overflow: hidden;
}
.eq-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.eq-table thead {
background: #f1f5f9;
position: sticky;
top: 0;
z-index: 10;
}
.eq-table th {
padding: 0.875rem 1rem;
text-align: left;
font-weight: 600;
color: #475569;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 2px solid #e2e8f0;
white-space: nowrap;
}
.eq-table td {
padding: 0.75rem 1rem;
border-bottom: 1px solid #f1f5f9;
vertical-align: middle;
}
.eq-table tbody tr {
transition: background 0.15s ease;
}
.eq-table tbody tr:hover {
background: #f8fafc;
}
.eq-table tbody tr:last-child td {
border-bottom: none;
}
/* 테이블 컬럼별 스타일 */
.eq-col-code {
font-weight: 600;
color: #1e40af;
white-space: nowrap;
}
.eq-col-name {
font-weight: 500;
color: #1e293b;
max-width: 200px;
}
.eq-col-model,
.eq-col-spec {
color: #64748b;
font-size: 0.8125rem;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.eq-col-price {
font-weight: 600;
color: #059669;
text-align: right;
white-space: nowrap;
}
.eq-col-date {
color: #64748b;
font-size: 0.8125rem;
white-space: nowrap;
}
/* 상태 배지 */
.eq-status {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.625rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
}
.eq-status-active {
background: #dcfce7;
color: #166534;
}
.eq-status-maintenance {
background: #fef3c7;
color: #92400e;
}
.eq-status-inactive {
background: #fee2e2;
color: #991b1b;
}
/* 액션 버튼 */
.eq-actions {
display: flex;
gap: 0.5rem;
}
.eq-btn-action {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s ease;
font-size: 0.875rem;
}
.eq-btn-edit {
background: #eff6ff;
color: #3b82f6;
}
.eq-btn-edit:hover {
background: #3b82f6;
color: white;
}
.eq-btn-delete {
background: #fef2f2;
color: #ef4444;
}
.eq-btn-delete:hover {
background: #ef4444;
color: white;
}
/* 빈 상태 */
.eq-empty-state {
text-align: center;
padding: 4rem 2rem;
color: #64748b;
}
.eq-empty-state p {
margin-bottom: 1.5rem;
font-size: 1rem;
}
/* 테이블 스크롤 래퍼 */
.eq-table-wrapper {
overflow-x: auto;
max-height: calc(100vh - 380px);
overflow-y: auto;
}
/* 결과 카운트 */
.eq-result-count {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
font-size: 0.8125rem;
color: #64748b;
}
.eq-result-count strong {
color: #1e293b;
}
/* 반응형 */
@media (max-width: 1200px) {
.eq-stats-section {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 768px) {
.eq-stats-section {
grid-template-columns: repeat(2, 1fr);
}
.eq-filter-section {
flex-direction: column;
}
.eq-filter-group {
width: 100%;
}
.eq-table th,
.eq-table td {
padding: 0.625rem 0.75rem;
}
.eq-col-spec,
.eq-col-model {
display: none;
}
}
/* 모달 개선 */
.eq-modal-body {
padding: 1.5rem;
}
.eq-form-section {
margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid #f1f5f9;
}
.eq-form-section:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.eq-form-section-title {
font-size: 0.8rem;
font-weight: 600;
color: #3b82f6;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 1rem;
}
.eq-form-row {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
@media (max-width: 600px) {
.eq-form-row {
grid-template-columns: 1fr;
}
}

View 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;
}

View 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;
}

View 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;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,713 @@
/* =====================================================
mobile.css - 모바일 전용 스타일
대시보드, TBM, 작업보고서, 출근 관리 페이지 최적화
===================================================== */
/* ========== 모바일 헤더 간소화 ========== */
@media (max-width: 768px) {
.dashboard-header {
height: 56px !important;
padding: 0 0.75rem !important;
}
body {
padding-top: 56px !important;
}
.brand-logo {
width: 32px !important;
height: 32px !important;
}
.brand-text {
display: none !important;
}
.header-right {
gap: 0.5rem !important;
}
.dashboard-btn,
.report-btn {
display: none !important;
}
.notification-btn {
width: 36px !important;
height: 36px !important;
}
.user-profile {
padding: 0.25rem 0.5rem !important;
}
.user-avatar {
width: 32px !important;
height: 32px !important;
font-size: 0.875rem !important;
}
}
/* ========== 공통 모바일 스타일 ========== */
@media (max-width: 768px) {
/* 기본 여백 조정 */
.dashboard-main,
.page-container,
.main-content {
padding: 0.75rem !important;
padding-top: 1rem !important;
}
/* 카드 스타일 */
.card {
border-radius: 12px;
margin-bottom: 0.75rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.card-header {
padding: 0.875rem 1rem;
}
.card-body {
padding: 0.875rem 1rem;
}
.card-title {
font-size: 1rem;
font-weight: 600;
}
/* 버튼 모바일 최적화 */
.btn {
padding: 0.625rem 1rem;
font-size: 0.875rem;
border-radius: 8px;
min-height: 44px; /* 터치 타겟 최소 크기 */
}
.btn-sm {
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
min-height: 36px;
}
.btn-lg {
padding: 0.875rem 1.25rem;
font-size: 1rem;
min-height: 48px;
}
/* 폼 요소 */
.form-control,
.form-select,
input[type="text"],
input[type="date"],
input[type="time"],
input[type="number"],
select,
textarea {
font-size: 16px !important; /* iOS 줌 방지 */
padding: 0.75rem;
border-radius: 8px;
min-height: 44px;
}
/* 테이블 스크롤 */
.table-responsive {
margin: 0 -1rem;
padding: 0 1rem;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
/* 모달 모바일 최적화 */
.modal-content,
[class*="modal-container"] {
width: 95% !important;
max-width: none !important;
margin: 1rem auto;
max-height: 90vh;
overflow-y: auto;
}
/* 숨기기 유틸리티 */
.hide-mobile {
display: none !important;
}
/* 푸터 */
.dashboard-footer {
padding: 1rem;
}
.footer-content {
flex-direction: column;
gap: 0.5rem;
text-align: center;
}
.footer-links {
justify-content: center;
}
}
/* ========== 대시보드 페이지 ========== */
@media (max-width: 768px) {
/* 작업장 현황 섹션 */
.workplace-status-section .card-header .flex {
flex-direction: column;
gap: 0.75rem;
align-items: stretch !important;
}
.workplace-status-section .card-header select {
width: 100% !important;
}
.workplace-status-section .card-header .btn {
width: 100%;
}
/* 지도 영역 */
#workplaceMapContainer {
min-height: 300px !important;
}
#workplaceMapCanvas {
max-height: 350px;
}
/* 범례 숨기기 또는 축소 */
#mapLegend {
display: none;
}
/* 안내 메시지 */
#mapPlaceholder {
min-height: 250px !important;
padding: 2rem 1rem;
}
#mapPlaceholder h3 {
font-size: 1.125rem;
}
/* 임시 이동 설비 그리드 */
.moved-equipment-grid {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.moved-equipment-item {
padding: 1rem;
border-radius: 10px;
}
/* 작업장 모달 */
.workplace-modal-container {
width: 100% !important;
height: 100% !important;
max-height: 100vh !important;
border-radius: 0 !important;
margin: 0 !important;
}
.workplace-modal-header {
padding: 1rem;
position: sticky;
top: 0;
background: white;
z-index: 10;
}
.workplace-modal-tabs {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding: 0.5rem;
gap: 0.5rem;
flex-wrap: nowrap;
}
.workplace-tab {
flex-shrink: 0;
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
}
.workplace-tab .tab-text {
display: none;
}
.workplace-tab .tab-icon {
font-size: 1.25rem;
}
}
/* ========== TBM 페이지 ========== */
@media (max-width: 768px) {
/* TBM 헤더 */
.tbm-header {
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
.tbm-header-left,
.tbm-header-right {
width: 100%;
}
/* TBM 폼 */
.tbm-form {
padding: 1rem;
}
.tbm-form-row {
flex-direction: column;
gap: 0.75rem;
}
.tbm-form-group {
width: 100%;
}
/* 서명 영역 */
.signature-area {
height: 120px;
}
.signature-canvas {
height: 100px;
}
/* 팀원 목록 */
.team-member-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.team-member-item {
padding: 0.875rem;
border-radius: 10px;
}
.team-member-checkbox {
width: 24px;
height: 24px;
}
/* 안전점검 체크리스트 */
.safety-checklist {
gap: 0.5rem;
}
.checklist-item {
padding: 0.75rem;
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.checklist-item label {
font-size: 0.9rem;
}
.checklist-buttons {
width: 100%;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
}
.checklist-btn {
padding: 0.5rem;
font-size: 0.8rem;
}
/* TBM 카드 */
.tbm-session-card {
padding: 1rem;
}
.tbm-session-header {
flex-direction: column;
gap: 0.5rem;
}
/* 날씨 정보 */
.weather-info {
flex-wrap: wrap;
gap: 0.5rem;
}
.weather-item {
min-width: 80px;
}
}
/* ========== 작업보고서 페이지 ========== */
@media (max-width: 768px) {
/* 작업보고서 헤더 */
.work-report-header {
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
/* 날짜/작업자 선택 */
.report-filter-row {
flex-direction: column;
gap: 0.75rem;
}
.report-filter-group {
width: 100%;
}
/* 작업 목록 */
.work-item {
padding: 1rem;
border-radius: 10px;
margin-bottom: 0.75rem;
}
.work-item-header {
flex-direction: column;
gap: 0.5rem;
align-items: flex-start;
}
.work-item-time {
font-size: 0.8rem;
}
/* 작업 입력 폼 */
.work-input-form {
padding: 1rem;
}
.work-form-row {
flex-direction: column;
gap: 0.75rem;
}
.work-form-group {
width: 100%;
}
/* 시간 입력 */
.time-input-group {
display: flex;
gap: 0.5rem;
align-items: center;
}
.time-input-group input {
flex: 1;
}
/* 결함 입력 */
.defect-section {
padding: 0.875rem;
border-radius: 10px;
}
.defect-item {
padding: 0.75rem;
margin-bottom: 0.5rem;
}
/* 요약 카드 */
.summary-cards {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
}
.summary-card {
padding: 0.875rem;
text-align: center;
}
.summary-card-value {
font-size: 1.25rem;
}
.summary-card-label {
font-size: 0.75rem;
}
}
/* ========== 출근 체크 / 근무 현황 페이지 ========== */
@media (max-width: 768px) {
/* 출근 체크 버튼 */
.checkin-button-container {
padding: 1rem;
}
.checkin-btn {
width: 100%;
padding: 1.5rem;
font-size: 1.25rem;
border-radius: 16px;
min-height: 80px;
}
.checkin-btn-icon {
font-size: 2rem;
margin-bottom: 0.5rem;
}
/* 출근 상태 카드 */
.checkin-status-card {
padding: 1.25rem;
border-radius: 12px;
text-align: center;
}
.checkin-time {
font-size: 2rem;
font-weight: 700;
}
/* 작업자 목록 */
.worker-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.worker-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
background: white;
border-radius: 10px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.worker-info {
display: flex;
align-items: center;
gap: 0.75rem;
}
.worker-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--primary-100, #dbeafe);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
color: var(--primary-700, #1d4ed8);
}
.worker-name {
font-weight: 600;
font-size: 0.9375rem;
}
.worker-status-badge {
padding: 0.375rem 0.75rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
}
/* 근무 현황 테이블 대체 */
.work-status-table-mobile {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.work-status-row {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 0.75rem;
align-items: center;
padding: 0.875rem 1rem;
background: white;
border-radius: 10px;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
.work-status-name {
font-weight: 600;
font-size: 0.9375rem;
}
.work-status-hours {
font-size: 0.875rem;
color: var(--gray-600, #4b5563);
}
/* 날짜 선택 */
.date-selector {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: white;
border-radius: 10px;
margin-bottom: 1rem;
}
.date-selector input[type="date"] {
flex: 1;
border: none;
background: transparent;
font-size: 1rem;
font-weight: 600;
}
.date-nav-btn {
width: 40px;
height: 40px;
border-radius: 8px;
background: var(--gray-100, #f3f4f6);
border: none;
font-size: 1.25rem;
display: flex;
align-items: center;
justify-content: center;
}
/* 통계 요약 */
.attendance-summary {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
margin-bottom: 1rem;
}
.attendance-stat {
text-align: center;
padding: 0.875rem 0.5rem;
background: white;
border-radius: 10px;
}
.attendance-stat-value {
font-size: 1.5rem;
font-weight: 700;
color: var(--primary-600, #2563eb);
}
.attendance-stat-label {
font-size: 0.6875rem;
color: var(--gray-500, #6b7280);
margin-top: 0.25rem;
}
/* 저장 버튼 영역 */
.save-button-container {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 1rem;
background: white;
box-shadow: 0 -2px 10px rgba(0,0,0,0.1);
z-index: 100;
}
.save-button-container .btn {
width: 100%;
}
/* 바디 패딩 (고정 버튼 공간) */
body.has-fixed-button {
padding-bottom: 80px;
}
}
/* ========== 모바일 퀵 액션 버튼 (FAB) ========== */
@media (max-width: 768px) {
.mobile-fab {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--primary-500, #3b82f6);
color: white;
border: none;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
font-size: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
z-index: 90;
transition: transform 0.2s, box-shadow 0.2s;
}
.mobile-fab:active {
transform: scale(0.95);
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.4);
}
}
/* ========== 터치 최적화 ========== */
@media (max-width: 768px) {
/* 터치 피드백 */
.btn:active,
.card:active,
.worker-item:active,
.nav-item:active {
transform: scale(0.98);
opacity: 0.9;
}
/* 스크롤 스냅 */
.horizontal-scroll {
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
}
.horizontal-scroll > * {
scroll-snap-align: start;
}
/* 탭 바 최적화 */
.tab-bar {
display: flex;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
gap: 0.25rem;
padding: 0.25rem;
background: var(--gray-100, #f3f4f6);
border-radius: 10px;
}
.tab-bar::-webkit-scrollbar {
display: none;
}
.tab-item {
flex-shrink: 0;
padding: 0.625rem 1rem;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
white-space: nowrap;
transition: all 0.2s;
}
.tab-item.active {
background: white;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
}
/* ========== 다크모드 지원 (선택적) ========== */
@media (max-width: 768px) and (prefers-color-scheme: dark) {
/* 다크모드 색상 조정 필요시 여기에 추가 */
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,583 @@
/**
* 나의 출근 현황 페이지 스타일
*/
/* 페이지 헤더 */
.page-header {
margin-bottom: 24px;
}
.page-title {
font-size: 28px;
font-weight: 700;
color: #1a1a1a;
margin: 0 0 8px 0;
display: flex;
align-items: center;
gap: 12px;
}
.title-icon {
font-size: 32px;
}
.page-description {
font-size: 14px;
color: #666;
margin: 0;
}
/* 통계 카드 섹션 */
.stats-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
display: flex;
align-items: center;
gap: 16px;
transition: transform 0.2s, box-shadow 0.2s;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
.stat-icon {
font-size: 36px;
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
background: #f8f9fa;
border-radius: 12px;
}
.stat-info {
flex: 1;
}
.stat-value {
font-size: 24px;
font-weight: 700;
color: #1a1a1a;
margin-bottom: 4px;
}
.stat-label {
font-size: 13px;
color: #666;
font-weight: 500;
}
/* 탭 컨테이너 */
.tab-container {
display: flex;
gap: 8px;
margin-bottom: 20px;
border-bottom: 2px solid #e9ecef;
}
.tab-btn {
background: none;
border: none;
padding: 12px 24px;
font-size: 14px;
font-weight: 600;
color: #6c757d;
cursor: pointer;
border-bottom: 3px solid transparent;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 8px;
}
.tab-btn:hover {
color: #495057;
background: #f8f9fa;
}
.tab-btn.active {
color: #007bff;
border-bottom-color: #007bff;
}
.tab-icon {
font-size: 16px;
}
/* 탭 컨텐츠 */
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
/* 테이블 스타일 */
#attendanceTable {
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
#attendanceTable thead {
background: #f8f9fa;
}
#attendanceTable th {
padding: 16px 12px;
font-weight: 600;
color: #495057;
text-align: center;
border-bottom: 2px solid #dee2e6;
}
#attendanceTable td {
padding: 14px 12px;
text-align: center;
border-bottom: 1px solid #f1f3f5;
}
#attendanceTable tbody tr {
transition: background-color 0.2s;
cursor: pointer;
}
#attendanceTable tbody tr:hover {
background-color: #f8f9fa;
}
.loading-cell,
.empty-cell,
.error-cell {
text-align: center;
padding: 40px 20px;
color: #6c757d;
font-size: 14px;
}
.error-cell {
color: #dc3545;
}
.notes-cell {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
color: #6c757d;
font-size: 13px;
}
/* 상태 배지 */
.status-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
text-transform: capitalize;
}
.status-badge.normal {
background: #d4edda;
color: #155724;
}
.status-badge.late {
background: #fff3cd;
color: #856404;
}
.status-badge.early {
background: #ffe5b5;
color: #a56200;
}
.status-badge.absent {
background: #f8d7da;
color: #721c24;
}
.status-badge.vacation {
background: #cce5ff;
color: #004085;
}
/* 달력 스타일 */
#calendarContainer {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.calendar-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.calendar-header h3 {
font-size: 20px;
font-weight: 700;
color: #1a1a1a;
margin: 0;
}
.calendar-nav-btn {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
width: 40px;
height: 40px;
font-size: 18px;
cursor: pointer;
transition: all 0.2s;
}
.calendar-nav-btn:hover {
background: #e9ecef;
border-color: #adb5bd;
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 8px;
}
.calendar-day-header {
text-align: center;
font-weight: 600;
font-size: 13px;
color: #495057;
padding: 12px 8px;
background: #f8f9fa;
border-radius: 4px;
}
.calendar-day {
aspect-ratio: 1;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 8px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
transition: all 0.2s;
background: white;
}
.calendar-day.empty {
background: #f8f9fa;
border-color: #f1f3f5;
}
.calendar-day.has-record {
cursor: pointer;
font-weight: 600;
}
.calendar-day.has-record:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
/* 달력 날짜 상태별 색상 */
.calendar-day.normal {
background: #d4edda;
border-color: #c3e6cb;
color: #155724;
}
.calendar-day.late {
background: #fff3cd;
border-color: #ffeaa7;
color: #856404;
}
.calendar-day.early {
background: #ffe5b5;
border-color: #ffd98a;
color: #a56200;
}
.calendar-day.absent {
background: #f8d7da;
border-color: #f5c6cb;
color: #721c24;
}
.calendar-day.vacation {
background: #cce5ff;
border-color: #b8daff;
color: #004085;
}
.calendar-day-number {
font-size: 14px;
margin-bottom: 4px;
}
.calendar-day-status {
font-size: 16px;
}
/* 달력 범례 */
.calendar-legend {
display: flex;
flex-wrap: wrap;
gap: 16px;
justify-content: center;
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid #e9ecef;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #495057;
}
.legend-dot {
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid #dee2e6;
}
.legend-dot.normal {
background: #d4edda;
border-color: #c3e6cb;
}
.legend-dot.late {
background: #fff3cd;
border-color: #ffeaa7;
}
.legend-dot.early {
background: #ffe5b5;
border-color: #ffd98a;
}
.legend-dot.absent {
background: #f8d7da;
border-color: #f5c6cb;
}
.legend-dot.vacation {
background: #cce5ff;
border-color: #b8daff;
}
/* 모달 스타일 */
.modal {
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
}
.modal-content {
background-color: white;
border-radius: 12px;
width: 90%;
max-width: 500px;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.modal-header {
padding: 20px 24px;
border-bottom: 1px solid #e9ecef;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h2 {
margin: 0;
font-size: 20px;
font-weight: 700;
color: #1a1a1a;
}
.modal-close-btn {
background: none;
border: none;
font-size: 28px;
color: #6c757d;
cursor: pointer;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.2s;
}
.modal-close-btn:hover {
background: #f8f9fa;
color: #343a40;
}
.modal-body {
padding: 24px;
}
.modal-footer {
padding: 16px 24px;
border-top: 1px solid #e9ecef;
display: flex;
justify-content: flex-end;
gap: 12px;
}
/* 상세 정보 그리드 */
.detail-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
}
.detail-item {
display: flex;
flex-direction: column;
gap: 8px;
}
.detail-item.full-width {
grid-column: 1 / -1;
}
.detail-item label {
font-size: 13px;
font-weight: 600;
color: #6c757d;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.detail-item div {
font-size: 15px;
color: #1a1a1a;
}
/* 버튼 스타일 */
.btn-primary {
background-color: #007bff;
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s;
}
.btn-primary:hover {
background-color: #0056b3;
}
.btn-secondary {
background-color: #6c757d;
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s;
}
.btn-secondary:hover {
background-color: #5a6268;
}
/* 반응형 디자인 */
@media (max-width: 768px) {
.stats-section {
grid-template-columns: 1fr;
}
.stat-card {
padding: 16px;
}
.stat-icon {
width: 48px;
height: 48px;
font-size: 28px;
}
.stat-value {
font-size: 20px;
}
.tab-btn {
padding: 10px 16px;
font-size: 13px;
}
.calendar-grid {
gap: 4px;
}
.calendar-day {
padding: 4px;
}
.calendar-day-number {
font-size: 12px;
}
.calendar-day-status {
font-size: 14px;
}
.detail-grid {
grid-template-columns: 1fr;
}
#attendanceTable {
font-size: 12px;
}
#attendanceTable th,
#attendanceTable td {
padding: 10px 8px;
}
.notes-cell {
max-width: 120px;
}
}

View File

@@ -0,0 +1,350 @@
/* My Dashboard CSS */
.dashboard-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem 1.5rem;
background: #f8f9fa;
min-height: 100vh;
}
.back-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: rgba(255, 255, 255, 0.9);
color: #495057;
text-decoration: none;
border-radius: 0.5rem;
font-weight: 500;
margin-bottom: 1.5rem;
transition: all 0.3s ease;
box-shadow: 0 0.125rem 0.25rem rgba(0,0,0,0.1);
}
.back-button:hover {
background: white;
color: #007bff;
transform: translateY(-0.0625rem);
}
.page-header {
text-align: center;
margin-bottom: 2rem;
}
.page-header h1 {
font-size: 2rem;
color: #333;
margin-bottom: 0.5rem;
}
.page-header p {
color: #666;
font-size: 1.1rem;
}
/* 사용자 정보 카드 */
.user-info-card {
background: white;
border-radius: 0.75rem;
padding: 1.5rem;
margin-bottom: 2rem;
box-shadow: 0 0.25rem 0.5rem rgba(0,0,0,0.1);
}
.info-row {
display: flex;
gap: 2rem;
flex-wrap: wrap;
}
.info-item {
display: flex;
gap: 0.5rem;
}
.info-item .label {
font-weight: 600;
color: #555;
}
/* 연차 정보 위젯 */
.vacation-widget {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 0.75rem;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: 0 0.25rem 0.5rem rgba(0,0,0,0.15);
}
.vacation-widget h2 {
margin-bottom: 1.5rem;
}
.vacation-summary {
display: flex;
justify-content: space-around;
margin-bottom: 1.5rem;
}
.vacation-summary .stat {
text-align: center;
}
.vacation-summary .label {
display: block;
font-size: 0.9rem;
opacity: 0.9;
margin-bottom: 0.5rem;
}
.vacation-summary .value {
display: block;
font-size: 2rem;
font-weight: 700;
}
.progress-bar {
height: 1.5rem;
background: rgba(255,255,255,0.2);
border-radius: 0.75rem;
overflow: hidden;
}
.progress {
height: 100%;
background: rgba(255,255,255,0.8);
transition: width 0.5s ease;
}
/* 캘린더 섹션 */
.calendar-section {
background: white;
border-radius: 0.75rem;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: 0 0.25rem 0.5rem rgba(0,0,0,0.1);
}
.calendar-section h2 {
margin-bottom: 1rem;
}
.calendar-controls {
display: flex;
justify-content: center;
align-items: center;
gap: 2rem;
margin-bottom: 1.5rem;
}
.calendar-controls button {
background: #667eea;
color: white;
border: none;
border-radius: 0.5rem;
padding: 0.5rem 1rem;
cursor: pointer;
font-size: 1rem;
}
.calendar-controls button:hover {
background: #764ba2;
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0.5rem;
}
.calendar-header {
text-align: center;
font-weight: 600;
padding: 0.5rem;
background: #f8f9fa;
border-radius: 0.25rem;
}
.calendar-day {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
background: #f8f9fa;
cursor: pointer;
transition: all 0.2s ease;
}
.calendar-day:hover:not(.empty) {
transform: scale(1.05);
}
.calendar-day.empty {
background: transparent;
}
.calendar-day.normal {
background: #d4edda;
color: #155724;
}
.calendar-day.late {
background: #fff3cd;
color: #856404;
}
.calendar-day.vacation {
background: #d1ecf1;
color: #0c5460;
}
.calendar-day.absent {
background: #f8d7da;
color: #721c24;
}
.calendar-legend {
display: flex;
justify-content: center;
gap: 1.5rem;
margin-top: 1.5rem;
flex-wrap: wrap;
}
.calendar-legend span {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
}
.dot {
width: 1rem;
height: 1rem;
border-radius: 50%;
}
.dot.normal {
background: #d4edda;
}
.dot.late {
background: #fff3cd;
}
.dot.vacation {
background: #d1ecf1;
}
.dot.absent {
background: #f8d7da;
}
/* 근무 시간 통계 */
.work-hours-stats {
background: white;
border-radius: 0.75rem;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: 0 0.25rem 0.5rem rgba(0,0,0,0.1);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
margin-top: 1.5rem;
}
.stat-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 1.5rem;
border-radius: 0.75rem;
text-align: center;
}
.stat-card .label {
display: block;
font-size: 0.9rem;
opacity: 0.9;
margin-bottom: 0.5rem;
}
.stat-card .value {
display: block;
font-size: 2rem;
font-weight: 700;
}
/* 최근 작업 보고서 */
.recent-reports {
background: white;
border-radius: 0.75rem;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: 0 0.25rem 0.5rem rgba(0,0,0,0.1);
}
.recent-reports h2 {
margin-bottom: 1.5rem;
}
.report-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #e9ecef;
}
.report-item:last-child {
border-bottom: none;
}
.report-item .date {
color: #666;
font-size: 0.9rem;
}
.report-item .project {
flex: 1;
margin: 0 1rem;
font-weight: 500;
}
.report-item .hours {
color: #667eea;
font-weight: 600;
}
.empty-message {
text-align: center;
color: #999;
padding: 2rem;
}
/* 반응형 */
@media (max-width: 768px) {
.dashboard-container {
padding: 1rem;
}
.vacation-summary {
flex-direction: column;
gap: 1rem;
}
.calendar-grid {
gap: 0.25rem;
}
.calendar-legend {
gap: 0.75rem;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,953 @@
/* 시스템 대시보드 전용 스타일 */
/* 시스템 대시보드 배경 - 깔끔한 흰색 */
.main-layout .content-wrapper {
background: #ffffff;
min-height: calc(100vh - 80px);
padding: 0;
border-left: 1px solid #e0e0e0;
}
/* 시스템 관리자 배너 */
.system-banner {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 2rem;
margin-bottom: 2rem;
position: relative;
overflow: hidden;
}
.system-banner::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="grid" width="10" height="10" patternUnits="userSpaceOnUse"><path d="M 10 0 L 0 0 0 10" fill="none" stroke="rgba(255,255,255,0.1)" stroke-width="0.5"/></pattern></defs><rect width="100" height="100" fill="url(%23grid)"/></svg>');
opacity: 0.3;
}
.banner-content {
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
z-index: 1;
}
.banner-left {
display: flex;
align-items: center;
gap: 1.5rem;
}
.system-icon {
font-size: 3rem;
background: rgba(255,255,255,0.2);
padding: 1rem;
border-radius: 50%;
backdrop-filter: blur(10px);
border: 2px solid rgba(255,255,255,0.3);
}
.banner-text h1 {
margin: 0 0 0.5rem 0;
font-size: 2.2rem;
font-weight: 700;
text-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.banner-text p {
margin: 0;
font-size: 1.1rem;
opacity: 0.9;
font-weight: 300;
}
.banner-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 1rem;
}
.system-status {
display: flex;
align-items: center;
gap: 0.5rem;
background: rgba(255,255,255,0.15);
padding: 0.5rem 1rem;
border-radius: 20px;
backdrop-filter: blur(10px);
border: 1px solid rgba(255,255,255,0.2);
}
.quick-actions {
display: flex;
gap: 0.5rem;
}
.quick-btn {
background: rgba(255,255,255,0.2);
border: 1px solid rgba(255,255,255,0.3);
border-radius: 50%;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
font-size: 1.2rem;
}
.quick-btn:hover {
background: rgba(255,255,255,0.3);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
animation: pulse 2s infinite;
}
.status-dot.online {
background: #2ecc71;
box-shadow: 0 0 10px rgba(46,204,113,0.5);
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
/* 메인 컨텐츠 패딩 조정 */
.main-content {
padding: 0 2rem 2rem 2rem;
}
/* 반응형 배너 */
@media (max-width: 768px) {
.system-banner {
padding: 1.5rem;
}
.banner-content {
flex-direction: column;
gap: 1.5rem;
text-align: center;
}
.banner-left {
flex-direction: column;
gap: 1rem;
}
.system-icon {
font-size: 2.5rem;
padding: 0.8rem;
}
.banner-text h1 {
font-size: 1.8rem;
}
.banner-text p {
font-size: 1rem;
}
.banner-right {
align-items: center;
}
.main-content {
padding: 0 1rem 2rem 1rem;
}
}
.page-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
color: #333;
border-bottom: 2px solid #f0f0f0;
padding-bottom: 1rem;
}
.page-header h1 {
margin: 0;
font-size: 1.8rem;
font-weight: 500;
color: #2c3e50;
}
/* 시스템 배지 */
.system-badge {
background: #e74c3c;
color: white;
padding: 0.2rem 0.6rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
border: 1px solid #c0392b;
}
.header-right {
display: flex;
align-items: center;
}
.user-info {
display: flex;
align-items: center;
gap: 1rem;
}
.logout-btn {
background: #e74c3c;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 8px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.3s ease;
}
.logout-btn:hover {
background: #c0392b;
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(231, 76, 60, 0.3);
}
/* 메인 컨텐츠 */
.main-content {
max-width: 1200px;
margin: 0 auto;
padding: 0;
display: flex;
flex-direction: column;
gap: 2rem;
width: 100%;
}
/* 시스템 상태 개요 */
.system-overview {
margin-bottom: 2rem;
}
.system-overview h2 {
color: #2c3e50;
margin-bottom: 1.5rem;
font-size: 1.4rem;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.5rem;
border-bottom: 1px solid #ecf0f1;
padding-bottom: 0.5rem;
}
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.status-card {
background: #ffffff;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.2s ease;
}
.status-card:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.status-icon {
width: 60px;
height: 60px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
background: linear-gradient(45deg, #3498db, #2980b9);
color: white;
}
.status-info h3 {
margin: 0 0 0.5rem 0;
color: #2c3e50;
font-size: 1rem;
font-weight: 500;
}
.status-value {
font-size: 1.3rem;
font-weight: 600;
margin: 0.5rem 0;
}
.status-value.online {
color: #27ae60;
}
.status-value.warning {
color: #f39c12;
}
.status-value.error {
color: #e74c3c;
}
.status-info small {
color: #7f8c8d;
font-size: 0.85rem;
}
/* 관리 섹션 */
.management-section {
margin-bottom: 2rem;
}
.management-section h2 {
color: #2c3e50;
margin-bottom: 1.5rem;
font-size: 1.4rem;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.5rem;
border-bottom: 1px solid #ecf0f1;
padding-bottom: 0.5rem;
}
.management-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1rem;
}
.management-card {
background: #ffffff;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.2s ease;
display: flex;
flex-direction: column;
height: 100%;
}
.management-card:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.management-card.primary {
border-left: 4px solid #3498db;
}
.card-header {
display: flex;
align-items: center;
gap: 0.8rem;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid #f0f0f0;
}
.card-header i {
font-size: 1.2rem;
color: #3498db;
}
.card-header h3 {
margin: 0;
color: #2c3e50;
font-size: 1.1rem;
font-weight: 500;
}
.card-content {
display: flex;
flex-direction: column;
height: 100%;
}
.card-content p {
color: #7f8c8d;
margin-bottom: 1.5rem;
line-height: 1.5;
font-size: 0.9rem;
flex: 1;
}
.card-actions {
display: flex;
gap: 0.8rem;
margin-top: auto;
}
.btn {
padding: 0.6rem 1rem;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 0.5rem;
background: #ffffff;
text-decoration: none;
}
.btn-primary {
background: #3498db;
color: white;
border-color: #2980b9;
}
.btn-primary:hover {
background: #2980b9;
}
.btn-secondary {
background: #95a5a6;
color: white;
border-color: #7f8c8d;
}
.btn-secondary:hover {
background: #7f8c8d;
}
/* 최근 활동 */
.recent-activity {
margin-bottom: 2rem;
}
.recent-activity h2 {
color: white;
margin-bottom: 1.5rem;
font-size: 1.8rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
}
.activity-container {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 16px;
padding: 1.5rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.activity-list {
max-height: 400px;
overflow-y: auto;
}
.activity-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
transition: background-color 0.3s ease;
}
.activity-item:hover {
background: rgba(52, 152, 219, 0.05);
}
.activity-item:last-child {
border-bottom: none;
}
.activity-icon {
width: 40px;
height: 40px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(45deg, #3498db, #2980b9);
color: white;
font-size: 1rem;
}
.activity-info h4 {
margin: 0 0 0.3rem 0;
color: #2c3e50;
font-size: 1rem;
font-weight: 600;
}
.activity-info p {
margin: 0;
color: #7f8c8d;
font-size: 0.9rem;
}
.activity-time {
margin-left: auto;
color: #95a5a6;
font-size: 0.8rem;
}
/* 모달 */
.modal {
display: none;
position: fixed;
z-index: 2000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(5px);
}
.modal-content {
background: white;
margin: 5% auto;
padding: 0;
border-radius: 16px;
width: 90%;
max-width: 800px;
max-height: 80vh;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.modal-header {
background: linear-gradient(45deg, #3498db, #2980b9);
color: white;
padding: 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
margin: 0;
font-size: 1.3rem;
font-weight: 600;
}
.close-btn {
background: none;
border: none;
color: white;
font-size: 1.5rem;
cursor: pointer;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background-color 0.3s ease;
}
.close-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
.modal-body {
padding: 2rem;
max-height: 60vh;
overflow-y: auto;
}
/* 반응형 디자인 */
@media (max-width: 1200px) {
.status-grid {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
.management-grid {
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
}
@media (max-width: 768px) {
.main-layout .content-wrapper {
padding: 1rem;
}
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.page-header h1 {
font-size: 1.5rem;
}
.status-grid,
.management-grid {
grid-template-columns: 1fr;
gap: 0.8rem;
}
.status-card,
.management-card {
padding: 1rem;
}
.modal-content {
width: 95%;
margin: 5% auto;
}
.system-overview h2,
.management-section h2,
.recent-activity h2 {
font-size: 1.2rem;
}
}
@media (max-width: 480px) {
.main-layout .content-wrapper {
padding: 0.5rem;
}
.status-card,
.management-card {
padding: 0.8rem;
}
.btn {
padding: 0.5rem 0.8rem;
font-size: 0.8rem;
}
}
/* 계정 관리 스타일 */
.account-management {
padding: 1rem;
}
.account-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 2px solid #ecf0f1;
}
.account-header h4 {
margin: 0;
color: #2c3e50;
font-size: 1.3rem;
font-weight: 600;
}
.account-filters {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.account-filters input,
.account-filters select {
padding: 0.7rem;
border: 2px solid #ecf0f1;
border-radius: 8px;
font-size: 0.9rem;
transition: border-color 0.3s ease;
}
.account-filters input:focus,
.account-filters select:focus {
outline: none;
border-color: #3498db;
}
.users-table {
overflow-x: auto;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}
.users-table table {
width: 100%;
border-collapse: collapse;
background: white;
}
.users-table th,
.users-table td {
padding: 1rem;
text-align: left;
border-bottom: 1px solid #ecf0f1;
}
.users-table th {
background: linear-gradient(45deg, #3498db, #2980b9);
color: white;
font-weight: 600;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.users-table tr:hover {
background: rgba(52, 152, 219, 0.05);
}
.role-badge {
padding: 0.3rem 0.8rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.role-badge.role-system {
background: linear-gradient(45deg, #e74c3c, #c0392b);
color: white;
}
.role-badge.role-admin {
background: linear-gradient(45deg, #f39c12, #e67e22);
color: white;
}
.role-badge.role-leader {
background: linear-gradient(45deg, #9b59b6, #8e44ad);
color: white;
}
.role-badge.role-user {
background: linear-gradient(45deg, #95a5a6, #7f8c8d);
color: white;
}
.status-badge {
padding: 0.3rem 0.8rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: bold;
text-transform: uppercase;
}
.status-badge.active {
background: linear-gradient(45deg, #27ae60, #2ecc71);
color: white;
}
.status-badge.inactive {
background: linear-gradient(45deg, #e74c3c, #c0392b);
color: white;
}
.action-buttons {
display: flex;
gap: 0.5rem;
}
.btn-small {
padding: 0.4rem 0.6rem;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.8rem;
transition: all 0.3s ease;
}
.btn-edit {
background: linear-gradient(45deg, #3498db, #2980b9);
color: white;
}
.btn-edit:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.3);
}
.btn-delete {
background: linear-gradient(45deg, #e74c3c, #c0392b);
color: white;
}
.btn-delete:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(231, 76, 60, 0.3);
}
.loading-spinner {
text-align: center;
padding: 3rem;
color: #7f8c8d;
}
.loading-spinner i {
font-size: 2rem;
margin-bottom: 1rem;
}
.error-message {
text-align: center;
padding: 3rem;
color: #e74c3c;
}
.error-message i {
font-size: 3rem;
margin-bottom: 1rem;
}
.error-message p {
margin: 1rem 0;
font-size: 1.1rem;
}
/* 알림 스타일 */
.notification {
position: fixed;
top: 20px;
right: 20px;
padding: 1rem 1.5rem;
border-radius: 8px;
color: white;
font-weight: 500;
z-index: 3000;
animation: slideIn 0.3s ease;
}
.notification-info {
background: linear-gradient(45deg, #3498db, #2980b9);
}
.notification-success {
background: linear-gradient(45deg, #27ae60, #2ecc71);
}
.notification-warning {
background: linear-gradient(45deg, #f39c12, #e67e22);
}
.notification-error {
background: linear-gradient(45deg, #e74c3c, #c0392b);
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* 사용자 폼 스타일 */
.user-edit-form,
.user-create-form {
padding: 1.5rem;
}
.user-edit-form h4,
.user-create-form h4 {
margin: 0 0 2rem 0;
color: #2c3e50;
font-size: 1.3rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: #2c3e50;
font-weight: 500;
font-size: 0.9rem;
}
.form-group input,
.form-group select {
width: 100%;
padding: 0.8rem;
border: 2px solid #ecf0f1;
border-radius: 8px;
font-size: 0.9rem;
transition: border-color 0.3s ease;
box-sizing: border-box;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #3498db;
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1);
}
.form-group input:disabled {
background: #f8f9fa;
color: #6c757d;
cursor: not-allowed;
}
.form-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 2px solid #ecf0f1;
}
/* 스크롤바 스타일링 */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.1);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: linear-gradient(45deg, #3498db, #2980b9);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: linear-gradient(45deg, #2980b9, #3498db);
}

File diff suppressed because it is too large Load Diff

View 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;
}

View File

@@ -0,0 +1,472 @@
/**
* vacation-allocation.css
* 휴가 발생 입력 페이지 스타일
*/
.page-container {
min-height: 100vh;
background: var(--color-bg-primary);
}
.main-content {
padding: 2rem 0;
}
.content-wrapper {
max-width: 1400px;
margin: 0 auto;
padding: 0 2rem;
}
/* 페이지 헤더 */
.page-header {
margin-bottom: 2rem;
}
.page-title {
font-size: 2rem;
font-weight: 700;
color: var(--color-text-primary);
margin-bottom: 0.5rem;
}
.page-description {
font-size: 1rem;
color: var(--color-text-secondary);
margin: 0;
}
/* 탭 네비게이션 */
.tab-navigation {
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
border-bottom: 2px solid var(--color-border);
}
.tab-button {
padding: 1rem 2rem;
background: none;
border: none;
border-bottom: 3px solid transparent;
font-size: 1rem;
font-weight: 600;
color: var(--color-text-secondary);
cursor: pointer;
transition: all 0.2s;
position: relative;
bottom: -2px;
}
.tab-button:hover {
color: var(--color-primary);
background: var(--color-bg-secondary);
}
.tab-button.active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
/* 탭 콘텐츠 */
.tab-content {
display: none;
}
.tab-content.active {
display: block;
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 폼 섹션 */
.form-section {
margin-bottom: 2rem;
padding-bottom: 2rem;
border-bottom: 1px solid var(--color-border);
}
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 1.5rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group.full-width {
grid-column: 1 / -1;
}
.form-group label {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text-primary);
}
.required {
color: #ef4444;
}
.form-select,
.form-input {
padding: 0.625rem 1rem;
border: 1px solid var(--color-border);
border-radius: 8px;
background: white;
font-size: 0.875rem;
color: var(--color-text-primary);
transition: all 0.2s;
}
.form-select:focus,
.form-input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.form-input[type="number"] {
max-width: 200px;
}
.form-group small {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
/* 자동 계산 섹션 */
.auto-calculate-section {
background: var(--color-bg-secondary);
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 1.5rem;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.section-header h3 {
font-size: 1rem;
font-weight: 600;
color: var(--color-text-primary);
margin: 0;
}
.alert {
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
font-size: 0.875rem;
}
.alert-info {
background: #dbeafe;
color: #1e40af;
border: 1px solid #93c5fd;
}
.alert-warning {
background: #fef3c7;
color: #92400e;
border: 1px solid #fde68a;
}
.alert-success {
background: #d1fae5;
color: #065f46;
border: 1px solid #6ee7b7;
}
/* 폼 액션 버튼 */
.form-actions {
display: flex;
gap: 1rem;
margin-top: 1.5rem;
}
/* 기존 데이터 섹션 */
.existing-data-section {
margin-top: 2rem;
}
.existing-data-section h3 {
font-size: 1.125rem;
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: 1rem;
}
/* 미리보기 섹션 */
.preview-section {
margin-top: 2rem;
animation: slideDown 0.3s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
max-height: 0;
}
to {
opacity: 1;
max-height: 1000px;
}
}
.preview-section h3 {
font-size: 1.125rem;
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: 1rem;
}
/* 테이블 */
.table-responsive {
overflow-x: auto;
border-radius: 8px;
border: 1px solid var(--color-border);
}
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.data-table thead {
background: var(--color-bg-secondary);
}
.data-table th {
padding: 1rem;
text-align: left;
font-weight: 600;
color: var(--color-text-primary);
border-bottom: 2px solid var(--color-border);
white-space: nowrap;
}
.data-table tbody tr {
border-bottom: 1px solid var(--color-border);
transition: background 0.2s;
}
.data-table tbody tr:hover {
background: var(--color-bg-secondary);
}
.data-table td {
padding: 1rem;
color: var(--color-text-primary);
}
.loading-state {
padding: 3rem 1rem !important;
text-align: center;
}
.loading-state .spinner {
margin: 0 auto 1rem;
width: 40px;
height: 40px;
border: 4px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-state p {
margin: 0;
color: var(--color-text-secondary);
}
/* 액션 버튼 */
.action-buttons {
display: flex;
gap: 0.5rem;
}
.btn-icon {
padding: 0.5rem;
min-width: auto;
font-size: 1rem;
}
/* 배지 */
.badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
}
.badge-info {
background: #dbeafe;
color: #1e40af;
}
.badge-success {
background: #d1fae5;
color: #065f46;
}
.badge-warning {
background: #fef3c7;
color: #92400e;
}
.badge-error {
background: #fee2e2;
color: #991b1b;
}
/* 모달 */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal.active {
display: flex;
}
.modal-content {
background: white;
border-radius: 12px;
max-width: 600px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
animation: modalSlideIn 0.3s ease-out;
}
@keyframes modalSlideIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid var(--color-border);
}
.modal-header h3 {
font-size: 1.25rem;
font-weight: 600;
color: var(--color-text-primary);
margin: 0;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
color: var(--color-text-secondary);
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.2s;
}
.modal-close:hover {
background: var(--color-bg-secondary);
color: var(--color-text-primary);
}
.modal-body {
padding: 1.5rem;
}
/* 반응형 */
@media (max-width: 768px) {
.content-wrapper {
padding: 0 1rem;
}
.page-title {
font-size: 1.5rem;
}
.tab-navigation {
overflow-x: auto;
}
.tab-button {
padding: 0.75rem 1.25rem;
font-size: 0.875rem;
white-space: nowrap;
}
.form-row {
grid-template-columns: 1fr;
}
.form-actions {
flex-direction: column;
}
.form-actions button {
width: 100%;
}
.section-header {
flex-direction: column;
gap: 1rem;
align-items: flex-start;
}
.modal-content {
width: 95%;
max-height: 95vh;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,431 @@
/* 작업 관리 페이지 스타일 */
/* 기본 레이아웃 */
body {
margin: 0;
padding: 0;
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
/* 헤더 스타일은 navbar.html에서 관리됨 */
/* 메인 콘텐츠 */
.dashboard-main {
flex: 1;
padding: 2rem;
min-height: calc(100vh - 80px);
}
.page-header {
margin-bottom: 2rem;
}
.page-title-section {
text-align: center;
margin-bottom: 2rem;
}
.page-title {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
font-size: 2.5rem;
font-weight: 700;
color: white;
margin: 0 0 0.5rem 0;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.title-icon {
font-size: 2.5rem;
}
.page-description {
font-size: 1.125rem;
color: rgba(255, 255, 255, 0.9);
margin: 0;
font-weight: 400;
}
/* 관리 메뉴 그리드 */
.management-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 1.5rem;
margin-bottom: 3rem;
}
.management-card {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border-radius: 1rem;
padding: 1.5rem;
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.management-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
transform: scaleX(0);
transition: transform 0.3s ease;
}
.management-card:hover::before {
transform: scaleX(1);
}
.management-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
}
.card-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.card-icon {
font-size: 2rem;
width: 3rem;
height: 3rem;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #f3f4f6, #e5e7eb);
border-radius: 0.75rem;
}
.card-title {
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
margin: 0;
}
.card-content {
margin-bottom: 1.5rem;
}
.card-description {
font-size: 0.875rem;
color: #6b7280;
line-height: 1.5;
margin: 0 0 1rem 0;
}
.card-stats {
display: flex;
gap: 1rem;
}
.stat-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.stat-label {
font-size: 0.75rem;
color: #9ca3af;
font-weight: 500;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
color: #3b82f6;
}
.card-footer {
display: flex;
justify-content: flex-end;
}
.card-action {
font-size: 0.875rem;
color: #3b82f6;
font-weight: 500;
transition: color 0.3s ease;
}
.management-card:hover .card-action {
color: #1d4ed8;
}
/* 최근 활동 섹션 */
.recent-activity-section {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border-radius: 1rem;
padding: 1.5rem;
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid #e5e7eb;
}
.section-title {
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
margin: 0;
}
.refresh-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
}
.refresh-btn:hover {
background: #2563eb;
transform: translateY(-1px);
}
.activity-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.activity-item {
display: flex;
align-items: flex-start;
gap: 1rem;
padding: 1rem;
background: #f8fafc;
border-radius: 0.75rem;
border: 1px solid #e2e8f0;
transition: all 0.3s ease;
}
.activity-item:hover {
background: #f1f5f9;
border-color: #cbd5e1;
}
.activity-icon {
font-size: 1.25rem;
width: 2.5rem;
height: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
background: white;
border-radius: 0.5rem;
border: 1px solid #e2e8f0;
flex-shrink: 0;
}
.activity-content {
flex: 1;
}
.activity-title {
font-size: 0.875rem;
font-weight: 500;
color: #1f2937;
margin-bottom: 0.25rem;
line-height: 1.4;
}
.activity-meta {
display: flex;
gap: 1rem;
font-size: 0.75rem;
color: #6b7280;
}
.activity-user {
font-weight: 500;
}
/* 반응형 디자인 */
@media (max-width: 1024px) {
.dashboard-main {
padding: 1.5rem;
}
.management-grid {
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
}
}
@media (max-width: 768px) {
.page-title {
font-size: 2rem;
}
.management-grid {
grid-template-columns: 1fr;
}
.section-header {
flex-direction: column;
gap: 1rem;
align-items: flex-start;
}
}
@media (max-width: 480px) {
.dashboard-main {
padding: 1rem;
}
.page-title {
font-size: 1.75rem;
}
.management-card {
padding: 1rem;
}
.recent-activity-section {
padding: 1rem;
}
}
/* ========== 빠른 액세스 섹션 ========== */
.quick-access-section {
margin-bottom: 3rem;
}
.section-title {
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
margin-bottom: 1.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.quick-actions-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
max-width: 800px;
}
.quick-action-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1.5rem 1rem;
background: rgba(255, 255, 255, 0.95);
border: 2px solid rgba(59, 130, 246, 0.1);
border-radius: 12px;
cursor: pointer;
transition: all 0.3s ease;
text-decoration: none;
color: inherit;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
backdrop-filter: blur(10px);
min-height: 120px;
justify-content: center;
}
.quick-action-btn:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(59, 130, 246, 0.15);
border-color: rgba(59, 130, 246, 0.3);
background: rgba(255, 255, 255, 1);
}
.quick-icon {
font-size: 2rem;
line-height: 1;
}
.quick-text {
font-size: 0.875rem;
font-weight: 600;
color: #374151;
text-align: center;
white-space: nowrap;
}
/* ========== 관리 섹션 ========== */
.management-section {
margin-bottom: 3rem;
}
/* ========== 시스템 상태 섹션 ========== */
.system-status-section {
margin-bottom: 3rem;
}
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-top: 1.5rem;
}
.status-card {
display: flex;
align-items: center;
gap: 1rem;
padding: 1.5rem;
background: rgba(255, 255, 255, 0.95);
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.status-icon {
font-size: 2rem;
line-height: 1;
}
.status-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.status-label {
font-size: 0.875rem;
color: #6b7280;
font-weight: 500;
}
.status-value {
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
}

View 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;
}

View 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;
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,19 @@
version: "3.8"
services:
web:
build:
context: .
dockerfile: Dockerfile
container_name: web_hyungi_dev
restart: unless-stopped
ports:
- "20000:80"
volumes:
- .:/usr/share/nginx/html:ro
networks:
- hyungi_network
networks:
hyungi_network:
external: true

View 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;
}

View 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();
}
});
}
});

View 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>

View 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>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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>

File diff suppressed because it is too large Load Diff

View 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>

View 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>

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 369 KiB

View File

@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>(주)테크니컬코리아 생산팀 포털</title>
<link rel="stylesheet" href="css/login.css" />
<link rel="icon" type="image/png" href="img/favicon.png">
</head>
<body>
<div class="login-container">
<img src="img/logo.png" alt="테크니컬코리아 로고" class="logo" />
<h1>(주)테크니컬코리아</h1>
<h3>생산팀 포털 로그인</h3>
<form id="loginForm">
<input type="text" id="username" placeholder="아이디" required autocomplete="username" />
<input type="password" id="password" placeholder="비밀번호" required autocomplete="current-password" />
<button type="submit">로그인</button>
</form>
<div id="error" class="error-message"></div>
</div>
<!-- 스크립트 로딩 (순서 중요) -->
<script type="module" src="js/api-config.js"></script>
<script type="module" src="js/api-helper.js"></script>
<script type="module" src="js/login.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View 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);

View File

@@ -0,0 +1,104 @@
// /js/api-base.js
// API 기본 설정 및 보안 유틸리티 (비모듈 - 빠른 로딩용)
(function() {
'use strict';
// ==================== 보안 유틸리티 (XSS 방지) ====================
/**
* HTML 특수문자 이스케이프 (XSS 방지)
* innerHTML에 사용자 입력/API 데이터를 삽입할 때 반드시 사용
*
* @param {string} str - 이스케이프할 문자열
* @returns {string} 이스케이프된 문자열
*/
window.escapeHtml = function(str) {
if (str === null || str === undefined) return '';
if (typeof str !== 'string') str = String(str);
const htmlEntities = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
'/': '&#x2F;',
'`': '&#x60;',
'=': '&#x3D;'
};
return str.replace(/[&<>"'`=\/]/g, function(char) {
return htmlEntities[char];
});
};
/**
* URL 파라미터 이스케이프
*/
window.escapeUrl = function(str) {
if (str === null || str === undefined) return '';
return encodeURIComponent(String(str));
};
// ==================== API 설정 ====================
const API_PORT = 20005;
const API_PATH = '/api';
function getApiBaseUrl() {
const hostname = window.location.hostname;
const protocol = window.location.protocol;
// 프로덕션 환경 (technicalkorea.net 도메인)
if (hostname.includes('technicalkorea.net')) {
return `${protocol}//${hostname}${API_PATH}`;
}
// 개발 환경 (localhost 또는 IP)
return `${protocol}//${hostname}:${API_PORT}${API_PATH}`;
}
// 전역 API 설정
const apiUrl = getApiBaseUrl();
window.API_BASE_URL = apiUrl;
window.API = apiUrl; // 이전 호환성
// 인증 헤더 생성
window.getAuthHeaders = function() {
const token = localStorage.getItem('token');
return {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : ''
};
};
// API 호출 헬퍼 (기존 시그니처 유지: endpoint, method, data)
// JSON 파싱하여 반환
window.apiCall = async function(endpoint, method = 'GET', data = null) {
const url = `${window.API_BASE_URL}${endpoint}`;
const config = {
method: method,
headers: window.getAuthHeaders()
};
if (data && (method === 'POST' || method === 'PUT' || method === 'PATCH' || method === 'DELETE')) {
config.body = JSON.stringify(data);
}
const response = await fetch(url, config);
// 401 Unauthorized 처리
if (response.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/index.html';
throw new Error('인증이 만료되었습니다.');
}
// JSON 파싱하여 반환
return response.json();
};
console.log('✅ API 설정 완료:', window.API_BASE_URL);
})();

View File

@@ -0,0 +1,249 @@
// api-config.js - nginx 프록시 대응 API 설정
import { config } from './config.js';
import { redirectToLogin } from './navigation.js';
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 포트(config.api.port)로 직접 연결
const baseUrl = `${protocol}//${hostname}:${config.api.port}${config.api.path}`;
console.log('✅ nginx 프록시 사용:', baseUrl);
return baseUrl;
}
// 🚨 백업: 직접 접근 (nginx 프록시 실패시에만)
console.warn('⚠️ 직접 API 접근 (백업 모드)');
return `${protocol}//${hostname}:${config.api.port}${config.api.path}`;
}
// API 설정
const API_URL = getApiBaseUrl();
// 전역 변수로 설정
window.API = API_URL;
window.API_BASE_URL = API_URL;
function ensureAuthenticated() {
const token = localStorage.getItem('token');
if (!token || token === 'undefined' || token === 'null') {
console.log('🚨 인증되지 않은 사용자. 로그인 페이지로 이동합니다.');
clearAuthData(); // 만약을 위해 한번 더 정리
redirectToLogin();
return false; // 이후 코드 실행 방지
}
// 토큰 만료 확인
if (isTokenExpired(token)) {
console.log('🚨 토큰이 만료되었습니다. 로그인 페이지로 이동합니다.');
clearAuthData();
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
redirectToLogin();
return false;
}
return token;
}
// 토큰 만료 확인 함수
function isTokenExpired(token) {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
const currentTime = Math.floor(Date.now() / 1000);
return payload.exp < currentTime;
} catch (error) {
console.error('토큰 파싱 오류:', error);
return true; // 파싱 실패 시 만료된 것으로 간주
}
}
// 인증 데이터 정리 함수
function clearAuthData() {
localStorage.removeItem('token');
localStorage.removeItem('user');
localStorage.removeItem('userInfo');
localStorage.removeItem('currentUser');
}
function getAuthHeaders() {
const token = localStorage.getItem('token');
return {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
};
}
// 🔧 개선된 API 호출 함수 (에러 처리 강화)
async function apiCall(url, method = 'GET', data = null) {
// 상대 경로를 절대 경로로 변환
const fullUrl = url.startsWith('http') ? url : `${API}${url}`;
const options = {
method: method,
headers: {
'Content-Type': 'application/json',
...getAuthHeaders()
}
};
// POST/PUT 요청시 데이터 추가
if (data && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
options.body = JSON.stringify(data);
}
try {
console.log(`📡 API 호출: ${fullUrl} (${method})`);
const response = await fetch(fullUrl, options);
// 인증 만료 처리
if (response.status === 401) {
console.error('🚨 인증 실패: 토큰이 만료되었거나 유효하지 않습니다.');
clearAuthData();
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
redirectToLogin();
throw new Error('인증에 실패했습니다.');
}
// 응답 실패 처리
if (!response.ok) {
let errorMessage = `HTTP ${response.status}`;
try {
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
const errorData = await response.json();
console.error('📋 서버 에러 상세:', errorData);
// 에러 메시지 추출 (여러 형식 지원)
if (typeof errorData === 'string') {
errorMessage = errorData;
} else if (errorData.error) {
errorMessage = typeof errorData.error === 'string'
? errorData.error
: JSON.stringify(errorData.error);
} else if (errorData.message) {
errorMessage = errorData.message;
} else if (errorData.details) {
errorMessage = errorData.details;
} else {
errorMessage = `HTTP ${response.status}: ${JSON.stringify(errorData)}`;
}
} else {
const errorText = await response.text();
console.error('📋 서버 에러 텍스트:', errorText);
errorMessage = errorText || errorMessage;
}
} catch (e) {
console.error('📋 에러 파싱 중 예외 발생:', e.message);
// 파싱 실패해도 HTTP 상태 코드는 전달
}
throw new Error(errorMessage);
}
const result = await response.json();
console.log(`✅ API 성공: ${fullUrl}`);
return result;
} catch (error) {
console.error(`❌ API 오류 (${fullUrl}):`, error);
console.error('❌ 에러 전체 내용:', JSON.stringify(error, null, 2));
// 네트워크 오류 vs 서버 오류 구분
if (error.name === 'TypeError' && error.message.includes('fetch')) {
throw new Error('네트워크 연결 오류입니다. 인터넷 연결을 확인해주세요.');
}
throw error;
}
}
// 디버깅 정보
console.log('🔗 API Base URL:', API);
console.log('🌐 Current Location:', {
hostname: window.location.hostname,
protocol: window.location.protocol,
port: window.location.port,
href: window.location.href
});
// 🧪 API 연결 테스트 함수 (개발용)
async function testApiConnection() {
try {
console.log('🧪 API 연결 테스트 시작...');
const response = await fetch(`${API}/health`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' }
});
if (response.ok) {
console.log('✅ API 연결 성공!');
return true;
} else {
console.log('❌ API 연결 실패:', response.status);
return false;
}
} catch (error) {
console.log('❌ API 연결 오류:', error.message);
return false;
}
}
// API 헬퍼 함수들
async function apiGet(url) {
return apiCall(url, 'GET');
}
async function apiPost(url, data) {
return apiCall(url, 'POST', data);
}
async function apiPut(url, data) {
return apiCall(url, 'PUT', data);
}
async function apiDelete(url) {
return apiCall(url, 'DELETE');
}
// 전역 함수로 설정
window.ensureAuthenticated = ensureAuthenticated;
window.getAuthHeaders = getAuthHeaders;
window.apiCall = apiCall;
window.apiGet = apiGet;
window.apiPost = apiPost;
window.apiPut = apiPut;
window.apiDelete = apiDelete;
window.testApiConnection = testApiConnection;
window.isTokenExpired = isTokenExpired;
window.clearAuthData = clearAuthData;
// 개발 모드에서 자동 테스트
if (window.location.hostname === 'localhost' || window.location.hostname.startsWith('192.168.')) {
setTimeout(() => {
testApiConnection();
}, 1000);
}
// 주기적으로 토큰 만료 확인 (5분마다)
setInterval(() => {
const token = localStorage.getItem('token');
if (token && isTokenExpired(token)) {
console.log('🚨 주기적 확인: 토큰이 만료되었습니다.');
clearAuthData();
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
redirectToLogin();
}
}, config.app.tokenRefreshInterval); // 5분마다 확인
// ES6 모듈 export
export { API_URL as API_BASE_URL, API_URL as API, apiCall, getAuthHeaders };

View File

@@ -0,0 +1,136 @@
// /public/js/api-helper.js
// ES6 모듈 의존성 제거 - 브라우저 호환성 개선
// API 설정 (window 객체에서 가져오기)
const API_BASE_URL = window.API_BASE_URL || 'http://localhost:20005/api';
// 인증 관련 함수들 (직접 구현)
function getToken() {
const token = localStorage.getItem('token');
return token && token !== 'undefined' && token !== 'null' ? token : null;
}
function clearAuthData() {
localStorage.removeItem('token');
localStorage.removeItem('user');
}
/**
* 로그인 API를 호출합니다. (인증이 필요 없는 public 요청)
* @param {string} username - 사용자 아이디
* @param {string} password - 사용자 비밀번호
* @returns {Promise<object>} - API 응답 결과
*/
async function login(username, password) {
const response = await fetch(`${API_BASE_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const result = await response.json();
if (!response.ok) {
// API 에러 응답을 그대로 에러 객체로 던져서 호출부에서 처리하도록 함
throw new Error(result.error || '로그인에 실패했습니다.');
}
return result;
}
/**
* 인증이 필요한 API 요청을 위한 fetch 래퍼 함수
* @param {string} endpoint - /로 시작하는 API 엔드포인트
* @param {object} options - fetch 함수에 전달할 옵션
* @returns {Promise<Response>} - fetch 응답 객체
*/
async function authFetch(endpoint, options = {}) {
const token = getToken();
if (!token) {
console.error('토큰이 없습니다. 로그인이 필요합니다.');
clearAuthData(); // 인증 정보 정리
window.location.href = '/index.html'; // 로그인 페이지로 리디렉션
// 에러를 던져서 후속 실행을 중단
throw new Error('인증 토큰이 없습니다.');
}
const defaultHeaders = {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
};
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
...options,
headers: {
...defaultHeaders,
...options.headers
}
});
// 401 Unauthorized 에러 발생 시, 토큰이 유효하지 않다는 의미
if (response.status === 401) {
console.error('인증 실패. 토큰이 만료되었거나 유효하지 않습니다.');
clearAuthData(); // 만료된 인증 정보 정리
window.location.href = '/index.html';
throw new Error('인증에 실패했습니다.');
}
return response;
}
// 공통 API 요청 함수들
/**
* GET 요청 헬퍼
* @param {string} endpoint - API 엔드포인트
*/
async function apiGet(endpoint) {
const response = await authFetch(endpoint);
return response.json();
}
/**
* POST 요청 헬퍼
* @param {string} endpoint - API 엔드포인트
* @param {object} data - 전송할 데이터
*/
async function apiPost(endpoint, data) {
const response = await authFetch(endpoint, {
method: 'POST',
body: JSON.stringify(data)
});
return response.json();
}
/**
* PUT 요청 헬퍼
* @param {string} endpoint - API 엔드포인트
* @param {object} data - 전송할 데이터
*/
async function apiPut(endpoint, data) {
const response = await authFetch(endpoint, {
method: 'PUT',
body: JSON.stringify(data)
});
return response.json();
}
/**
* DELETE 요청 헬퍼
* @param {string} endpoint - API 엔드포인트
*/
async function apiDelete(endpoint) {
const response = await authFetch(endpoint, {
method: 'DELETE'
});
return response.json();
}
// 전역 함수로 설정
window.login = login;
window.apiGet = apiGet;
window.apiPost = apiPost;
window.apiPut = apiPut;
window.apiDelete = apiDelete;
window.getToken = getToken;
window.clearAuthData = clearAuthData;

View File

@@ -0,0 +1,587 @@
// /js/app-init.js
// 앱 초기화 - 인증, 네비바, 사이드바를 한 번에 로드
// 모든 페이지에서 이 하나의 스크립트만 로드하면 됨
(function() {
'use strict';
// ===== 캐시 설정 =====
const CACHE_DURATION = 10 * 60 * 1000; // 10분
const COMPONENT_CACHE_PREFIX = 'component_v3_';
// ===== 인증 함수 =====
function isLoggedIn() {
const token = localStorage.getItem('token');
return token && token !== 'undefined' && token !== 'null';
}
function getUser() {
const user = localStorage.getItem('user');
return user ? JSON.parse(user) : null;
}
function clearAuthData() {
localStorage.removeItem('token');
localStorage.removeItem('user');
localStorage.removeItem('userPageAccess');
}
// ===== 페이지 권한 캐시 =====
let pageAccessPromise = null;
async function getPageAccess(currentUser) {
if (!currentUser || !currentUser.user_id) return null;
// 캐시 확인
const cached = localStorage.getItem('userPageAccess');
if (cached) {
try {
const cacheData = JSON.parse(cached);
if (Date.now() - cacheData.timestamp < CACHE_DURATION) {
return cacheData.pages;
}
} catch (e) {
localStorage.removeItem('userPageAccess');
}
}
// 이미 로딩 중이면 기존 Promise 반환
if (pageAccessPromise) return pageAccessPromise;
// 새로운 API 호출
pageAccessPromise = (async () => {
try {
const response = await fetch(`${window.API_BASE_URL}/users/${currentUser.user_id}/page-access`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (!response.ok) return null;
const data = await response.json();
const pages = data.data.pageAccess || [];
localStorage.setItem('userPageAccess', JSON.stringify({
pages: pages,
timestamp: Date.now()
}));
return pages;
} catch (error) {
console.error('페이지 권한 조회 오류:', error);
return null;
} finally {
pageAccessPromise = null;
}
})();
return pageAccessPromise;
}
async function getAccessiblePageKeys(currentUser) {
const pages = await getPageAccess(currentUser);
if (!pages) return [];
return pages.filter(p => p.can_access === 1).map(p => p.page_key);
}
// ===== 현재 페이지 키 추출 =====
function getCurrentPageKey() {
const path = window.location.pathname;
if (!path.startsWith('/pages/')) return null;
const pagePath = path.substring(7).replace('.html', '');
return pagePath.replace(/\//g, '.');
}
// ===== 컴포넌트 로더 =====
async function loadComponent(name, selector, processor) {
const container = document.querySelector(selector);
if (!container) return;
const paths = {
'navbar': '/components/navbar.html',
'sidebar-nav': '/components/sidebar-nav.html'
};
const componentPath = paths[name];
if (!componentPath) return;
try {
const cacheKey = COMPONENT_CACHE_PREFIX + name;
let html = sessionStorage.getItem(cacheKey);
if (!html) {
const response = await fetch(componentPath);
if (!response.ok) throw new Error('컴포넌트 로드 실패');
html = await response.text();
try { sessionStorage.setItem(cacheKey, html); } catch (e) {}
}
if (processor) {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
await processor(doc);
container.innerHTML = doc.body.innerHTML;
} else {
container.innerHTML = html;
}
} catch (error) {
console.error(`컴포넌트 로드 오류 (${name}):`, error);
}
}
// ===== 네비바 처리 =====
const ROLE_NAMES = {
'system admin': '시스템 관리자',
'admin': '관리자',
'leader': '그룹장',
'user': '작업자',
'support': '지원팀',
'default': '사용자'
};
async function processNavbar(doc, currentUser, accessiblePageKeys) {
const userRole = (currentUser.role || '').toLowerCase();
const isAdmin = userRole === 'admin' || userRole === 'system admin';
if (isAdmin) {
doc.querySelectorAll('.admin-only').forEach(el => el.classList.add('visible'));
} else {
doc.querySelectorAll('[data-page-key]').forEach(item => {
const pageKey = item.getAttribute('data-page-key');
if (pageKey === 'dashboard' || pageKey.startsWith('profile.')) return;
if (!accessiblePageKeys.includes(pageKey)) item.remove();
});
doc.querySelectorAll('.admin-only').forEach(el => el.remove());
}
// 사용자 정보 표시
const displayName = currentUser.name || currentUser.username;
const roleName = ROLE_NAMES[userRole] || ROLE_NAMES.default;
const setElementText = (id, text) => {
const el = doc.getElementById(id);
if (el) el.textContent = text;
};
setElementText('userName', displayName);
setElementText('userRole', roleName);
setElementText('userInitial', displayName.charAt(0));
}
// ===== 사이드바 처리 =====
async function processSidebar(doc, currentUser, accessiblePageKeys) {
const userRole = (currentUser.role || '').toLowerCase();
const accessLevel = (currentUser.access_level || '').toLowerCase();
// role 또는 access_level로 관리자 확인
const isAdmin = userRole === 'admin' || userRole === 'system admin' || userRole === 'system' ||
accessLevel === 'admin' || accessLevel === 'system';
if (isAdmin) {
doc.querySelectorAll('.admin-only').forEach(el => el.classList.add('visible'));
} else {
doc.querySelectorAll('[data-page-key]').forEach(item => {
const pageKey = item.getAttribute('data-page-key');
if (pageKey === 'dashboard' || pageKey.startsWith('profile.')) return;
if (!accessiblePageKeys.includes(pageKey)) item.style.display = 'none';
});
doc.querySelectorAll('.nav-category.admin-only').forEach(el => el.remove());
}
// 현재 페이지 하이라이트
const currentPath = window.location.pathname;
doc.querySelectorAll('.nav-item').forEach(item => {
const href = item.getAttribute('href');
if (href && currentPath.includes(href.replace(/^\//, ''))) {
item.classList.add('active');
const category = item.closest('.nav-category');
if (category) category.classList.add('expanded');
}
});
// 저장된 상태 복원 (기본값: 접힌 상태)
const isCollapsed = localStorage.getItem('sidebarCollapsed') !== 'false';
const sidebar = doc.querySelector('.sidebar-nav');
if (isCollapsed && sidebar) {
sidebar.classList.add('collapsed');
document.body.classList.add('sidebar-collapsed');
}
const expandedCategories = JSON.parse(localStorage.getItem('sidebarExpanded') || '[]');
expandedCategories.forEach(category => {
const el = doc.querySelector(`[data-category="${category}"]`);
if (el) el.classList.add('expanded');
});
}
// ===== 사이드바 이벤트 설정 =====
function setupSidebarEvents() {
const sidebar = document.getElementById('sidebarNav');
const toggle = document.getElementById('sidebarToggle');
if (!sidebar || !toggle) return;
toggle.addEventListener('click', () => {
sidebar.classList.toggle('collapsed');
document.body.classList.toggle('sidebar-collapsed');
localStorage.setItem('sidebarCollapsed', sidebar.classList.contains('collapsed'));
});
sidebar.querySelectorAll('.nav-category-header').forEach(header => {
header.addEventListener('click', () => {
const category = header.closest('.nav-category');
category.classList.toggle('expanded');
const expanded = [];
sidebar.querySelectorAll('.nav-category.expanded').forEach(cat => {
const name = cat.getAttribute('data-category');
if (name) expanded.push(name);
});
localStorage.setItem('sidebarExpanded', JSON.stringify(expanded));
});
});
}
// ===== 네비바 이벤트 설정 =====
function setupNavbarEvents() {
const logoutButton = document.getElementById('logoutBtn');
if (logoutButton) {
logoutButton.addEventListener('click', () => {
if (confirm('로그아웃 하시겠습니까?')) {
clearAuthData();
window.location.href = '/index.html';
}
});
}
// 알림 버튼 이벤트
const notificationBtn = document.getElementById('notificationBtn');
const notificationDropdown = document.getElementById('notificationDropdown');
const notificationWrapper = document.getElementById('notificationWrapper');
if (notificationBtn && notificationDropdown) {
notificationBtn.addEventListener('click', (e) => {
e.stopPropagation();
notificationDropdown.classList.toggle('show');
});
document.addEventListener('click', (e) => {
if (notificationWrapper && !notificationWrapper.contains(e.target)) {
notificationDropdown.classList.remove('show');
}
});
}
}
// ===== 알림 로드 =====
async function loadNotifications() {
try {
const token = localStorage.getItem('token');
if (!token) return;
const response = await fetch(`${window.API_BASE_URL}/notifications/unread`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) return;
const result = await response.json();
if (result.success) {
const notifications = result.data || [];
updateNotificationBadge(notifications.length);
renderNotificationList(notifications);
}
} catch (error) {
console.warn('알림 로드 오류:', error.message);
}
}
function updateNotificationBadge(count) {
const badge = document.getElementById('notificationBadge');
const btn = document.getElementById('notificationBtn');
if (!badge) return;
if (count > 0) {
badge.textContent = count > 99 ? '99+' : count;
badge.style.display = 'flex';
btn?.classList.add('has-notifications');
} else {
badge.style.display = 'none';
btn?.classList.remove('has-notifications');
}
}
function renderNotificationList(notifications) {
const list = document.getElementById('notificationList');
if (!list) return;
if (notifications.length === 0) {
list.innerHTML = '<div class="notification-empty">새 알림이 없습니다.</div>';
return;
}
const icons = { repair: '🔧', safety: '⚠️', system: '📢', equipment: '🔩', maintenance: '🛠️' };
list.innerHTML = notifications.slice(0, 5).map(n => `
<div class="notification-item ${n.is_read ? '' : 'unread'}" data-id="${n.notification_id}" data-url="${n.link_url || ''}">
<div class="notification-item-icon ${n.type || 'repair'}">${icons[n.type] || '🔔'}</div>
<div class="notification-item-content">
<div class="notification-item-title">${escapeHtml(n.title)}</div>
<div class="notification-item-desc">${escapeHtml(n.message || '')}</div>
</div>
<div class="notification-item-time">${formatTimeAgo(n.created_at)}</div>
</div>
`).join('');
list.querySelectorAll('.notification-item').forEach(item => {
item.addEventListener('click', () => {
const url = item.dataset.url;
// 수리 알림은 클릭해도 읽음 처리 안함 (수리 처리 페이지에서 확인 처리해야 함)
window.location.href = url || '/pages/admin/notifications.html';
});
});
}
function formatTimeAgo(dateString) {
const date = new Date(dateString);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return '방금 전';
if (diffMins < 60) return `${diffMins}분 전`;
if (diffHours < 24) return `${diffHours}시간 전`;
if (diffDays < 7) return `${diffDays}일 전`;
return date.toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' });
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// ===== 날짜/시간 업데이트 =====
function updateDateTime() {
const now = new Date();
const timeEl = document.getElementById('timeValue');
if (timeEl) {
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
timeEl.textContent = `${hours}${minutes}${seconds}`;
}
const dateEl = document.getElementById('dateValue');
if (dateEl) {
const days = ['일', '월', '화', '수', '목', '토'];
dateEl.textContent = `${now.getMonth() + 1}${now.getDate()}일 (${days[now.getDay()]})`;
}
}
// ===== 날씨 업데이트 =====
const WEATHER_ICONS = { clear: '☀️', rain: '🌧️', snow: '❄️', heat: '🔥', cold: '🥶', wind: '💨', fog: '🌫️', dust: '😷', cloudy: '⛅', overcast: '☁️' };
const WEATHER_NAMES = { clear: '맑음', rain: '비', snow: '눈', heat: '폭염', cold: '한파', wind: '강풍', fog: '안개', dust: '미세먼지', cloudy: '구름많음', overcast: '흐림' };
async function updateWeather() {
try {
const token = localStorage.getItem('token');
if (!token) return;
// 캐시 확인
const cached = sessionStorage.getItem('weatherCache');
let result;
if (cached) {
const cacheData = JSON.parse(cached);
if (Date.now() - cacheData.timestamp < 5 * 60 * 1000) {
result = cacheData.data;
}
}
if (!result) {
const response = await fetch(`${window.API_BASE_URL}/tbm/weather/current`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) return;
result = await response.json();
sessionStorage.setItem('weatherCache', JSON.stringify({ data: result, timestamp: Date.now() }));
}
if (result.success && result.data) {
const { temperature, conditions } = result.data;
const tempEl = document.getElementById('weatherTemp');
if (tempEl && temperature != null) tempEl.textContent = `${Math.round(temperature)}°C`;
const iconEl = document.getElementById('weatherIcon');
const descEl = document.getElementById('weatherDesc');
if (conditions && conditions.length > 0) {
const primary = conditions[0];
if (iconEl) iconEl.textContent = WEATHER_ICONS[primary] || '🌤️';
if (descEl) descEl.textContent = WEATHER_NAMES[primary] || '맑음';
}
}
} catch (error) {
console.warn('날씨 정보 로드 실패');
}
}
// ===== 메인 초기화 =====
async function init() {
console.log('🚀 app-init 시작');
// 1. 인증 확인
if (!isLoggedIn()) {
clearAuthData();
window.location.href = '/index.html';
return;
}
const currentUser = getUser();
if (!currentUser || !currentUser.username) {
clearAuthData();
window.location.href = '/index.html';
return;
}
console.log('✅ 인증 확인:', currentUser.username);
const userRole = (currentUser.role || '').toLowerCase();
const accessLevel = (currentUser.access_level || '').toLowerCase();
// role 또는 access_level로 관리자 확인
const isAdmin = userRole === 'admin' || userRole === 'system admin' || userRole === 'system' ||
accessLevel === 'admin' || accessLevel === 'system';
// 2. 페이지 접근 권한 체크 (Admin은 건너뛰기)
let accessiblePageKeys = [];
if (!isAdmin) {
const pageKey = getCurrentPageKey();
if (pageKey && pageKey !== 'dashboard' && !pageKey.startsWith('profile.')) {
accessiblePageKeys = await getAccessiblePageKeys(currentUser);
if (!accessiblePageKeys.includes(pageKey)) {
alert('이 페이지에 접근할 권한이 없습니다.');
window.location.href = '/pages/dashboard.html';
return;
}
}
}
// 3. 사이드바 컨테이너 생성 (없으면)
let sidebarContainer = document.getElementById('sidebar-container');
if (!sidebarContainer) {
sidebarContainer = document.createElement('div');
sidebarContainer.id = 'sidebar-container';
document.body.prepend(sidebarContainer);
console.log('📦 사이드바 컨테이너 생성됨');
}
// 4. 네비바와 사이드바 동시 로드
console.log('📥 컴포넌트 로딩 시작');
await Promise.all([
loadComponent('navbar', '#navbar-container', (doc) => processNavbar(doc, currentUser, accessiblePageKeys)),
loadComponent('sidebar-nav', '#sidebar-container', (doc) => processSidebar(doc, currentUser, accessiblePageKeys))
]);
console.log('✅ 컴포넌트 로딩 완료');
// 5. 이벤트 설정
setupNavbarEvents();
setupSidebarEvents();
document.body.classList.add('has-sidebar');
// 6. 페이지 전환 로딩 인디케이터 설정
setupPageTransitionLoader();
// 7. 날짜/시간 (비동기)
updateDateTime();
setInterval(updateDateTime, 1000);
// 8. 날씨 (백그라운드)
setTimeout(updateWeather, 100);
// 9. 알림 로드 (30초마다 갱신)
setTimeout(loadNotifications, 200);
setInterval(loadNotifications, 30000);
console.log('✅ app-init 완료');
}
// ===== 페이지 전환 로딩 인디케이터 =====
function setupPageTransitionLoader() {
// 로딩 바 스타일 추가
const style = document.createElement('style');
style.textContent = `
#page-loader {
position: fixed;
top: 0;
left: 0;
width: 0;
height: 3px;
background: linear-gradient(90deg, #3b82f6, #60a5fa);
z-index: 99999;
transition: width 0.3s ease;
box-shadow: 0 0 10px rgba(59, 130, 246, 0.5);
}
#page-loader.loading {
width: 70%;
}
#page-loader.done {
width: 100%;
opacity: 0;
transition: width 0.2s ease, opacity 0.3s ease 0.2s;
}
body.page-loading {
cursor: wait;
}
body.page-loading * {
pointer-events: none;
}
`;
document.head.appendChild(style);
// 로딩 바 엘리먼트 생성
const loader = document.createElement('div');
loader.id = 'page-loader';
document.body.appendChild(loader);
// 모든 내부 링크에 클릭 이벤트 추가
document.addEventListener('click', (e) => {
const link = e.target.closest('a');
if (!link) return;
const href = link.getAttribute('href');
if (!href) return;
// 외부 링크, 해시 링크, javascript: 링크 제외
if (href.startsWith('http') || href.startsWith('#') || href.startsWith('javascript:')) return;
// 새 탭 링크 제외
if (link.target === '_blank') return;
// 로딩 시작
loader.classList.remove('done');
loader.classList.add('loading');
document.body.classList.add('page-loading');
});
// 페이지 떠날 때 완료 표시
window.addEventListener('beforeunload', () => {
const loader = document.getElementById('page-loader');
if (loader) {
loader.classList.remove('loading');
loader.classList.add('done');
}
});
}
// DOMContentLoaded 시 실행
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
// 전역 노출 (필요시)
window.appInit = { getUser, clearAuthData, isLoggedIn };
})();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,176 @@
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() });
const allWorkers = await res.json();
// 활성화된 작업자만 필터링
workers = allWorkers.filter(worker => {
return worker.status === 'active' || worker.is_active === 1 || worker.is_active === true;
});
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);

View File

@@ -0,0 +1,157 @@
// /js/auth-check.js
// auth.js의 함수들을 직접 구현 (모듈 의존성 제거)
function isLoggedIn() {
const token = localStorage.getItem('token');
return token && token !== 'undefined' && token !== 'null';
}
function getUser() {
const user = localStorage.getItem('user');
return user ? JSON.parse(user) : null;
}
function clearAuthData() {
localStorage.removeItem('token');
localStorage.removeItem('user');
localStorage.removeItem('userPageAccess'); // 페이지 권한 캐시도 삭제
}
/**
* 현재 페이지의 page_key를 URL 경로로부터 추출
* 예: /pages/work/tbm.html -> work.tbm
* /pages/admin/accounts.html -> admin.accounts
* /pages/dashboard.html -> dashboard
*/
function getCurrentPageKey() {
const path = window.location.pathname;
// /pages/로 시작하는지 확인
if (!path.startsWith('/pages/')) {
return null;
}
// /pages/ 이후 경로 추출
const pagePath = path.substring(7); // '/pages/' 제거
// .html 제거
const withoutExt = pagePath.replace('.html', '');
// 슬래시를 점으로 변환
const pageKey = withoutExt.replace(/\//g, '.');
return pageKey;
}
/**
* 사용자의 페이지 접근 권한 확인 (캐시 활용)
*/
async function checkPageAccess(pageKey) {
const currentUser = getUser();
// Admin은 모든 페이지 접근 가능
if (currentUser.role === 'Admin' || currentUser.role === 'System Admin') {
return true;
}
// 프로필 페이지는 모든 사용자 접근 가능
if (pageKey && pageKey.startsWith('profile.')) {
return true;
}
// 대시보드는 모든 사용자 접근 가능
if (pageKey === 'dashboard') {
return true;
}
try {
// 캐시된 권한 확인
const cached = localStorage.getItem('userPageAccess');
let accessiblePages = null;
if (cached) {
const cacheData = JSON.parse(cached);
// 캐시가 5분 이내인 경우 사용
if (Date.now() - cacheData.timestamp < 5 * 60 * 1000) {
accessiblePages = cacheData.pages;
}
}
// 캐시가 없으면 API 호출
if (!accessiblePages) {
const response = await fetch(`${window.API_BASE_URL}/users/${currentUser.user_id}/page-access`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (!response.ok) {
console.error('페이지 권한 조회 실패:', response.status);
return false;
}
const data = await response.json();
accessiblePages = data.data.pageAccess || [];
// 캐시 저장
localStorage.setItem('userPageAccess', JSON.stringify({
pages: accessiblePages,
timestamp: Date.now()
}));
}
// 해당 페이지에 대한 접근 권한 확인
const pageAccess = accessiblePages.find(p => p.page_key === pageKey);
return pageAccess && pageAccess.can_access === 1;
} catch (error) {
console.error('페이지 권한 체크 오류:', error);
return false;
}
}
// 즉시 실행 함수로 스코프를 보호하고 로직을 실행
(async function() {
if (!isLoggedIn()) {
console.log('🚨 인증되지 않은 사용자. 로그인 페이지로 이동합니다.');
clearAuthData(); // 만약을 위해 한번 더 정리
window.location.href = '/index.html';
return; // 이후 코드 실행 방지
}
const currentUser = getUser();
// 사용자 정보가 유효한지 확인 (토큰은 있지만 유저 정보가 깨졌을 경우)
if (!currentUser || !currentUser.username) {
console.error('🚨 사용자 정보가 유효하지 않습니다. 강제 로그아웃 처리합니다.');
clearAuthData();
window.location.href = '/index.html';
return;
}
const userRole = currentUser.role || currentUser.access_level || '사용자';
console.log(`${currentUser.username}(${userRole})님 인증 성공.`);
// 페이지 접근 권한 체크 (Admin은 건너뛰기)
if (currentUser.role !== 'Admin' && currentUser.role !== 'System Admin') {
const pageKey = getCurrentPageKey();
if (pageKey) {
console.log(`🔍 페이지 권한 체크: ${pageKey}`);
const hasAccess = await checkPageAccess(pageKey);
if (!hasAccess) {
console.error(`🚫 페이지 접근 권한이 없습니다: ${pageKey}`);
alert('이 페이지에 접근할 권한이 없습니다.');
window.location.href = '/pages/dashboard.html';
return;
}
console.log(`✅ 페이지 접근 권한 확인됨: ${pageKey}`);
}
}
// 역할 기반 메뉴 제어 로직은 각 컴포넌트 로더(load-navbar.js 등)로 이전함.
// 전역 변수 할당(window.currentUser) 제거.
})();

View 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;
}

View 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);
}

View 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');
});

View File

@@ -0,0 +1,259 @@
/**
* Security Utilities - 보안 관련 유틸리티 함수
*
* XSS 방지, 입력값 검증, 안전한 DOM 조작을 위한 함수 모음
*
* @author TK-FB-Project
* @since 2026-02-04
*/
(function(global) {
'use strict';
const SecurityUtils = {
/**
* HTML 특수문자 이스케이프 (XSS 방지)
* innerHTML에 사용자 입력을 삽입할 때 반드시 사용
*
* @param {string} str - 이스케이프할 문자열
* @returns {string} 이스케이프된 문자열
*
* @example
* element.innerHTML = `<span>${SecurityUtils.escapeHtml(userInput)}</span>`;
*/
escapeHtml: function(str) {
if (str === null || str === undefined) return '';
if (typeof str !== 'string') str = String(str);
const htmlEntities = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
'/': '&#x2F;',
'`': '&#x60;',
'=': '&#x3D;'
};
return str.replace(/[&<>"'`=\/]/g, function(char) {
return htmlEntities[char];
});
},
/**
* URL 파라미터 이스케이프
* URL에 사용자 입력을 포함할 때 사용
*
* @param {string} str - 이스케이프할 문자열
* @returns {string} URL 인코딩된 문자열
*/
escapeUrl: function(str) {
if (str === null || str === undefined) return '';
return encodeURIComponent(String(str));
},
/**
* JavaScript 문자열 이스케이프
* 동적 JavaScript 생성 시 사용 (권장하지 않음)
*
* @param {string} str - 이스케이프할 문자열
* @returns {string} 이스케이프된 문자열
*/
escapeJs: function(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'")
.replace(/"/g, '\\"')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(/\t/g, '\\t');
},
/**
* 안전한 텍스트 설정
* innerHTML 대신 textContent 사용 권장
*
* @param {Element} element - DOM 요소
* @param {string} text - 설정할 텍스트
*/
setTextSafe: function(element, text) {
if (element && element.nodeType === 1) {
element.textContent = text;
}
},
/**
* 안전한 HTML 삽입
* 사용자 입력이 포함된 HTML을 삽입할 때 사용
*
* @param {Element} element - DOM 요소
* @param {string} template - HTML 템플릿 ({{변수}} 형식)
* @param {Object} data - 삽입할 데이터 (자동 이스케이프됨)
*
* @example
* SecurityUtils.setHtmlSafe(div, '<span>{{name}}</span>', { name: userInput });
*/
setHtmlSafe: function(element, template, data) {
if (!element || element.nodeType !== 1) return;
const self = this;
const safeHtml = template.replace(/\{\{(\w+)\}\}/g, function(match, key) {
return data.hasOwnProperty(key) ? self.escapeHtml(data[key]) : '';
});
element.innerHTML = safeHtml;
},
/**
* 입력값 검증 - 숫자
*
* @param {any} value - 검증할 값
* @param {Object} options - 옵션 { min, max, allowFloat }
* @returns {number|null} 유효한 숫자 또는 null
*/
validateNumber: function(value, options) {
options = options || {};
const num = options.allowFloat ? parseFloat(value) : parseInt(value, 10);
if (isNaN(num)) return null;
if (options.min !== undefined && num < options.min) return null;
if (options.max !== undefined && num > options.max) return null;
return num;
},
/**
* 입력값 검증 - 이메일
*
* @param {string} email - 검증할 이메일
* @returns {boolean} 유효 여부
*/
validateEmail: function(email) {
if (!email || typeof email !== 'string') return false;
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
},
/**
* 입력값 검증 - 길이
*
* @param {string} str - 검증할 문자열
* @param {Object} options - 옵션 { min, max }
* @returns {boolean} 유효 여부
*/
validateLength: function(str, options) {
options = options || {};
if (!str || typeof str !== 'string') return false;
const len = str.length;
if (options.min !== undefined && len < options.min) return false;
if (options.max !== undefined && len > options.max) return false;
return true;
},
/**
* 안전한 JSON 파싱
*
* @param {string} jsonString - 파싱할 JSON 문자열
* @param {any} defaultValue - 파싱 실패 시 기본값
* @returns {any} 파싱된 객체 또는 기본값
*/
parseJsonSafe: function(jsonString, defaultValue) {
defaultValue = defaultValue === undefined ? null : defaultValue;
try {
return JSON.parse(jsonString);
} catch (e) {
console.warn('[SecurityUtils] JSON 파싱 실패:', e.message);
return defaultValue;
}
},
/**
* localStorage에서 안전하게 데이터 가져오기
*
* @param {string} key - 키
* @param {any} defaultValue - 기본값
* @returns {any} 저장된 값 또는 기본값
*/
getStorageSafe: function(key, defaultValue) {
try {
const item = localStorage.getItem(key);
if (item === null) return defaultValue;
return this.parseJsonSafe(item, defaultValue);
} catch (e) {
console.warn('[SecurityUtils] localStorage 접근 실패:', e.message);
return defaultValue;
}
},
/**
* URL 파라미터 안전하게 가져오기
*
* @param {string} name - 파라미터 이름
* @param {string} defaultValue - 기본값
* @returns {string} 파라미터 값 (이스케이프됨)
*/
getUrlParamSafe: function(name, defaultValue) {
defaultValue = defaultValue === undefined ? '' : defaultValue;
try {
const urlParams = new URLSearchParams(window.location.search);
const value = urlParams.get(name);
return value !== null ? value : defaultValue;
} catch (e) {
return defaultValue;
}
},
/**
* ID 파라미터 안전하게 가져오기 (숫자 검증)
*
* @param {string} name - 파라미터 이름
* @returns {number|null} 유효한 ID 또는 null
*/
getIdParamSafe: function(name) {
const value = this.getUrlParamSafe(name);
return this.validateNumber(value, { min: 1 });
},
/**
* Content Security Policy 위반 리포터
*
* @param {string} reportUri - 리포트 전송 URL
*/
enableCspReporting: function(reportUri) {
document.addEventListener('securitypolicyviolation', function(e) {
console.error('[CSP Violation]', {
blockedUri: e.blockedURI,
violatedDirective: e.violatedDirective,
originalPolicy: e.originalPolicy
});
if (reportUri) {
fetch(reportUri, {
method: 'POST',
body: JSON.stringify({
blocked_uri: e.blockedURI,
violated_directive: e.violatedDirective,
document_uri: e.documentURI,
timestamp: new Date().toISOString()
}),
headers: { 'Content-Type': 'application/json' }
}).catch(function() {});
}
});
}
};
// 전역 노출
global.SecurityUtils = SecurityUtils;
// 편의를 위한 단축 함수
global.escapeHtml = SecurityUtils.escapeHtml.bind(SecurityUtils);
global.escapeUrl = SecurityUtils.escapeUrl.bind(SecurityUtils);
console.log('[Module] common/security.js 로드 완료');
})(typeof window !== 'undefined' ? window : this);

View File

@@ -0,0 +1,81 @@
// /js/component-loader.js
import { config } from './config.js';
// 캐시 버전 (컴포넌트 변경 시 증가)
const CACHE_VERSION = 'v4';
/**
* 컴포넌트 HTML을 캐시에서 가져오거나 fetch
*/
async function getComponentHtml(componentName, componentPath) {
const cacheKey = `component_${componentName}_${CACHE_VERSION}`;
// 캐시에서 먼저 확인
const cached = sessionStorage.getItem(cacheKey);
if (cached) {
return cached;
}
// 캐시 없으면 fetch
const response = await fetch(componentPath);
if (!response.ok) {
throw new Error(`컴포넌트 파일을 불러올 수 없습니다: ${response.statusText}`);
}
const htmlText = await response.text();
// 캐시에 저장
try {
sessionStorage.setItem(cacheKey, htmlText);
} catch (e) {
// sessionStorage 용량 초과 시 무시
}
return htmlText;
}
/**
* 공용 HTML 컴포넌트를 페이지의 특정 위치에 동적으로 로드합니다.
* @param {string} componentName - 로드할 컴포넌트의 이름 (e.g., 'sidebar', 'navbar'). config.js의 components 객체에 정의된 키와 일치해야 합니다.
* @param {string} containerSelector - 컴포넌트가 삽입될 DOM 요소의 CSS 선택자 (e.g., '#sidebar-container').
* @param {function(Document): void} [domProcessor=null] - DOM에 삽입하기 전에 로드된 HTML(Document)을 조작하는 선택적 함수.
* (e.g., 역할 기반 메뉴 필터링)
*/
export async function loadComponent(componentName, containerSelector, domProcessor = null) {
const container = document.querySelector(containerSelector);
if (!container) {
console.warn(`⚠️ 컴포넌트를 삽입할 컨테이너를 찾을 수 없습니다: ${containerSelector} (선택사항일 수 있음)`);
return;
}
const componentPath = config.components[componentName];
if (!componentPath) {
console.error(`🔴 설정 파일(config.js)에서 '${componentName}' 컴포넌트의 경로를 찾을 수 없습니다.`);
container.innerHTML = `<p>${componentName} 로딩 실패</p>`;
return;
}
try {
const htmlText = await getComponentHtml(componentName, componentPath);
if (domProcessor) {
// 1. 텍스트를 가상 DOM으로 파싱
const parser = new DOMParser();
const doc = parser.parseFromString(htmlText, 'text/html');
// 2. DOM 프로세서(콜백)를 실행하여 DOM 조작
await domProcessor(doc);
// 3. 조작된 HTML을 실제 DOM에 삽입
container.innerHTML = doc.body.innerHTML;
} else {
// DOM 조작이 필요 없는 경우, 바로 삽입
container.innerHTML = htmlText;
}
console.log(`✅ '${componentName}' 컴포넌트 로딩 완료: ${containerSelector}`);
} catch (error) {
console.error(`🔴 '${componentName}' 컴포넌트 로딩 실패:`, error);
container.innerHTML = `<p>${componentName} 로딩에 실패했습니다. 관리자에게 문의하세요.</p>`;
}
}

View File

@@ -0,0 +1,42 @@
// /js/config.js
// ES6 모듈을 사용하여 설정을 내보냅니다.
// 이 파일을 통해 프로젝트의 모든 하드코딩된 값을 관리합니다.
export const config = {
// API 관련 설정
api: {
// 로컬 개발 및 Docker 환경에서 사용하는 API 서버 포트
port: 20005,
// API의 기본 경로
path: '/api',
},
// 페이지 경로 설정
paths: {
// 로그인 페이지 경로
loginPage: '/index.html',
// 메인 대시보드 경로 (모든 사용자 공통)
dashboard: '/pages/dashboard.html',
// 하위 호환성을 위한 별칭들
defaultDashboard: '/pages/dashboard.html',
systemDashboard: '/pages/dashboard.html',
groupLeaderDashboard: '/pages/dashboard.html',
},
// 공용 컴포넌트 경로 설정
components: {
// 사이드바 HTML 파일 경로 (구버전)
sidebar: '/components/sidebar.html',
// 새 사이드바 네비게이션 (카테고리별)
'sidebar-nav': '/components/sidebar-nav.html',
// 네비게이션 바 HTML 파일 경로
navbar: '/components/navbar.html',
},
// 애플리케이션 관련 기타 설정
app: {
// 토큰 만료 확인 주기 (밀리초 단위, 예: 5분)
tokenRefreshInterval: 5 * 60 * 1000,
}
};

View File

@@ -0,0 +1,71 @@
// /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 {
// 보고서가 없으면 전체 작업자 목록을 가져옵니다.
const allWorkers = await apiGet('/workers');
// 활성화된 작업자만 필터링
workers = allWorkers.filter(worker => {
return worker.status === 'active' || worker.is_active === 1 || worker.is_active === true;
});
}
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;
}
}

View 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 = '등록';
}
}

View 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);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,386 @@
/**
* Daily Work Report - API Client
* 작업보고서 관련 모든 API 호출을 관리
*/
class DailyWorkReportAPI {
constructor() {
this.state = window.DailyWorkReportState;
console.log('[API] DailyWorkReportAPI 초기화');
}
/**
* 작업자 로드 (생산팀 소속)
*/
async loadWorkers() {
try {
console.log('[API] Workers 로딩 중...');
const data = await window.apiCall('/workers?limit=1000&department_id=1');
const allWorkers = Array.isArray(data) ? data : (data.data || data.workers || []);
// 퇴사자만 제외
const filtered = allWorkers.filter(worker => worker.employment_status !== 'resigned');
this.state.workers = filtered;
console.log(`[API] Workers 로드 완료: ${filtered.length}`);
return filtered;
} catch (error) {
console.error('[API] 작업자 로딩 오류:', error);
throw error;
}
}
/**
* 프로젝트 로드 (활성 프로젝트만)
*/
async loadProjects() {
try {
console.log('[API] Projects 로딩 중...');
const data = await window.apiCall('/projects/active/list');
const projects = Array.isArray(data) ? data : (data.data || data.projects || []);
this.state.projects = projects;
console.log(`[API] Projects 로드 완료: ${projects.length}`);
return projects;
} catch (error) {
console.error('[API] 프로젝트 로딩 오류:', error);
throw error;
}
}
/**
* 작업 유형 로드
*/
async loadWorkTypes() {
try {
const data = await window.apiCall('/daily-work-reports/work-types');
if (Array.isArray(data) && data.length > 0) {
this.state.workTypes = data;
console.log('[API] 작업 유형 로드 완료:', data.length);
return data;
}
throw new Error('API 실패');
} catch (error) {
console.log('[API] 작업 유형 API 사용 불가, 기본값 사용');
this.state.workTypes = [
{ id: 1, name: 'Base' },
{ id: 2, name: 'Vessel' },
{ id: 3, name: 'Piping' }
];
return this.state.workTypes;
}
}
/**
* 업무 상태 유형 로드
*/
async loadWorkStatusTypes() {
try {
const data = await window.apiCall('/daily-work-reports/work-status-types');
if (Array.isArray(data) && data.length > 0) {
this.state.workStatusTypes = data;
console.log('[API] 업무 상태 유형 로드 완료:', data.length);
return data;
}
throw new Error('API 실패');
} catch (error) {
console.log('[API] 업무 상태 유형 API 사용 불가, 기본값 사용');
this.state.workStatusTypes = [
{ id: 1, name: '정상', is_error: false },
{ id: 2, name: '부적합', is_error: true }
];
return this.state.workStatusTypes;
}
}
/**
* 오류 유형 로드 (신고 카테고리/아이템)
*/
async loadErrorTypes() {
try {
// 1. 신고 카테고리 (nonconformity만)
const categoriesResponse = await window.apiCall('/work-issues/categories');
if (categoriesResponse.success && categoriesResponse.data) {
this.state.issueCategories = categoriesResponse.data.filter(
c => c.category_type === 'nonconformity'
);
console.log('[API] 신고 카테고리 로드:', this.state.issueCategories.length);
}
// 2. 신고 아이템 전체
const itemsResponse = await window.apiCall('/work-issues/items');
if (itemsResponse.success && itemsResponse.data) {
// nonconformity 카테고리의 아이템만 필터링
const nonconfCatIds = this.state.issueCategories.map(c => c.category_id);
this.state.issueItems = itemsResponse.data.filter(
item => nonconfCatIds.includes(item.category_id)
);
console.log('[API] 신고 아이템 로드:', this.state.issueItems.length);
}
// 레거시 호환: errorTypes에 카테고리 매핑
this.state.errorTypes = this.state.issueCategories.map(cat => ({
id: cat.category_id,
name: cat.category_name
}));
} catch (error) {
console.error('[API] 오류 유형 로딩 오류:', error);
// 기본값 설정
this.state.errorTypes = [
{ id: 1, name: '자재 부적합' },
{ id: 2, name: '도면 오류' },
{ id: 3, name: '장비 고장' }
];
}
}
/**
* 미완료 TBM 세션 로드
*/
async loadIncompleteTbms() {
try {
const response = await window.apiCall('/tbm/sessions/incomplete-reports');
if (!response.success) {
throw new Error(response.message || '미완료 TBM 조회 실패');
}
let data = response.data || [];
// 사용자 권한 확인 및 필터링
const user = this.state.getUser();
if (user && user.role !== 'Admin' && user.access_level !== 'system') {
const userId = user.user_id;
data = data.filter(tbm => tbm.created_by === userId);
}
this.state.incompleteTbms = data;
console.log('[API] 미완료 TBM 로드 완료:', data.length);
return data;
} catch (error) {
console.error('[API] 미완료 TBM 로드 오류:', error);
throw error;
}
}
/**
* TBM 세션별 당일 신고 로드
*/
async loadDailyIssuesForTbms() {
const tbms = this.state.incompleteTbms;
if (!tbms || tbms.length === 0) {
console.log('[API] 미완료 TBM 없음, 신고 조회 건너뜀');
return;
}
// 고유한 날짜 수집
const uniqueDates = [...new Set(tbms.map(tbm => {
return window.DailyWorkReportUtils?.formatDateForApi(tbm.session_date) ||
this.formatDateForApi(tbm.session_date);
}).filter(Boolean))];
console.log('[API] 조회할 날짜들:', uniqueDates);
for (const dateStr of uniqueDates) {
if (this.state.dailyIssuesCache[dateStr]) {
console.log(`[API] 캐시 사용 (${dateStr})`);
continue;
}
try {
const response = await window.apiCall(`/work-issues?start_date=${dateStr}&end_date=${dateStr}`);
if (response.success) {
this.state.setDailyIssuesCache(dateStr, response.data || []);
console.log(`[API] 신고 로드 완료 (${dateStr}):`, this.state.dailyIssuesCache[dateStr].length);
} else {
this.state.setDailyIssuesCache(dateStr, []);
}
} catch (error) {
console.error(`[API] 신고 조회 오류 (${dateStr}):`, error);
this.state.setDailyIssuesCache(dateStr, []);
}
}
}
/**
* 완료된 작업보고서 조회
*/
async loadCompletedReports(date) {
try {
const response = await window.apiCall(`/daily-work-reports/v2/reports?date=${date}`);
if (response.success) {
console.log(`[API] 완료 보고서 로드 (${date}):`, response.data?.length || 0);
return response.data || [];
}
throw new Error(response.message || '조회 실패');
} catch (error) {
console.error('[API] 완료 보고서 로드 오류:', error);
throw error;
}
}
/**
* TBM 작업보고서 제출
*/
async submitTbmWorkReport(reportData) {
try {
const response = await window.apiCall('/daily-work-reports/from-tbm', 'POST', reportData);
if (!response.success) {
throw new Error(response.message || '제출 실패');
}
console.log('[API] TBM 작업보고서 제출 완료:', response);
return response;
} catch (error) {
console.error('[API] TBM 작업보고서 제출 오류:', error);
throw error;
}
}
/**
* 수동 작업보고서 제출
*/
async submitManualWorkReport(reportData) {
try {
const response = await window.apiCall('/daily-work-reports/v2/reports', 'POST', reportData);
if (!response.success) {
throw new Error(response.message || '제출 실패');
}
console.log('[API] 수동 작업보고서 제출 완료:', response);
return response;
} catch (error) {
console.error('[API] 수동 작업보고서 제출 오류:', error);
throw error;
}
}
/**
* 작업보고서 삭제
*/
async deleteWorkReport(reportId) {
try {
const response = await window.apiCall(`/daily-work-reports/v2/reports/${reportId}`, 'DELETE');
if (!response.success) {
throw new Error(response.message || '삭제 실패');
}
console.log('[API] 작업보고서 삭제 완료:', reportId);
return response;
} catch (error) {
console.error('[API] 작업보고서 삭제 오류:', error);
throw error;
}
}
/**
* 작업보고서 수정
*/
async updateWorkReport(reportId, updateData) {
try {
const response = await window.apiCall(`/daily-work-reports/v2/reports/${reportId}`, 'PUT', updateData);
if (!response.success) {
throw new Error(response.message || '수정 실패');
}
console.log('[API] 작업보고서 수정 완료:', reportId);
return response;
} catch (error) {
console.error('[API] 작업보고서 수정 오류:', error);
throw error;
}
}
/**
* 신고 카테고리 추가
*/
async addIssueCategory(categoryData) {
try {
const response = await window.apiCall('/work-issues/categories', 'POST', categoryData);
if (response.success) {
await this.loadErrorTypes(); // 목록 새로고침
}
return response;
} catch (error) {
console.error('[API] 카테고리 추가 오류:', error);
throw error;
}
}
/**
* 신고 아이템 추가
*/
async addIssueItem(itemData) {
try {
const response = await window.apiCall('/work-issues/items', 'POST', itemData);
if (response.success) {
await this.loadErrorTypes(); // 목록 새로고침
}
return response;
} catch (error) {
console.error('[API] 아이템 추가 오류:', error);
throw error;
}
}
/**
* 모든 기본 데이터 로드
*/
async loadAllData() {
console.log('[API] 모든 기본 데이터 로딩 시작...');
await Promise.all([
this.loadWorkers(),
this.loadProjects(),
this.loadWorkTypes(),
this.loadWorkStatusTypes(),
this.loadErrorTypes()
]);
console.log('[API] 모든 기본 데이터 로딩 완료');
}
// 유틸리티: 날짜 형식 변환 (API 형식)
formatDateForApi(date) {
if (!date) return null;
let dateObj;
if (date instanceof Date) {
dateObj = date;
} else if (typeof date === 'string') {
dateObj = new Date(date);
} else {
return null;
}
const year = dateObj.getFullYear();
const month = String(dateObj.getMonth() + 1).padStart(2, '0');
const day = String(dateObj.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
}
// 전역 인스턴스 생성
window.DailyWorkReportAPI = new DailyWorkReportAPI();
// 하위 호환성: 기존 함수들
window.loadWorkers = () => window.DailyWorkReportAPI.loadWorkers();
window.loadProjects = () => window.DailyWorkReportAPI.loadProjects();
window.loadWorkTypes = () => window.DailyWorkReportAPI.loadWorkTypes();
window.loadWorkStatusTypes = () => window.DailyWorkReportAPI.loadWorkStatusTypes();
window.loadErrorTypes = () => window.DailyWorkReportAPI.loadErrorTypes();
window.loadIncompleteTbms = () => window.DailyWorkReportAPI.loadIncompleteTbms();
window.loadDailyIssuesForTbms = () => window.DailyWorkReportAPI.loadDailyIssuesForTbms();
window.loadCompletedReports = () => window.DailyWorkReportAPI.loadCompletedReports(
document.getElementById('completedReportDate')?.value
);
// 통합 데이터 로드 함수
window.loadData = async () => {
try {
window.showMessage?.('데이터를 불러오는 중...', 'loading');
await window.DailyWorkReportAPI.loadAllData();
window.hideMessage?.();
} catch (error) {
console.error('[API] 데이터 로드 실패:', error);
window.showMessage?.('데이터 로드 중 오류가 발생했습니다: ' + error.message, 'error');
}
};

View File

@@ -0,0 +1,318 @@
/**
* Daily Work Report - Module Loader
* 작업보고서 모듈을 초기화하고 연결하는 메인 진입점
*
* 로드 순서:
* 1. state.js - 전역 상태 관리
* 2. utils.js - 유틸리티 함수
* 3. api.js - API 클라이언트
* 4. index.js - 이 파일 (메인 컨트롤러)
*/
class DailyWorkReportController {
constructor() {
this.state = window.DailyWorkReportState;
this.api = window.DailyWorkReportAPI;
this.utils = window.DailyWorkReportUtils;
this.initialized = false;
console.log('[Controller] DailyWorkReportController 생성');
}
/**
* 초기화
*/
async init() {
if (this.initialized) {
console.log('[Controller] 이미 초기화됨');
return;
}
console.log('[Controller] 초기화 시작...');
try {
// 이벤트 리스너 설정
this.setupEventListeners();
// 기본 데이터 로드
await this.api.loadAllData();
// TBM 탭이 기본
await this.switchTab('tbm');
this.initialized = true;
console.log('[Controller] 초기화 완료');
} catch (error) {
console.error('[Controller] 초기화 실패:', error);
window.showMessage?.('초기화 중 오류가 발생했습니다: ' + error.message, 'error');
}
}
/**
* 이벤트 리스너 설정
*/
setupEventListeners() {
// 탭 버튼
const tbmBtn = document.getElementById('tbmReportTab');
const completedBtn = document.getElementById('completedReportTab');
if (tbmBtn) {
tbmBtn.addEventListener('click', () => this.switchTab('tbm'));
}
if (completedBtn) {
completedBtn.addEventListener('click', () => this.switchTab('completed'));
}
// 완료 보고서 날짜 변경
const completedDateInput = document.getElementById('completedReportDate');
if (completedDateInput) {
completedDateInput.addEventListener('change', () => this.loadCompletedReports());
}
console.log('[Controller] 이벤트 리스너 설정 완료');
}
/**
* 탭 전환
*/
async switchTab(tab) {
this.state.setCurrentTab(tab);
const tbmBtn = document.getElementById('tbmReportTab');
const completedBtn = document.getElementById('completedReportTab');
const tbmSection = document.getElementById('tbmReportSection');
const completedSection = document.getElementById('completedReportSection');
// 모든 탭 버튼 비활성화
tbmBtn?.classList.remove('active');
completedBtn?.classList.remove('active');
// 모든 섹션 숨기기
if (tbmSection) tbmSection.style.display = 'none';
if (completedSection) completedSection.style.display = 'none';
// 선택된 탭 활성화
if (tab === 'tbm') {
tbmBtn?.classList.add('active');
if (tbmSection) tbmSection.style.display = 'block';
await this.loadTbmData();
} else if (tab === 'completed') {
completedBtn?.classList.add('active');
if (completedSection) completedSection.style.display = 'block';
// 오늘 날짜로 초기화
const dateInput = document.getElementById('completedReportDate');
if (dateInput) {
dateInput.value = this.utils.getKoreaToday();
}
await this.loadCompletedReports();
}
}
/**
* TBM 데이터 로드
*/
async loadTbmData() {
try {
await this.api.loadIncompleteTbms();
await this.api.loadDailyIssuesForTbms();
// 렌더링은 기존 함수 사용 (점진적 마이그레이션)
if (typeof window.renderTbmWorkList === 'function') {
window.renderTbmWorkList();
}
} catch (error) {
console.error('[Controller] TBM 데이터 로드 오류:', error);
window.showMessage?.('TBM 데이터를 불러오는 중 오류가 발생했습니다.', 'error');
}
}
/**
* 완료 보고서 로드
*/
async loadCompletedReports() {
try {
const dateInput = document.getElementById('completedReportDate');
const date = dateInput?.value || this.utils.getKoreaToday();
const reports = await this.api.loadCompletedReports(date);
// 렌더링은 기존 함수 사용
if (typeof window.renderCompletedReports === 'function') {
window.renderCompletedReports(reports);
}
} catch (error) {
console.error('[Controller] 완료 보고서 로드 오류:', error);
window.showMessage?.('완료 보고서를 불러오는 중 오류가 발생했습니다.', 'error');
}
}
/**
* TBM 작업보고서 제출
*/
async submitTbmWorkReport(index) {
try {
const tbm = this.state.incompleteTbms[index];
if (!tbm) {
throw new Error('TBM 데이터를 찾을 수 없습니다.');
}
// 유효성 검사
const totalHoursInput = document.getElementById(`totalHours_${index}`);
const totalHours = parseFloat(totalHoursInput?.value);
if (!totalHours || totalHours <= 0) {
window.showMessage?.('작업시간을 입력해주세요.', 'warning');
return;
}
// 부적합 시간 계산
const defects = this.state.tempDefects[index] || [];
const errorHours = defects.reduce((sum, d) => sum + (parseFloat(d.defect_hours) || 0), 0);
const regularHours = totalHours - errorHours;
if (regularHours < 0) {
window.showMessage?.('부적합 시간이 총 작업시간을 초과할 수 없습니다.', 'warning');
return;
}
// API 데이터 구성
const user = this.state.getCurrentUser();
const reportData = {
tbm_session_id: tbm.session_id,
tbm_assignment_id: tbm.assignment_id,
worker_id: tbm.worker_id,
project_id: tbm.project_id,
work_type_id: tbm.work_type_id,
report_date: this.utils.formatDateForApi(tbm.session_date),
total_hours: totalHours,
regular_hours: regularHours,
error_hours: errorHours,
work_status_id: errorHours > 0 ? 2 : 1,
created_by: user?.user_id || user?.id,
defects: defects.map(d => ({
category_id: d.category_id,
item_id: d.item_id,
issue_report_id: d.issue_report_id,
defect_hours: d.defect_hours,
note: d.note
}))
};
const result = await this.api.submitTbmWorkReport(reportData);
window.showSaveResultModal?.(
'success',
'제출 완료',
`${tbm.worker_name}의 작업보고서가 제출되었습니다.`
);
// 목록 새로고침
await this.loadTbmData();
} catch (error) {
console.error('[Controller] 제출 오류:', error);
window.showSaveResultModal?.(
'error',
'제출 실패',
error.message || '작업보고서 제출 중 오류가 발생했습니다.'
);
}
}
/**
* 세션 일괄 제출
*/
async batchSubmitSession(sessionKey) {
const rows = document.querySelectorAll(`tr[data-session-key="${sessionKey}"][data-type="tbm"]`);
const indices = [];
rows.forEach(row => {
const index = parseInt(row.dataset.index);
const totalHoursInput = document.getElementById(`totalHours_${index}`);
if (totalHoursInput?.value && parseFloat(totalHoursInput.value) > 0) {
indices.push(index);
}
});
if (indices.length === 0) {
window.showMessage?.('제출할 항목이 없습니다. 작업시간을 입력해주세요.', 'warning');
return;
}
const confirmed = confirm(`${indices.length}건의 작업보고서를 일괄 제출하시겠습니까?`);
if (!confirmed) return;
let successCount = 0;
let failCount = 0;
for (const index of indices) {
try {
await this.submitTbmWorkReport(index);
successCount++;
} catch (error) {
failCount++;
console.error(`[Controller] 일괄 제출 오류 (index: ${index}):`, error);
}
}
if (failCount === 0) {
window.showSaveResultModal?.('success', '일괄 제출 완료', `${successCount}건이 성공적으로 제출되었습니다.`);
} else {
window.showSaveResultModal?.('warning', '일괄 제출 부분 완료', `성공: ${successCount}건, 실패: ${failCount}`);
}
}
/**
* 상태 디버그
*/
debug() {
console.log('[Controller] 상태 디버그:');
this.state.debug();
}
}
// 전역 인스턴스 생성
window.DailyWorkReportController = new DailyWorkReportController();
// 하위 호환성: 기존 전역 함수들
window.switchTab = (tab) => window.DailyWorkReportController.switchTab(tab);
window.submitTbmWorkReport = (index) => window.DailyWorkReportController.submitTbmWorkReport(index);
window.batchSubmitTbmSession = (sessionKey) => window.DailyWorkReportController.batchSubmitSession(sessionKey);
// 사용자 정보 함수
window.getUser = () => window.DailyWorkReportState.getUser();
window.getCurrentUser = () => window.DailyWorkReportState.getCurrentUser();
// 날짜 그룹 토글 (UI 함수)
window.toggleDateGroup = function(dateStr) {
const group = document.querySelector(`.date-group[data-date="${dateStr}"]`);
if (!group) return;
const isExpanded = group.classList.contains('expanded');
const content = group.querySelector('.date-group-content');
const icon = group.querySelector('.date-toggle-icon');
if (isExpanded) {
group.classList.remove('expanded');
group.classList.add('collapsed');
if (content) content.style.display = 'none';
if (icon) icon.textContent = '▶';
} else {
group.classList.remove('collapsed');
group.classList.add('expanded');
if (content) content.style.display = 'block';
if (icon) icon.textContent = '▼';
}
};
// DOMContentLoaded 이벤트에서 초기화
document.addEventListener('DOMContentLoaded', () => {
// 약간의 지연 후 초기화 (다른 스크립트 로드 대기)
setTimeout(() => {
window.DailyWorkReportController.init();
}, 100);
});
console.log('[Module] daily-work-report/index.js 로드 완료');

View File

@@ -0,0 +1,342 @@
/**
* Daily Work Report - State Manager
* 작업보고서 페이지의 전역 상태 관리
*/
class DailyWorkReportState {
constructor() {
// 마스터 데이터
this.workTypes = [];
this.workStatusTypes = [];
this.errorTypes = []; // 레거시 호환용
this.issueCategories = []; // 신고 카테고리 (nonconformity)
this.issueItems = []; // 신고 아이템
this.workers = [];
this.projects = [];
// UI 상태
this.selectedWorkers = new Set();
this.workEntryCounter = 0;
this.currentStep = 1;
this.editingWorkId = null;
this.currentTab = 'tbm';
// TBM 관련
this.incompleteTbms = [];
// 부적합 원인 관리
this.currentDefectIndex = null;
this.tempDefects = {}; // { index: [{ error_type_id, defect_hours, note }] }
// 작업장소 지도 관련
this.mapCanvas = null;
this.mapCtx = null;
this.mapImage = null;
this.mapRegions = [];
this.selectedWorkplace = null;
this.selectedWorkplaceName = null;
this.selectedWorkplaceCategory = null;
this.selectedWorkplaceCategoryName = null;
// 시간 선택 관련
this.currentEditingField = null; // { index, type: 'total' | 'error' }
this.currentTimeValue = 0;
// 캐시
this.dailyIssuesCache = {}; // { 'YYYY-MM-DD': [issues] }
// 리스너
this.listeners = new Map();
console.log('[State] DailyWorkReportState 초기화 완료');
}
/**
* 상태 업데이트
*/
update(key, value) {
const prevValue = this[key];
this[key] = value;
this.notifyListeners(key, value, prevValue);
}
/**
* 리스너 등록
*/
subscribe(key, callback) {
if (!this.listeners.has(key)) {
this.listeners.set(key, []);
}
this.listeners.get(key).push(callback);
}
/**
* 리스너에게 알림
*/
notifyListeners(key, newValue, prevValue) {
const keyListeners = this.listeners.get(key) || [];
keyListeners.forEach(callback => {
try {
callback(newValue, prevValue);
} catch (error) {
console.error(`[State] 리스너 오류 (${key}):`, error);
}
});
}
/**
* 현재 사용자 정보 가져오기
*/
getUser() {
const user = localStorage.getItem('user');
return user ? JSON.parse(user) : null;
}
/**
* 토큰에서 사용자 정보 추출
*/
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('[State] 토큰에서 사용자 정보 추출 실패:', error);
}
try {
const userInfo = localStorage.getItem('user') || localStorage.getItem('userInfo') || localStorage.getItem('currentUser');
if (userInfo) {
return JSON.parse(userInfo);
}
} catch (error) {
console.log('[State] localStorage에서 사용자 정보 가져오기 실패:', error);
}
return null;
}
/**
* 선택된 작업자 토글
*/
toggleWorkerSelection(workerId) {
if (this.selectedWorkers.has(workerId)) {
this.selectedWorkers.delete(workerId);
} else {
this.selectedWorkers.add(workerId);
}
this.notifyListeners('selectedWorkers', this.selectedWorkers, null);
}
/**
* 작업자 전체 선택/해제
*/
selectAllWorkers(select = true) {
if (select) {
this.workers.forEach(w => this.selectedWorkers.add(w.worker_id));
} else {
this.selectedWorkers.clear();
}
this.notifyListeners('selectedWorkers', this.selectedWorkers, null);
}
/**
* 작업 항목 카운터 증가
*/
incrementWorkEntryCounter() {
this.workEntryCounter++;
return this.workEntryCounter;
}
/**
* 탭 변경
*/
setCurrentTab(tab) {
const prevTab = this.currentTab;
this.currentTab = tab;
this.notifyListeners('currentTab', tab, prevTab);
}
/**
* 부적합 임시 저장소 초기화
*/
initTempDefects(index) {
if (!this.tempDefects[index]) {
this.tempDefects[index] = [];
}
}
/**
* 부적합 추가
*/
addTempDefect(index, defect) {
this.initTempDefects(index);
this.tempDefects[index].push(defect);
this.notifyListeners('tempDefects', this.tempDefects, null);
}
/**
* 부적합 업데이트
*/
updateTempDefect(index, defectIndex, field, value) {
if (this.tempDefects[index] && this.tempDefects[index][defectIndex]) {
this.tempDefects[index][defectIndex][field] = value;
this.notifyListeners('tempDefects', this.tempDefects, null);
}
}
/**
* 부적합 삭제
*/
removeTempDefect(index, defectIndex) {
if (this.tempDefects[index]) {
this.tempDefects[index].splice(defectIndex, 1);
this.notifyListeners('tempDefects', this.tempDefects, null);
}
}
/**
* 일일 이슈 캐시 설정
*/
setDailyIssuesCache(dateStr, issues) {
this.dailyIssuesCache[dateStr] = issues;
}
/**
* 일일 이슈 캐시 조회
*/
getDailyIssuesCache(dateStr) {
return this.dailyIssuesCache[dateStr] || [];
}
/**
* 상태 초기화
*/
reset() {
this.selectedWorkers.clear();
this.workEntryCounter = 0;
this.currentStep = 1;
this.editingWorkId = null;
this.tempDefects = {};
this.currentDefectIndex = null;
this.dailyIssuesCache = {};
}
/**
* 디버그 출력
*/
debug() {
console.log('[State] 현재 상태:', {
workTypes: this.workTypes.length,
workers: this.workers.length,
projects: this.projects.length,
selectedWorkers: this.selectedWorkers.size,
currentTab: this.currentTab,
incompleteTbms: this.incompleteTbms.length,
tempDefects: Object.keys(this.tempDefects).length
});
}
}
// 전역 인스턴스 생성
window.DailyWorkReportState = new DailyWorkReportState();
// 하위 호환성을 위한 전역 변수 프록시
const stateProxy = window.DailyWorkReportState;
// 기존 전역 변수들과 호환
Object.defineProperties(window, {
workTypes: {
get: () => stateProxy.workTypes,
set: (v) => { stateProxy.workTypes = v; }
},
workStatusTypes: {
get: () => stateProxy.workStatusTypes,
set: (v) => { stateProxy.workStatusTypes = v; }
},
errorTypes: {
get: () => stateProxy.errorTypes,
set: (v) => { stateProxy.errorTypes = v; }
},
issueCategories: {
get: () => stateProxy.issueCategories,
set: (v) => { stateProxy.issueCategories = v; }
},
issueItems: {
get: () => stateProxy.issueItems,
set: (v) => { stateProxy.issueItems = v; }
},
workers: {
get: () => stateProxy.workers,
set: (v) => { stateProxy.workers = v; }
},
projects: {
get: () => stateProxy.projects,
set: (v) => { stateProxy.projects = v; }
},
selectedWorkers: {
get: () => stateProxy.selectedWorkers,
set: (v) => { stateProxy.selectedWorkers = v; }
},
incompleteTbms: {
get: () => stateProxy.incompleteTbms,
set: (v) => { stateProxy.incompleteTbms = v; }
},
tempDefects: {
get: () => stateProxy.tempDefects,
set: (v) => { stateProxy.tempDefects = v; }
},
dailyIssuesCache: {
get: () => stateProxy.dailyIssuesCache,
set: (v) => { stateProxy.dailyIssuesCache = v; }
},
currentTab: {
get: () => stateProxy.currentTab,
set: (v) => { stateProxy.currentTab = v; }
},
currentStep: {
get: () => stateProxy.currentStep,
set: (v) => { stateProxy.currentStep = v; }
},
editingWorkId: {
get: () => stateProxy.editingWorkId,
set: (v) => { stateProxy.editingWorkId = v; }
},
workEntryCounter: {
get: () => stateProxy.workEntryCounter,
set: (v) => { stateProxy.workEntryCounter = v; }
},
currentDefectIndex: {
get: () => stateProxy.currentDefectIndex,
set: (v) => { stateProxy.currentDefectIndex = v; }
},
currentEditingField: {
get: () => stateProxy.currentEditingField,
set: (v) => { stateProxy.currentEditingField = v; }
},
currentTimeValue: {
get: () => stateProxy.currentTimeValue,
set: (v) => { stateProxy.currentTimeValue = v; }
},
selectedWorkplace: {
get: () => stateProxy.selectedWorkplace,
set: (v) => { stateProxy.selectedWorkplace = v; }
},
selectedWorkplaceName: {
get: () => stateProxy.selectedWorkplaceName,
set: (v) => { stateProxy.selectedWorkplaceName = v; }
},
selectedWorkplaceCategory: {
get: () => stateProxy.selectedWorkplaceCategory,
set: (v) => { stateProxy.selectedWorkplaceCategory = v; }
},
selectedWorkplaceCategoryName: {
get: () => stateProxy.selectedWorkplaceCategoryName,
set: (v) => { stateProxy.selectedWorkplaceCategoryName = v; }
}
});

View File

@@ -0,0 +1,470 @@
/**
* Daily Work Report - Utilities
* 작업보고서 관련 유틸리티 함수들
*/
class DailyWorkReportUtils {
constructor() {
console.log('[Utils] DailyWorkReportUtils 초기화');
}
/**
* 한국 시간 기준 오늘 날짜 (YYYY-MM-DD)
*/
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}`;
}
/**
* 날짜를 API 형식(YYYY-MM-DD)으로 변환
*/
formatDateForApi(date) {
if (!date) return null;
let dateObj;
if (date instanceof Date) {
dateObj = date;
} else if (typeof date === 'string') {
dateObj = new Date(date);
} else {
return null;
}
const year = dateObj.getFullYear();
const month = String(dateObj.getMonth() + 1).padStart(2, '0');
const day = String(dateObj.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* 날짜 포맷팅 (표시용)
*/
formatDate(date) {
if (!date) return '-';
let dateObj;
if (date instanceof Date) {
dateObj = date;
} else if (typeof date === 'string') {
dateObj = new Date(date);
} else {
return '-';
}
const year = dateObj.getFullYear();
const month = String(dateObj.getMonth() + 1).padStart(2, '0');
const day = String(dateObj.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* 시간 포맷팅 (HH:mm)
*/
formatTime(time) {
if (!time) return '-';
if (typeof time === 'string' && time.includes(':')) {
return time.substring(0, 5);
}
return time;
}
/**
* 상태 라벨 반환
*/
getStatusLabel(status) {
const labels = {
'pending': '접수',
'in_progress': '처리중',
'resolved': '해결',
'completed': '완료',
'closed': '종료'
};
return labels[status] || status || '-';
}
/**
* 숫자 포맷팅 (천 단위 콤마)
*/
formatNumber(num) {
if (num === null || num === undefined) return '0';
return num.toLocaleString('ko-KR');
}
/**
* 소수점 자리수 포맷팅
*/
formatDecimal(num, decimals = 1) {
if (num === null || num === undefined) return '0';
return Number(num).toFixed(decimals);
}
/**
* 요일 반환
*/
getDayOfWeek(date) {
const days = ['일', '월', '화', '수', '목', '금', '토'];
const dateObj = date instanceof Date ? date : new Date(date);
return days[dateObj.getDay()];
}
/**
* 오늘인지 확인
*/
isToday(date) {
const today = this.getKoreaToday();
const targetDate = this.formatDateForApi(date);
return today === targetDate;
}
/**
* 두 날짜 사이 일수 계산
*/
daysBetween(date1, date2) {
const d1 = new Date(date1);
const d2 = new Date(date2);
const diffTime = Math.abs(d2 - d1);
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
}
/**
* 디바운스 함수
*/
debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
/**
* 쓰로틀 함수
*/
throttle(func, limit) {
let inThrottle;
return function(...args) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
}
/**
* HTML 이스케이프
*/
escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* 객체 깊은 복사
*/
deepClone(obj) {
return JSON.parse(JSON.stringify(obj));
}
/**
* 빈 값 확인
*/
isEmpty(value) {
if (value === null || value === undefined) return true;
if (typeof value === 'string') return value.trim() === '';
if (Array.isArray(value)) return value.length === 0;
if (typeof value === 'object') return Object.keys(value).length === 0;
return false;
}
/**
* 숫자 유효성 검사
*/
isValidNumber(value) {
return !isNaN(value) && isFinite(value);
}
/**
* 시간 유효성 검사 (0-24)
*/
isValidHours(hours) {
const num = parseFloat(hours);
return this.isValidNumber(num) && num >= 0 && num <= 24;
}
/**
* 쿼리 스트링 파싱
*/
parseQueryString(queryString) {
const params = new URLSearchParams(queryString);
const result = {};
for (const [key, value] of params) {
result[key] = value;
}
return result;
}
/**
* 쿼리 스트링 생성
*/
buildQueryString(params) {
return new URLSearchParams(params).toString();
}
/**
* 로컬 스토리지 안전하게 가져오기
*/
getLocalStorage(key, defaultValue = null) {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : defaultValue;
} catch (error) {
console.error('[Utils] localStorage 읽기 오류:', error);
return defaultValue;
}
}
/**
* 로컬 스토리지 안전하게 저장하기
*/
setLocalStorage(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
return true;
} catch (error) {
console.error('[Utils] localStorage 저장 오류:', error);
return false;
}
}
/**
* 배열 그룹화
*/
groupBy(array, key) {
return array.reduce((result, item) => {
const groupKey = typeof key === 'function' ? key(item) : item[key];
if (!result[groupKey]) {
result[groupKey] = [];
}
result[groupKey].push(item);
return result;
}, {});
}
/**
* 배열 정렬 (다중 키)
*/
sortBy(array, ...keys) {
return [...array].sort((a, b) => {
for (const key of keys) {
const direction = key.startsWith('-') ? -1 : 1;
const actualKey = key.replace(/^-/, '');
const aVal = a[actualKey];
const bVal = b[actualKey];
if (aVal < bVal) return -1 * direction;
if (aVal > bVal) return 1 * direction;
}
return 0;
});
}
/**
* UUID 생성
*/
generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
}
// 전역 인스턴스 생성
window.DailyWorkReportUtils = new DailyWorkReportUtils();
// 하위 호환성: 기존 함수들
window.getKoreaToday = () => window.DailyWorkReportUtils.getKoreaToday();
window.formatDateForApi = (date) => window.DailyWorkReportUtils.formatDateForApi(date);
window.formatDate = (date) => window.DailyWorkReportUtils.formatDate(date);
window.getStatusLabel = (status) => window.DailyWorkReportUtils.getStatusLabel(status);
// 메시지 표시 함수들
window.showMessage = function(message, type = 'info') {
const container = document.getElementById('message-container');
if (!container) {
console.log(`[Message] ${type}: ${message}`);
return;
}
container.innerHTML = `<div class="message ${type}">${message}</div>`;
if (type === 'success') {
setTimeout(() => window.hideMessage(), 5000);
}
};
window.hideMessage = function() {
const container = document.getElementById('message-container');
if (container) {
container.innerHTML = '';
}
};
// 저장 결과 모달
window.showSaveResultModal = function(type, title, message, details = null) {
const modal = document.getElementById('saveResultModal');
const titleElement = document.getElementById('resultModalTitle');
const contentElement = document.getElementById('resultModalContent');
if (!modal || !contentElement) {
alert(`${title}\n\n${message}`);
return;
}
const icons = {
success: '✅',
error: '❌',
warning: '⚠️',
info: ''
};
let content = `
<div class="result-icon ${type}">${icons[type] || icons.info}</div>
<h3 class="result-title ${type}">${title}</h3>
<p class="result-message">${message}</p>
`;
if (details) {
if (Array.isArray(details) && details.length > 0) {
content += `
<div class="result-details">
<h4>상세 정보:</h4>
<ul>${details.map(d => `<li>${d}</li>`).join('')}</ul>
</div>
`;
} else if (typeof details === 'string') {
content += `<div class="result-details"><p>${details}</p></div>`;
}
}
if (titleElement) titleElement.textContent = '저장 결과';
contentElement.innerHTML = content;
modal.style.display = 'flex';
// ESC 키로 닫기
const escHandler = (e) => {
if (e.key === 'Escape') {
window.closeSaveResultModal();
document.removeEventListener('keydown', escHandler);
}
};
document.addEventListener('keydown', escHandler);
// 배경 클릭으로 닫기
modal.onclick = (e) => {
if (e.target === modal) {
window.closeSaveResultModal();
}
};
};
window.closeSaveResultModal = function() {
const modal = document.getElementById('saveResultModal');
if (modal) {
modal.style.display = 'none';
}
};
// 단계 이동 함수
window.goToStep = function(stepNumber) {
const state = window.DailyWorkReportState;
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');
}
}
}
window.updateProgressSteps(stepNumber);
state.currentStep = stepNumber;
};
window.updateProgressSteps = function(currentStepNumber) {
for (let i = 1; i <= 3; i++) {
const progressStep = document.getElementById(`progressStep${i}`);
if (progressStep) {
progressStep.classList.remove('active', 'completed');
if (i < currentStepNumber) {
progressStep.classList.add('completed');
} else if (i === currentStepNumber) {
progressStep.classList.add('active');
}
}
}
};
// 토스트 메시지 (간단 버전)
window.showToast = function(message, type = 'info', duration = 3000) {
console.log(`[Toast] ${type}: ${message}`);
// 기존 토스트 제거
const existingToast = document.querySelector('.toast-message');
if (existingToast) {
existingToast.remove();
}
// 새 토스트 생성
const toast = document.createElement('div');
toast.className = `toast-message toast-${type}`;
toast.textContent = message;
toast.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
padding: 12px 20px;
border-radius: 8px;
color: white;
font-size: 14px;
z-index: 10000;
animation: slideIn 0.3s ease;
background-color: ${type === 'success' ? '#10b981' : type === 'error' ? '#ef4444' : type === 'warning' ? '#f59e0b' : '#3b82f6'};
`;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'slideOut 0.3s ease';
setTimeout(() => toast.remove(), 300);
}, duration);
};
// 확인 다이얼로그
window.showConfirmDialog = function(message, onConfirm, onCancel) {
if (confirm(message)) {
onConfirm?.();
} else {
onCancel?.();
}
};

View File

@@ -0,0 +1,339 @@
// department-management.js
// 부서 관리 페이지 JavaScript
let departments = [];
let selectedDepartmentId = null;
let selectedWorkers = new Set();
// 페이지 초기화
document.addEventListener('DOMContentLoaded', async () => {
await waitForApiConfig();
await loadDepartments();
});
// API 설정 로드 대기
async function waitForApiConfig() {
let retryCount = 0;
while (!window.apiCall && retryCount < 50) {
await new Promise(resolve => setTimeout(resolve, 100));
retryCount++;
}
if (!window.apiCall) {
console.error('API 설정 로드 실패');
}
}
// 부서 목록 로드
async function loadDepartments() {
try {
const result = await window.apiCall('/departments');
if (result.success) {
departments = result.data;
renderDepartmentList();
updateMoveToDepartmentSelect();
}
} catch (error) {
console.error('부서 목록 로드 실패:', error);
}
}
// 부서 목록 렌더링
function renderDepartmentList() {
const container = document.getElementById('departmentList');
if (departments.length === 0) {
container.innerHTML = `
<div style="text-align: center; padding: 2rem; color: #9ca3af;">
등록된 부서가 없습니다.<br>
<button class="btn btn-primary btn-sm" style="margin-top: 1rem;" onclick="openDepartmentModal()">
첫 부서 등록하기
</button>
</div>
`;
return;
}
container.innerHTML = departments.map(dept => `
<div class="department-item ${selectedDepartmentId === dept.department_id ? 'active' : ''}"
onclick="selectDepartment(${dept.department_id})">
<div class="department-info">
<span class="department-name">${dept.department_name}</span>
<span class="department-count">${dept.worker_count || 0}명</span>
</div>
<div class="department-actions" onclick="event.stopPropagation()">
<button class="btn-icon" onclick="editDepartment(${dept.department_id})" title="수정">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</button>
<button class="btn-icon danger" onclick="deleteDepartment(${dept.department_id})" title="삭제">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
</div>
</div>
`).join('');
}
// 부서 선택
async function selectDepartment(departmentId) {
selectedDepartmentId = departmentId;
selectedWorkers.clear();
updateBulkActions();
renderDepartmentList();
const dept = departments.find(d => d.department_id === departmentId);
document.getElementById('workerListTitle').textContent = `${dept.department_name} 작업자`;
document.getElementById('addWorkerBtn').style.display = 'inline-flex';
await loadWorkers(departmentId);
}
// 부서별 작업자 로드
async function loadWorkers(departmentId) {
try {
const result = await window.apiCall(`/departments/${departmentId}/workers`);
if (result.success) {
renderWorkerList(result.data);
}
} catch (error) {
console.error('작업자 목록 로드 실패:', error);
}
}
// 작업자 목록 렌더링
function renderWorkerList(workers) {
const container = document.getElementById('workerList');
if (workers.length === 0) {
container.innerHTML = `
<div style="text-align: center; padding: 2rem; color: #9ca3af;">
이 부서에 소속된 작업자가 없습니다.
</div>
`;
return;
}
container.innerHTML = workers.map(worker => `
<div class="worker-card ${selectedWorkers.has(worker.worker_id) ? 'selected' : ''}"
onclick="toggleWorkerSelection(${worker.worker_id})">
<div class="worker-info-row">
<input type="checkbox" ${selectedWorkers.has(worker.worker_id) ? 'checked' : ''}
onclick="event.stopPropagation(); toggleWorkerSelection(${worker.worker_id})">
<div class="worker-avatar">${worker.worker_name.charAt(0)}</div>
<div class="worker-details">
<span class="worker-name">${worker.worker_name}</span>
<span class="worker-job">${getJobTypeName(worker.job_type)}</span>
</div>
</div>
</div>
`).join('');
}
// 직책 한글 변환
function getJobTypeName(jobType) {
const names = {
leader: '그룹장',
worker: '작업자',
admin: '관리자'
};
return names[jobType] || jobType || '-';
}
// 작업자 선택 토글
function toggleWorkerSelection(workerId) {
if (selectedWorkers.has(workerId)) {
selectedWorkers.delete(workerId);
} else {
selectedWorkers.add(workerId);
}
updateBulkActions();
// 선택 상태 업데이트
const card = document.querySelector(`.worker-card[onclick*="${workerId}"]`);
if (card) {
card.classList.toggle('selected', selectedWorkers.has(workerId));
const checkbox = card.querySelector('input[type="checkbox"]');
if (checkbox) checkbox.checked = selectedWorkers.has(workerId);
}
}
// 일괄 작업 영역 업데이트
function updateBulkActions() {
const bulkActions = document.getElementById('bulkActions');
const selectedCount = document.getElementById('selectedCount');
if (selectedWorkers.size > 0) {
bulkActions.classList.add('visible');
selectedCount.textContent = selectedWorkers.size;
} else {
bulkActions.classList.remove('visible');
}
}
// 이동 대상 부서 선택 업데이트
function updateMoveToDepartmentSelect() {
const select = document.getElementById('moveToDepartment');
select.innerHTML = '<option value="">부서 이동...</option>' +
departments.map(d => `<option value="${d.department_id}">${d.department_name}</option>`).join('');
}
// 선택한 작업자 이동
async function moveSelectedWorkers() {
const targetDepartmentId = document.getElementById('moveToDepartment').value;
if (!targetDepartmentId) {
alert('이동할 부서를 선택하세요.');
return;
}
if (selectedWorkers.size === 0) {
alert('이동할 작업자를 선택하세요.');
return;
}
if (parseInt(targetDepartmentId) === selectedDepartmentId) {
alert('같은 부서로는 이동할 수 없습니다.');
return;
}
try {
const result = await window.apiCall('/departments/move-workers', 'POST', {
workerIds: Array.from(selectedWorkers),
departmentId: parseInt(targetDepartmentId)
});
if (result.success) {
alert(result.message);
selectedWorkers.clear();
updateBulkActions();
document.getElementById('moveToDepartment').value = '';
await loadDepartments();
await loadWorkers(selectedDepartmentId);
} else {
alert(result.error || '이동 실패');
}
} catch (error) {
console.error('작업자 이동 실패:', error);
alert('작업자 이동에 실패했습니다.');
}
}
// 부서 모달 열기
function openDepartmentModal(departmentId = null) {
const modal = document.getElementById('departmentModal');
const title = document.getElementById('departmentModalTitle');
const form = document.getElementById('departmentForm');
// 상위 부서 선택 옵션 업데이트
const parentSelect = document.getElementById('parentDepartment');
parentSelect.innerHTML = '<option value="">없음 (최상위 부서)</option>' +
departments
.filter(d => d.department_id !== departmentId)
.map(d => `<option value="${d.department_id}">${d.department_name}</option>`)
.join('');
if (departmentId) {
const dept = departments.find(d => d.department_id === departmentId);
title.textContent = '부서 수정';
document.getElementById('departmentId').value = dept.department_id;
document.getElementById('departmentName').value = dept.department_name;
document.getElementById('parentDepartment').value = dept.parent_id || '';
document.getElementById('departmentDescription').value = dept.description || '';
document.getElementById('displayOrder').value = dept.display_order || 0;
document.getElementById('isActive').checked = dept.is_active;
} else {
title.textContent = '새 부서 등록';
form.reset();
document.getElementById('departmentId').value = '';
document.getElementById('isActive').checked = true;
}
modal.classList.add('show');
}
// 부서 모달 닫기
function closeDepartmentModal() {
document.getElementById('departmentModal').classList.remove('show');
}
// 부서 저장
async function saveDepartment(event) {
event.preventDefault();
const departmentId = document.getElementById('departmentId').value;
const data = {
department_name: document.getElementById('departmentName').value,
parent_id: document.getElementById('parentDepartment').value || null,
description: document.getElementById('departmentDescription').value,
display_order: parseInt(document.getElementById('displayOrder').value) || 0,
is_active: document.getElementById('isActive').checked
};
try {
const url = departmentId ? `/departments/${departmentId}` : '/departments';
const method = departmentId ? 'PUT' : 'POST';
const result = await window.apiCall(url, method, data);
if (result.success) {
alert(result.message);
closeDepartmentModal();
await loadDepartments();
} else {
alert(result.error || '저장 실패');
}
} catch (error) {
console.error('부서 저장 실패:', error);
alert('부서 저장에 실패했습니다.');
}
}
// 부서 수정
function editDepartment(departmentId) {
openDepartmentModal(departmentId);
}
// 부서 삭제
async function deleteDepartment(departmentId) {
const dept = departments.find(d => d.department_id === departmentId);
if (!confirm(`"${dept.department_name}" 부서를 삭제하시겠습니까?\n\n소속 작업자가 있거나 하위 부서가 있으면 삭제할 수 없습니다.`)) {
return;
}
try {
const result = await window.apiCall(`/departments/${departmentId}`, 'DELETE');
if (result.success) {
alert('부서가 삭제되었습니다.');
if (selectedDepartmentId === departmentId) {
selectedDepartmentId = null;
document.getElementById('workerListTitle').textContent = '부서를 선택하세요';
document.getElementById('addWorkerBtn').style.display = 'none';
document.getElementById('workerList').innerHTML = `
<div style="text-align: center; padding: 2rem; color: #9ca3af;">
왼쪽에서 부서를 선택하면 해당 부서의 작업자가 표시됩니다.
</div>
`;
}
await loadDepartments();
} else {
alert(result.error || '삭제 실패');
}
} catch (error) {
console.error('부서 삭제 실패:', error);
alert('부서 삭제에 실패했습니다.');
}
}
// 작업자 추가 모달 (작업자 관리 페이지로 이동)
function openAddWorkerModal() {
alert('작업자 관리 페이지에서 작업자를 등록한 후 이 페이지에서 부서를 배정하세요.');
// window.location.href = '/pages/admin/workers.html';
}

View File

@@ -0,0 +1,803 @@
/**
* equipment-detail.js - 설비 상세 페이지 스크립트
*/
// 전역 변수
let currentEquipment = null;
let equipmentId = null;
let workplaces = [];
let factories = [];
let selectedMovePosition = null;
let repairPhotoBases = [];
// 상태 라벨
const STATUS_LABELS = {
active: '정상 가동',
maintenance: '점검 중',
repair_needed: '수리 필요',
inactive: '비활성',
external: '외부 반출',
repair_external: '수리 외주'
};
// 페이지 초기화
document.addEventListener('DOMContentLoaded', () => {
// URL에서 equipment_id 추출
const urlParams = new URLSearchParams(window.location.search);
equipmentId = urlParams.get('id');
if (!equipmentId) {
alert('설비 ID가 필요합니다.');
goBack();
return;
}
// API 설정 후 데이터 로드
waitForApiConfig().then(() => {
loadEquipmentData();
loadFactories();
loadRepairCategories();
});
});
// API 설정 대기
function waitForApiConfig() {
return new Promise(resolve => {
const check = setInterval(() => {
if (window.API_BASE_URL) {
clearInterval(check);
resolve();
}
}, 50);
});
}
// 뒤로가기
function goBack() {
if (document.referrer && document.referrer.includes(window.location.host)) {
history.back();
} else {
window.location.href = '/pages/admin/equipments.html';
}
}
// ==========================================
// 설비 데이터 로드
// ==========================================
async function loadEquipmentData() {
try {
const response = await axios.get(`/equipments/${equipmentId}`);
if (response.data.success) {
currentEquipment = response.data.data;
renderEquipmentInfo();
loadPhotos();
loadRepairHistory();
loadExternalLogs();
loadMoveLogs();
}
} catch (error) {
console.error('설비 정보 로드 실패:', error);
alert('설비 정보를 불러오는데 실패했습니다.');
}
}
function renderEquipmentInfo() {
const eq = currentEquipment;
// 헤더
document.getElementById('equipmentTitle').textContent = `[${eq.equipment_code}] ${eq.equipment_name}`;
document.getElementById('equipmentMeta').textContent = `${eq.model_name || '-'} | ${eq.manufacturer || '-'}`;
// 상태 배지
const statusBadge = document.getElementById('equipmentStatus');
statusBadge.textContent = STATUS_LABELS[eq.status] || eq.status;
statusBadge.className = `eq-status-badge ${eq.status}`;
// 기본 정보 카드
document.getElementById('equipmentInfoCard').innerHTML = `
<div class="eq-info-grid">
<div class="eq-info-item">
<span class="eq-info-label">관리번호</span>
<span class="eq-info-value">${escapeHtml(eq.equipment_code || '-')}</span>
</div>
<div class="eq-info-item">
<span class="eq-info-label">설비명</span>
<span class="eq-info-value">${escapeHtml(eq.equipment_name || '-')}</span>
</div>
<div class="eq-info-item">
<span class="eq-info-label">모델명</span>
<span class="eq-info-value">${escapeHtml(eq.model_name || '-')}</span>
</div>
<div class="eq-info-item">
<span class="eq-info-label">규격</span>
<span class="eq-info-value">${escapeHtml(eq.specifications || '-')}</span>
</div>
<div class="eq-info-item">
<span class="eq-info-label">제조사</span>
<span class="eq-info-value">${escapeHtml(eq.manufacturer || '-')}</span>
</div>
<div class="eq-info-item">
<span class="eq-info-label">구입처</span>
<span class="eq-info-value">${escapeHtml(eq.supplier || '-')}</span>
</div>
<div class="eq-info-item">
<span class="eq-info-label">구입일</span>
<span class="eq-info-value">${eq.installation_date ? formatDate(eq.installation_date) : '-'}</span>
</div>
<div class="eq-info-item">
<span class="eq-info-label">구입가격</span>
<span class="eq-info-value">${eq.purchase_price ? Number(eq.purchase_price).toLocaleString() + '원' : '-'}</span>
</div>
<div class="eq-info-item">
<span class="eq-info-label">시리얼번호</span>
<span class="eq-info-value">${escapeHtml(eq.serial_number || '-')}</span>
</div>
<div class="eq-info-item">
<span class="eq-info-label">설비유형</span>
<span class="eq-info-value">${escapeHtml(eq.equipment_type || '-')}</span>
</div>
</div>
`;
// 위치 정보
const originalLocation = eq.workplace_name
? `${eq.category_name || ''} > ${eq.workplace_name}`
: '미배정';
document.getElementById('originalLocation').textContent = originalLocation;
if (eq.is_temporarily_moved && eq.current_workplace_id) {
document.getElementById('currentLocationRow').style.display = 'flex';
// 현재 위치 작업장 이름 로드 필요
loadCurrentWorkplaceName(eq.current_workplace_id);
}
// 지도 미리보기 (작업장 지도 표시)
renderMapPreview();
}
async function loadCurrentWorkplaceName(workplaceId) {
try {
const response = await axios.get(`/workplaces/${workplaceId}`);
if (response.data.success) {
const wp = response.data.data;
document.getElementById('currentLocation').textContent = `${wp.category_name || ''} > ${wp.workplace_name}`;
}
} catch (error) {
console.error('현재 위치 로드 실패:', error);
}
}
function renderMapPreview() {
const eq = currentEquipment;
const mapPreview = document.getElementById('mapPreview');
if (!eq.workplace_id) {
mapPreview.innerHTML = '<div style="padding: 1rem; text-align: center; color: #9ca3af;">위치 미배정</div>';
return;
}
// 작업장 지도 정보 로드
axios.get(`/workplaces/${eq.workplace_id}`).then(response => {
if (response.data.success && response.data.data.map_image_url) {
const wp = response.data.data;
const xPercent = eq.is_temporarily_moved ? eq.current_map_x_percent : eq.map_x_percent;
const yPercent = eq.is_temporarily_moved ? eq.current_map_y_percent : eq.map_y_percent;
mapPreview.innerHTML = `
<img src="${window.API_BASE_URL}${wp.map_image_url}" alt="작업장 지도">
<div class="eq-map-marker" style="left: ${xPercent}%; top: ${yPercent}%;"></div>
`;
} else {
mapPreview.innerHTML = '<div style="padding: 1rem; text-align: center; color: #9ca3af;">지도 없음</div>';
}
}).catch(() => {
mapPreview.innerHTML = '<div style="padding: 1rem; text-align: center; color: #9ca3af;">지도 로드 실패</div>';
});
}
// ==========================================
// 사진 관리
// ==========================================
async function loadPhotos() {
try {
const response = await axios.get(`/equipments/${equipmentId}/photos`);
if (response.data.success) {
renderPhotos(response.data.data);
}
} catch (error) {
console.error('사진 로드 실패:', error);
}
}
function renderPhotos(photos) {
const grid = document.getElementById('photoGrid');
if (!photos || photos.length === 0) {
grid.innerHTML = '<div class="eq-photo-empty">등록된 사진이 없습니다</div>';
return;
}
grid.innerHTML = photos.map(photo => {
const safePhotoId = parseInt(photo.photo_id) || 0;
const safePhotoPath = encodeURI(photo.photo_path || '');
const safeDescription = escapeHtml(photo.description || '설비 사진');
return `
<div class="eq-photo-item" onclick="viewPhoto('${window.API_BASE_URL}${safePhotoPath}')">
<img src="${window.API_BASE_URL}${safePhotoPath}" alt="${safeDescription}">
<button class="eq-photo-delete" onclick="event.stopPropagation(); deletePhoto(${safePhotoId})">&times;</button>
</div>
`;
}).join('');
}
function openPhotoModal() {
document.getElementById('photoInput').value = '';
document.getElementById('photoDescription').value = '';
document.getElementById('photoPreviewContainer').style.display = 'none';
document.getElementById('photoModal').style.display = 'flex';
}
function closePhotoModal() {
document.getElementById('photoModal').style.display = 'none';
}
function previewPhoto(event) {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = e => {
document.getElementById('photoPreview').src = e.target.result;
document.getElementById('photoPreviewContainer').style.display = 'block';
};
reader.readAsDataURL(file);
}
}
async function uploadPhoto() {
const fileInput = document.getElementById('photoInput');
const description = document.getElementById('photoDescription').value;
if (!fileInput.files[0]) {
alert('사진을 선택하세요.');
return;
}
const reader = new FileReader();
reader.onload = async e => {
try {
const response = await axios.post(`/equipments/${equipmentId}/photos`, {
photo_base64: e.target.result,
description: description
});
if (response.data.success) {
closePhotoModal();
loadPhotos();
alert('사진이 추가되었습니다.');
}
} catch (error) {
console.error('사진 업로드 실패:', error);
alert('사진 업로드에 실패했습니다.');
}
};
reader.readAsDataURL(fileInput.files[0]);
}
async function deletePhoto(photoId) {
if (!confirm('이 사진을 삭제하시겠습니까?')) return;
try {
const response = await axios.delete(`/equipments/photos/${photoId}`);
if (response.data.success) {
loadPhotos();
}
} catch (error) {
console.error('사진 삭제 실패:', error);
alert('사진 삭제에 실패했습니다.');
}
}
function viewPhoto(url) {
document.getElementById('photoViewImage').src = url;
document.getElementById('photoViewModal').style.display = 'flex';
}
function closePhotoView() {
document.getElementById('photoViewModal').style.display = 'none';
}
// ==========================================
// 임시 이동
// ==========================================
async function loadFactories() {
try {
const response = await axios.get('/workplace-categories');
if (response.data.success) {
factories = response.data.data;
}
} catch (error) {
console.error('공장 목록 로드 실패:', error);
}
}
function openMoveModal() {
// 공장 선택 초기화
const factorySelect = document.getElementById('moveFactorySelect');
factorySelect.innerHTML = '<option value="">공장을 선택하세요</option>';
factories.forEach(f => {
const safeCategoryId = parseInt(f.category_id) || 0;
factorySelect.innerHTML += `<option value="${safeCategoryId}">${escapeHtml(f.category_name || '-')}</option>`;
});
document.getElementById('moveWorkplaceSelect').innerHTML = '<option value="">작업장을 선택하세요</option>';
document.getElementById('moveStep2').style.display = 'none';
document.getElementById('moveStep1').style.display = 'block';
document.getElementById('moveConfirmBtn').disabled = true;
document.getElementById('moveReason').value = '';
selectedMovePosition = null;
document.getElementById('moveModal').style.display = 'flex';
}
function closeMoveModal() {
document.getElementById('moveModal').style.display = 'none';
}
async function loadMoveWorkplaces() {
const categoryId = document.getElementById('moveFactorySelect').value;
const workplaceSelect = document.getElementById('moveWorkplaceSelect');
workplaceSelect.innerHTML = '<option value="">작업장을 선택하세요</option>';
if (!categoryId) return;
try {
const response = await axios.get(`/workplaces?category_id=${categoryId}`);
if (response.data.success) {
workplaces = response.data.data;
workplaces.forEach(wp => {
if (wp.map_image_url) {
const safeWorkplaceId = parseInt(wp.workplace_id) || 0;
workplaceSelect.innerHTML += `<option value="${safeWorkplaceId}">${escapeHtml(wp.workplace_name || '-')}</option>`;
}
});
}
} catch (error) {
console.error('작업장 로드 실패:', error);
}
}
function loadMoveMap() {
const workplaceId = document.getElementById('moveWorkplaceSelect').value;
if (!workplaceId) {
document.getElementById('moveStep2').style.display = 'none';
return;
}
const workplace = workplaces.find(wp => wp.workplace_id == workplaceId);
if (!workplace || !workplace.map_image_url) {
alert('선택한 작업장에 지도가 없습니다.');
return;
}
const container = document.getElementById('moveMapContainer');
container.innerHTML = `<img src="${window.API_BASE_URL}${workplace.map_image_url}" id="moveMapImage" onclick="onMoveMapClick(event)">`;
document.getElementById('moveStep2').style.display = 'block';
}
function onMoveMapClick(event) {
const img = event.target;
const rect = img.getBoundingClientRect();
const x = ((event.clientX - rect.left) / rect.width) * 100;
const y = ((event.clientY - rect.top) / rect.height) * 100;
selectedMovePosition = { x, y };
// 기존 마커 제거
const container = document.getElementById('moveMapContainer');
const existingMarker = container.querySelector('.move-marker');
if (existingMarker) existingMarker.remove();
// 새 마커 추가
const marker = document.createElement('div');
marker.className = 'move-marker';
marker.style.left = x + '%';
marker.style.top = y + '%';
container.appendChild(marker);
document.getElementById('moveConfirmBtn').disabled = false;
}
async function confirmMove() {
const targetWorkplaceId = document.getElementById('moveWorkplaceSelect').value;
const reason = document.getElementById('moveReason').value;
if (!targetWorkplaceId || !selectedMovePosition) {
alert('이동할 위치를 선택하세요.');
return;
}
try {
const response = await axios.post(`/equipments/${equipmentId}/move`, {
target_workplace_id: targetWorkplaceId,
target_x_percent: selectedMovePosition.x.toFixed(2),
target_y_percent: selectedMovePosition.y.toFixed(2),
from_workplace_id: currentEquipment.workplace_id,
from_x_percent: currentEquipment.map_x_percent,
from_y_percent: currentEquipment.map_y_percent,
reason: reason
});
if (response.data.success) {
closeMoveModal();
loadEquipmentData();
loadMoveLogs();
alert('설비가 임시 이동되었습니다.');
}
} catch (error) {
console.error('이동 실패:', error);
alert('설비 이동에 실패했습니다.');
}
}
async function returnToOriginal() {
if (!confirm('설비를 원래 위치로 복귀시키겠습니까?')) return;
try {
const response = await axios.post(`/equipments/${equipmentId}/return`);
if (response.data.success) {
loadEquipmentData();
loadMoveLogs();
alert('설비가 원위치로 복귀되었습니다.');
}
} catch (error) {
console.error('복귀 실패:', error);
alert('설비 복귀에 실패했습니다.');
}
}
// ==========================================
// 수리 신청
// ==========================================
let repairCategories = [];
async function loadRepairCategories() {
try {
const response = await axios.get('/equipments/repair-categories');
if (response.data.success) {
repairCategories = response.data.data;
}
} catch (error) {
console.error('수리 항목 로드 실패:', error);
}
}
function openRepairModal() {
const select = document.getElementById('repairItemSelect');
select.innerHTML = '<option value="">선택하세요</option>';
repairCategories.forEach(item => {
const safeItemId = parseInt(item.item_id) || 0;
select.innerHTML += `<option value="${safeItemId}">${escapeHtml(item.item_name || '-')}</option>`;
});
document.getElementById('repairDescription').value = '';
document.getElementById('repairPhotoInput').value = '';
document.getElementById('repairPhotoPreviews').innerHTML = '';
repairPhotoBases = [];
document.getElementById('repairModal').style.display = 'flex';
}
function closeRepairModal() {
document.getElementById('repairModal').style.display = 'none';
}
function previewRepairPhotos(event) {
const files = event.target.files;
const previewContainer = document.getElementById('repairPhotoPreviews');
previewContainer.innerHTML = '';
repairPhotoBases = [];
Array.from(files).forEach(file => {
const reader = new FileReader();
reader.onload = e => {
repairPhotoBases.push(e.target.result);
const img = document.createElement('img');
img.src = e.target.result;
img.className = 'repair-photo-preview';
previewContainer.appendChild(img);
};
reader.readAsDataURL(file);
});
}
async function submitRepairRequest() {
const itemId = document.getElementById('repairItemSelect').value;
const description = document.getElementById('repairDescription').value;
if (!description) {
alert('수리 내용을 입력하세요.');
return;
}
try {
const response = await axios.post(`/equipments/${equipmentId}/repair-request`, {
item_id: itemId || null,
description: description,
photo_base64_list: repairPhotoBases,
workplace_id: currentEquipment.workplace_id
});
if (response.data.success) {
closeRepairModal();
loadEquipmentData();
loadRepairHistory();
alert('수리 신청이 접수되었습니다.');
}
} catch (error) {
console.error('수리 신청 실패:', error);
alert('수리 신청에 실패했습니다.');
}
}
async function loadRepairHistory() {
try {
const response = await axios.get(`/equipments/${equipmentId}/repair-history`);
if (response.data.success) {
renderRepairHistory(response.data.data);
}
} catch (error) {
console.error('수리 이력 로드 실패:', error);
}
}
function renderRepairHistory(history) {
const container = document.getElementById('repairHistory');
if (!history || history.length === 0) {
container.innerHTML = '<div class="eq-history-empty">수리 이력이 없습니다</div>';
return;
}
const validStatuses = ['pending', 'in_progress', 'completed', 'closed'];
container.innerHTML = history.map(h => {
const safeStatus = validStatuses.includes(h.status) ? h.status : 'pending';
return `
<div class="eq-history-item">
<span class="eq-history-date">${formatDate(h.created_at)}</span>
<div class="eq-history-content">
<div class="eq-history-title">${escapeHtml(h.item_name || '수리 요청')}</div>
<div class="eq-history-detail">${escapeHtml(h.description || '-')}</div>
</div>
<span class="eq-history-status ${safeStatus}">${getRepairStatusLabel(h.status)}</span>
</div>
`;
}).join('');
}
function getRepairStatusLabel(status) {
const labels = {
pending: '대기중',
in_progress: '처리중',
completed: '완료',
closed: '종료'
};
return labels[status] || status;
}
// ==========================================
// 외부 반출
// ==========================================
function openExportModal() {
document.getElementById('exportDate').value = new Date().toISOString().slice(0, 10);
document.getElementById('expectedReturnDate').value = '';
document.getElementById('exportDestination').value = '';
document.getElementById('exportReason').value = '';
document.getElementById('exportNotes').value = '';
document.getElementById('isRepairExport').checked = false;
document.getElementById('exportModal').style.display = 'flex';
}
function closeExportModal() {
document.getElementById('exportModal').style.display = 'none';
}
function toggleRepairFields() {
// 현재는 특별한 필드 차이 없음
}
async function submitExport() {
const exportDate = document.getElementById('exportDate').value;
const expectedReturnDate = document.getElementById('expectedReturnDate').value;
const destination = document.getElementById('exportDestination').value;
const reason = document.getElementById('exportReason').value;
const notes = document.getElementById('exportNotes').value;
const isRepair = document.getElementById('isRepairExport').checked;
if (!exportDate) {
alert('반출일을 입력하세요.');
return;
}
try {
const response = await axios.post(`/equipments/${equipmentId}/export`, {
export_date: exportDate,
expected_return_date: expectedReturnDate || null,
destination: destination,
reason: reason,
notes: notes,
is_repair: isRepair
});
if (response.data.success) {
closeExportModal();
loadEquipmentData();
loadExternalLogs();
alert('외부 반출이 등록되었습니다.');
}
} catch (error) {
console.error('반출 등록 실패:', error);
alert('반출 등록에 실패했습니다.');
}
}
async function loadExternalLogs() {
try {
const response = await axios.get(`/equipments/${equipmentId}/external-logs`);
if (response.data.success) {
renderExternalLogs(response.data.data);
}
} catch (error) {
console.error('외부반출 이력 로드 실패:', error);
}
}
function renderExternalLogs(logs) {
const container = document.getElementById('externalHistory');
if (!logs || logs.length === 0) {
container.innerHTML = '<div class="eq-history-empty">외부반출 이력이 없습니다</div>';
return;
}
container.innerHTML = logs.map(log => {
const dateRange = log.actual_return_date
? `${formatDate(log.export_date)} ~ ${formatDate(log.actual_return_date)}`
: `${formatDate(log.export_date)} ~ (미반입)`;
const isReturned = !!log.actual_return_date;
const statusClass = isReturned ? 'returned' : 'exported';
const statusLabel = isReturned ? '반입완료' : '반출중';
const safeLogId = parseInt(log.log_id) || 0;
return `
<div class="eq-history-item">
<span class="eq-history-date">${dateRange}</span>
<div class="eq-history-content">
<div class="eq-history-title">${escapeHtml(log.destination || '외부')}</div>
<div class="eq-history-detail">${escapeHtml(log.reason || '-')}</div>
</div>
<span class="eq-history-status ${statusClass}">${statusLabel}</span>
${!isReturned ? `<button class="eq-history-action" onclick="openReturnModal(${safeLogId})">반입처리</button>` : ''}
</div>
`;
}).join('');
}
function openReturnModal(logId) {
document.getElementById('returnLogId').value = logId;
document.getElementById('returnDate').value = new Date().toISOString().slice(0, 10);
document.getElementById('returnStatus').value = 'active';
document.getElementById('returnNotes').value = '';
document.getElementById('returnModal').style.display = 'flex';
}
function closeReturnModal() {
document.getElementById('returnModal').style.display = 'none';
}
async function submitReturn() {
const logId = document.getElementById('returnLogId').value;
const returnDate = document.getElementById('returnDate').value;
const newStatus = document.getElementById('returnStatus').value;
const notes = document.getElementById('returnNotes').value;
if (!returnDate) {
alert('반입일을 입력하세요.');
return;
}
try {
const response = await axios.post(`/equipments/external-logs/${logId}/return`, {
return_date: returnDate,
new_status: newStatus,
notes: notes
});
if (response.data.success) {
closeReturnModal();
loadEquipmentData();
loadExternalLogs();
alert('반입 처리가 완료되었습니다.');
}
} catch (error) {
console.error('반입 처리 실패:', error);
alert('반입 처리에 실패했습니다.');
}
}
// ==========================================
// 이동 이력
// ==========================================
async function loadMoveLogs() {
try {
const response = await axios.get(`/equipments/${equipmentId}/move-logs`);
if (response.data.success) {
renderMoveLogs(response.data.data);
}
} catch (error) {
console.error('이동 이력 로드 실패:', error);
}
}
function renderMoveLogs(logs) {
const container = document.getElementById('moveHistory');
if (!logs || logs.length === 0) {
container.innerHTML = '<div class="eq-history-empty">이동 이력이 없습니다</div>';
return;
}
container.innerHTML = logs.map(log => {
const typeLabel = log.move_type === 'temporary' ? '임시이동' : '복귀';
const location = log.move_type === 'temporary'
? escapeHtml(log.to_workplace_name || '-')
: '원위치 복귀';
return `
<div class="eq-history-item">
<span class="eq-history-date">${formatDateTime(log.moved_at)}</span>
<div class="eq-history-content">
<div class="eq-history-title">${typeLabel}: ${location}</div>
<div class="eq-history-detail">${escapeHtml(log.reason || '-')} (${escapeHtml(log.moved_by_name || '시스템')})</div>
</div>
</div>
`;
}).join('');
}
// ==========================================
// 유틸리티
// ==========================================
function formatDate(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).replace(/\. /g, '-').replace('.', '');
}
function formatDateTime(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}

View File

@@ -0,0 +1,469 @@
// equipment-management.js
// 설비 관리 페이지 JavaScript
let equipments = [];
let allEquipments = []; // 필터링 전 전체 데이터
let workplaces = [];
let equipmentTypes = [];
let currentEquipment = null;
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', async () => {
await waitForAxiosConfig();
await loadInitialData();
});
// axios 설정 대기 함수
function waitForAxiosConfig() {
return new Promise((resolve) => {
const check = setInterval(() => {
if (axios.defaults.baseURL) {
clearInterval(check);
resolve();
}
}, 50);
setTimeout(() => {
clearInterval(check);
if (!axios.defaults.baseURL) {
console.error('Axios 설정 시간 초과');
}
resolve();
}, 5000);
});
}
// 초기 데이터 로드
async function loadInitialData() {
try {
await Promise.all([
loadEquipments(),
loadWorkplaces(),
loadEquipmentTypes()
]);
} catch (error) {
console.error('초기 데이터 로드 실패:', error);
alert('데이터를 불러오는데 실패했습니다.');
}
}
// 설비 목록 로드
async function loadEquipments() {
try {
const response = await axios.get('/equipments');
if (response.data.success) {
allEquipments = response.data.data;
equipments = [...allEquipments];
renderStats();
renderEquipmentList();
}
} catch (error) {
console.error('설비 목록 로드 실패:', error);
throw error;
}
}
// 작업장 목록 로드
async function loadWorkplaces() {
try {
const response = await axios.get('/workplaces');
if (response.data.success) {
workplaces = response.data.data;
populateWorkplaceFilters();
}
} catch (error) {
console.error('작업장 목록 로드 실패:', error);
}
}
// 설비 유형 목록 로드
async function loadEquipmentTypes() {
try {
const response = await axios.get('/equipments/types');
if (response.data.success) {
equipmentTypes = response.data.data;
populateTypeFilter();
}
} catch (error) {
console.error('설비 유형 로드 실패:', error);
}
}
// 통계 렌더링
function renderStats() {
const container = document.getElementById('statsSection');
if (!container) return;
const totalCount = allEquipments.length;
const activeCount = allEquipments.filter(e => e.status === 'active').length;
const maintenanceCount = allEquipments.filter(e => e.status === 'maintenance').length;
const inactiveCount = allEquipments.filter(e => e.status === 'inactive').length;
const totalValue = allEquipments.reduce((sum, e) => sum + (Number(e.purchase_price) || 0), 0);
const avgValue = totalCount > 0 ? totalValue / totalCount : 0;
container.innerHTML = `
<div class="eq-stat-card highlight">
<div class="eq-stat-label">전체 설비</div>
<div class="eq-stat-value">${totalCount}대</div>
<div class="eq-stat-sub">총 자산가치 ${formatPriceShort(totalValue)}</div>
</div>
<div class="eq-stat-card">
<div class="eq-stat-label">활성</div>
<div class="eq-stat-value" style="color: #16a34a;">${activeCount}대</div>
<div class="eq-stat-sub">${totalCount > 0 ? Math.round(activeCount / totalCount * 100) : 0}%</div>
</div>
<div class="eq-stat-card">
<div class="eq-stat-label">정비중</div>
<div class="eq-stat-value" style="color: #d97706;">${maintenanceCount}대</div>
<div class="eq-stat-sub">${totalCount > 0 ? Math.round(maintenanceCount / totalCount * 100) : 0}%</div>
</div>
<div class="eq-stat-card">
<div class="eq-stat-label">비활성</div>
<div class="eq-stat-value" style="color: #dc2626;">${inactiveCount}대</div>
<div class="eq-stat-sub">${totalCount > 0 ? Math.round(inactiveCount / totalCount * 100) : 0}%</div>
</div>
<div class="eq-stat-card">
<div class="eq-stat-label">평균 구입가</div>
<div class="eq-stat-value">${formatPriceShort(avgValue)}</div>
<div class="eq-stat-sub">설비당 평균</div>
</div>
`;
}
// 작업장 필터 채우기
function populateWorkplaceFilters() {
const filterWorkplace = document.getElementById('filterWorkplace');
const modalWorkplace = document.getElementById('workplaceId');
const workplaceOptions = workplaces.map(w => {
const safeId = parseInt(w.workplace_id) || 0;
const categoryName = escapeHtml(w.category_name || '');
const workplaceName = escapeHtml(w.workplace_name || '');
const label = categoryName ? categoryName + ' - ' + workplaceName : workplaceName;
return `<option value="${safeId}">${label}</option>`;
}).join('');
if (filterWorkplace) filterWorkplace.innerHTML = '<option value="">전체</option>' + workplaceOptions;
if (modalWorkplace) modalWorkplace.innerHTML = '<option value="">선택 안함</option>' + workplaceOptions;
}
// 설비 유형 필터 채우기
function populateTypeFilter() {
const filterType = document.getElementById('filterType');
if (!filterType) return;
const typeOptions = equipmentTypes.map(type => {
const safeType = escapeHtml(type || '');
return `<option value="${safeType}">${safeType}</option>`;
}).join('');
filterType.innerHTML = '<option value="">전체</option>' + typeOptions;
}
// 설비 목록 렌더링
function renderEquipmentList() {
const container = document.getElementById('equipmentList');
if (equipments.length === 0) {
container.innerHTML = `
<div class="eq-empty-state">
<p>등록된 설비가 없습니다.</p>
<button class="btn btn-primary" onclick="openEquipmentModal()">설비 추가하기</button>
</div>
`;
return;
}
const tableHTML = `
<div class="eq-result-count">
<span>검색 결과 <strong>${equipments.length}건</strong></span>
</div>
<div class="eq-table-wrapper">
<table class="eq-table">
<thead>
<tr>
<th>관리번호</th>
<th>설비명</th>
<th>모델명</th>
<th>규격</th>
<th>제조사</th>
<th>구입처</th>
<th style="text-align:right">구입가격</th>
<th>구입일자</th>
<th>상태</th>
<th style="width:80px">관리</th>
</tr>
</thead>
<tbody>
${equipments.map(eq => {
const safeId = parseInt(eq.equipment_id) || 0;
const safeCode = escapeHtml(eq.equipment_code || '-');
const safeName = escapeHtml(eq.equipment_name || '-');
const safeModel = escapeHtml(eq.model_name || '-');
const safeSpec = escapeHtml(eq.specifications || '-');
const safeManufacturer = escapeHtml(eq.manufacturer || '-');
const safeSupplier = escapeHtml(eq.supplier || '-');
const validStatuses = ['active', 'maintenance', 'inactive'];
const safeStatus = validStatuses.includes(eq.status) ? eq.status : 'inactive';
return `
<tr>
<td class="eq-col-code">${safeCode}</td>
<td class="eq-col-name" title="${safeName}">${safeName}</td>
<td class="eq-col-model" title="${safeModel}">${safeModel}</td>
<td class="eq-col-spec" title="${safeSpec}">${safeSpec}</td>
<td>${safeManufacturer}</td>
<td>${safeSupplier}</td>
<td class="eq-col-price">${eq.purchase_price ? formatPrice(eq.purchase_price) : '-'}</td>
<td class="eq-col-date">${eq.installation_date ? formatDate(eq.installation_date) : '-'}</td>
<td>
<span class="eq-status eq-status-${safeStatus}">
${getStatusText(eq.status)}
</span>
</td>
<td>
<div class="eq-actions">
<button class="eq-btn-action eq-btn-edit" onclick="editEquipment(${safeId})" title="수정">
✏️
</button>
<button class="eq-btn-action eq-btn-delete" onclick="deleteEquipment(${safeId})" title="삭제">
🗑️
</button>
</div>
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
</div>
`;
container.innerHTML = tableHTML;
}
// 상태 텍스트 변환
function getStatusText(status) {
const statusMap = {
'active': '활성',
'maintenance': '정비중',
'inactive': '비활성'
};
return statusMap[status] || status || '-';
}
// 가격 포맷팅 (전체)
function formatPrice(price) {
if (!price) return '-';
return Number(price).toLocaleString('ko-KR') + '원';
}
// 가격 포맷팅 (축약)
function formatPriceShort(price) {
if (!price) return '0원';
const num = Number(price);
if (num >= 100000000) {
return (num / 100000000).toFixed(1).replace(/\.0$/, '') + '억원';
} else if (num >= 10000) {
return (num / 10000).toFixed(0) + '만원';
}
return num.toLocaleString('ko-KR') + '원';
}
// 날짜 포맷팅
function formatDate(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit' });
}
// 필터링
function filterEquipments() {
const workplaceFilter = document.getElementById('filterWorkplace').value;
const typeFilter = document.getElementById('filterType').value;
const statusFilter = document.getElementById('filterStatus').value;
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
equipments = allEquipments.filter(e => {
if (workplaceFilter && e.workplace_id != workplaceFilter) return false;
if (typeFilter && e.equipment_type !== typeFilter) return false;
if (statusFilter && e.status !== statusFilter) return false;
if (searchTerm) {
const searchFields = [
e.equipment_name,
e.equipment_code,
e.manufacturer,
e.supplier,
e.model_name
].map(f => (f || '').toLowerCase());
if (!searchFields.some(f => f.includes(searchTerm))) return false;
}
return true;
});
renderEquipmentList();
}
// 설비 추가 모달 열기
async function openEquipmentModal(equipmentId = null) {
currentEquipment = equipmentId;
const modal = document.getElementById('equipmentModal');
const modalTitle = document.getElementById('modalTitle');
const form = document.getElementById('equipmentForm');
form.reset();
document.getElementById('equipmentId').value = '';
if (equipmentId) {
modalTitle.textContent = '설비 수정';
loadEquipmentData(equipmentId);
} else {
modalTitle.textContent = '설비 추가';
// 새 설비일 경우 다음 관리번호 자동 생성
await loadNextEquipmentCode();
}
modal.style.display = 'flex';
}
// 다음 관리번호 로드
async function loadNextEquipmentCode() {
try {
console.log('📋 다음 관리번호 조회 중...');
const response = await axios.get('/equipments/next-code');
console.log('📋 다음 관리번호 응답:', response.data);
if (response.data.success) {
document.getElementById('equipmentCode').value = response.data.data.next_code;
console.log('✅ 다음 관리번호 설정:', response.data.data.next_code);
}
} catch (error) {
console.error('❌ 다음 관리번호 조회 실패:', error);
console.error('❌ 에러 상세:', error.response?.data || error.message);
// 오류 시 기본값으로 빈 값 유지 (사용자가 직접 입력)
}
}
// 설비 데이터 로드 (수정용)
async function loadEquipmentData(equipmentId) {
try {
const response = await axios.get(`/equipments/${equipmentId}`);
if (response.data.success) {
const eq = response.data.data;
document.getElementById('equipmentId').value = eq.equipment_id;
document.getElementById('equipmentCode').value = eq.equipment_code || '';
document.getElementById('equipmentName').value = eq.equipment_name || '';
document.getElementById('equipmentType').value = eq.equipment_type || '';
document.getElementById('workplaceId').value = eq.workplace_id || '';
document.getElementById('manufacturer').value = eq.manufacturer || '';
document.getElementById('supplier').value = eq.supplier || '';
document.getElementById('purchasePrice').value = eq.purchase_price || '';
document.getElementById('modelName').value = eq.model_name || '';
document.getElementById('serialNumber').value = eq.serial_number || '';
document.getElementById('installationDate').value = eq.installation_date ? eq.installation_date.split('T')[0] : '';
document.getElementById('equipmentStatus').value = eq.status || 'active';
document.getElementById('specifications').value = eq.specifications || '';
document.getElementById('notes').value = eq.notes || '';
}
} catch (error) {
console.error('설비 데이터 로드 실패:', error);
alert('설비 정보를 불러오는데 실패했습니다.');
}
}
// 설비 모달 닫기
function closeEquipmentModal() {
document.getElementById('equipmentModal').style.display = 'none';
currentEquipment = null;
}
// 설비 저장
async function saveEquipment() {
const equipmentId = document.getElementById('equipmentId').value;
const equipmentData = {
equipment_code: document.getElementById('equipmentCode').value.trim(),
equipment_name: document.getElementById('equipmentName').value.trim(),
equipment_type: document.getElementById('equipmentType').value.trim() || null,
workplace_id: document.getElementById('workplaceId').value || null,
manufacturer: document.getElementById('manufacturer').value.trim() || null,
supplier: document.getElementById('supplier').value.trim() || null,
purchase_price: document.getElementById('purchasePrice').value || null,
model_name: document.getElementById('modelName').value.trim() || null,
serial_number: document.getElementById('serialNumber').value.trim() || null,
installation_date: document.getElementById('installationDate').value || null,
status: document.getElementById('equipmentStatus').value,
specifications: document.getElementById('specifications').value.trim() || null,
notes: document.getElementById('notes').value.trim() || null
};
if (!equipmentData.equipment_code) {
alert('관리번호를 입력해주세요.');
return;
}
if (!equipmentData.equipment_name) {
alert('설비명을 입력해주세요.');
return;
}
try {
let response;
if (equipmentId) {
response = await axios.put(`/equipments/${equipmentId}`, equipmentData);
} else {
response = await axios.post('/equipments', equipmentData);
}
if (response.data.success) {
alert(equipmentId ? '설비가 수정되었습니다.' : '설비가 추가되었습니다.');
closeEquipmentModal();
await loadEquipments();
await loadEquipmentTypes();
}
} catch (error) {
console.error('설비 저장 실패:', error);
if (error.response?.data?.message) {
alert(error.response.data.message);
} else {
alert('설비 저장 중 오류가 발생했습니다.');
}
}
}
// 설비 수정
function editEquipment(equipmentId) {
openEquipmentModal(equipmentId);
}
// 설비 삭제
async function deleteEquipment(equipmentId) {
const equipment = allEquipments.find(e => e.equipment_id === equipmentId);
if (!equipment) return;
if (!confirm(`'${equipment.equipment_name}' 설비를 삭제하시겠습니까?`)) {
return;
}
try {
const response = await axios.delete(`/equipments/${equipmentId}`);
if (response.data.success) {
alert('설비가 삭제되었습니다.');
await loadEquipments();
}
} catch (error) {
console.error('설비 삭제 실패:', error);
alert('설비 삭제 중 오류가 발생했습니다.');
}
}
// ESC 키로 모달 닫기
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeEquipmentModal();
}
});
// 모달 외부 클릭 시 닫기
document.getElementById('equipmentModal')?.addEventListener('click', (e) => {
if (e.target.id === 'equipmentModal') {
closeEquipmentModal();
}
});

View 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);
}
}
});
}

View 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>';
}
}
})();

View File

@@ -0,0 +1,157 @@
// /js/group-leader-dashboard.js
// 그룹장 전용 대시보드 - 실시간 근태 및 작업 현황 (Real Data Version)
import { apiCall } from './api-config.js';
console.log('📊 그룹장 대시보드 스크립트 로딩 (Live Data)');
// 상태별 스타일/텍스트 매핑
const STATUS_MAP = {
'incomplete': { text: '미제출', class: 'status-incomplete', icon: '❌', color: '#ff5252' },
'partial': { text: '작성중', class: 'status-warning', icon: '📝', color: '#ff9800' },
'complete': { text: '제출완료', class: 'status-success', icon: '✅', color: '#4caf50' },
'overtime': { text: '초과근무', class: 'status-info', icon: '🌙', color: '#673ab7' },
'vacation': { text: '휴가', class: 'status-vacation', icon: '🏖️', color: '#2196f3' }
};
// 현재 선택된 날짜
let currentSelectedDate = new Date().toISOString().split('T')[0];
/**
* 📅 날짜 초기화 및 이벤트 리스너 등록
*/
function initDateSelector() {
const dateInput = document.getElementById('selectedDate');
const refreshBtn = document.getElementById('refreshBtn');
if (dateInput) {
dateInput.value = currentSelectedDate;
dateInput.addEventListener('change', (e) => {
currentSelectedDate = e.target.value;
loadDailyWorkStatus();
});
}
if (refreshBtn) {
refreshBtn.addEventListener('click', () => {
loadDailyWorkStatus();
showToast('데이터를 새로고침했습니다.', 'success');
});
}
}
/**
* 🔄 일일 근태 현황 로드 (API 호출)
*/
async function loadDailyWorkStatus() {
const container = document.getElementById('workStatusContainer');
if (!container) return;
// 로딩 표시
container.innerHTML = `
<div class="loading-state">
<div class="spinner"></div>
<p>작업 현황을 불러오는 중...</p>
</div>
`;
try {
const result = await apiCall(`/attendance/daily-status?date=${currentSelectedDate}`);
const workers = result.data || [];
renderWorkStatus(workers);
updateSummaryStats(workers);
} catch (error) {
console.error('현황 로드 오류:', error);
container.innerHTML = `
<div class="error-state">
<p>⚠️ 데이터를 불러오는데 실패했습니다.</p>
<button onclick="loadDailyWorkStatus()" class="btn btn-sm btn-outline">재시도</button>
</div>
`;
}
}
/**
* 📊 통계 요약 업데이트
*/
function updateSummaryStats(workers) {
// 요약 카드가 있다면 업데이트 (현재 HTML에는 없으므로 생략 가능하거나 동적으로 추가)
// 여기서는 콘솔에만 로그
const stats = workers.reduce((acc, w) => {
acc[w.status] = (acc[w.status] || 0) + 1;
return acc;
}, {});
console.log('Daily Stats:', stats);
}
/**
* 🎨 현황 리스트 렌더링
*/
function renderWorkStatus(workers) {
const container = document.getElementById('workStatusContainer');
if (!container) return;
if (workers.length === 0) {
container.innerHTML = '<div class="empty-state">등록된 작업자가 없습니다.</div>';
return;
}
// 상태 우선순위 정렬 (미제출 -> 작성중 -> 완료)
const sortOrder = ['incomplete', 'partial', 'vacation', 'complete', 'overtime'];
workers.sort((a, b) => {
return sortOrder.indexOf(a.status) - sortOrder.indexOf(b.status) || a.worker_name.localeCompare(b.worker_name);
});
const html = `
<div class="status-grid">
${workers.map(worker => {
const statusInfo = STATUS_MAP[worker.status] || { text: worker.status, class: '', icon: '❓', color: '#999' };
return `
<div class="worker-card ${worker.status === 'incomplete' ? 'status-alert' : ''}" style="border-left: 4px solid ${statusInfo.color}">
<div class="worker-header">
<span class="worker-name">${worker.worker_name}</span>
<span class="worker-job">${worker.job_type || '-'}</span>
</div>
<div class="worker-body">
<div class="status-badge" style="background-color: ${statusInfo.color}20; color: ${statusInfo.color}">
${statusInfo.icon} ${statusInfo.text}
</div>
<div class="work-hours">
${worker.total_work_hours > 0 ? worker.total_work_hours + '시간' : '-'}
</div>
</div>
${worker.status === 'incomplete' ? `
<div class="worker-footer">
<span class="alert-text">⚠️ 보고서 미제출</span>
</div>
` : ''}
</div>
`;
}).join('')}
</div>
`;
container.innerHTML = html;
}
// 🍞 토스트 메시지 (기존 modern-dashboard.js에 있다면 중복 주의, 없으면 사용)
function showToast(message, type = 'info') {
if (window.showToast) {
window.showToast(message, type);
} else {
alert(message);
}
}
// 초기화
document.addEventListener('DOMContentLoaded', () => {
initDateSelector();
loadDailyWorkStatus();
});
// 전역 노출 대신 모듈로 내보내기
export { loadDailyWorkStatus as refreshTeamStatus };

View File

@@ -0,0 +1,421 @@
/**
* 신고 카테고리 관리 JavaScript
*/
import { API, getAuthHeaders } from '/js/api-config.js';
let currentType = 'nonconformity';
let categories = [];
let items = [];
// 초기화
document.addEventListener('DOMContentLoaded', async () => {
await loadCategories();
});
/**
* 유형 탭 전환
*/
window.switchType = async function(type) {
currentType = type;
// 탭 상태 업데이트
document.querySelectorAll('.type-tab').forEach(tab => {
tab.classList.toggle('active', tab.dataset.type === type);
});
await loadCategories();
};
/**
* 카테고리 로드
*/
async function loadCategories() {
const container = document.getElementById('categoryList');
container.innerHTML = '<div class="empty-state">카테고리를 불러오는 중...</div>';
try {
const response = await fetch(`${API}/work-issues/categories/type/${currentType}`, {
headers: getAuthHeaders()
});
if (!response.ok) throw new Error('카테고리 조회 실패');
const data = await response.json();
if (data.success && data.data) {
categories = data.data;
// 항목도 로드
const itemsResponse = await fetch(`${API}/work-issues/items`, {
headers: getAuthHeaders()
});
if (itemsResponse.ok) {
const itemsData = await itemsResponse.json();
if (itemsData.success) {
items = itemsData.data || [];
}
}
renderCategories();
} else {
container.innerHTML = '<div class="empty-state">카테고리가 없습니다.</div>';
}
} catch (error) {
console.error('카테고리 로드 실패:', error);
container.innerHTML = '<div class="empty-state">카테고리를 불러오지 못했습니다.</div>';
}
}
/**
* 카테고리 렌더링
*/
function renderCategories() {
const container = document.getElementById('categoryList');
if (categories.length === 0) {
container.innerHTML = '<div class="empty-state">등록된 카테고리가 없습니다.</div>';
return;
}
const severityLabel = {
low: '낮음',
medium: '보통',
high: '높음',
critical: '심각'
};
container.innerHTML = categories.map(cat => {
const catItems = items.filter(item => item.category_id === cat.category_id);
return `
<div class="category-section" data-category-id="${cat.category_id}">
<div class="category-header" onclick="toggleCategory(${cat.category_id})">
<div class="category-name">${cat.category_name}</div>
<div class="category-badge">
<span class="severity-badge ${cat.severity || 'medium'}">${severityLabel[cat.severity] || '보통'}</span>
<span class="item-count">${catItems.length}개 항목</span>
<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); openCategoryModal(${cat.category_id})">수정</button>
</div>
</div>
<div class="category-items">
<div class="item-list">
${catItems.length > 0 ? catItems.map(item => `
<div class="item-card">
<div class="item-info">
<div class="item-name">${item.item_name}</div>
${item.description ? `<div class="item-desc">${item.description}</div>` : ''}
</div>
<div class="item-actions">
<span class="severity-badge ${item.severity || 'medium'}">${severityLabel[item.severity] || '보통'}</span>
<button class="btn btn-secondary btn-sm" onclick="openItemModal(${cat.category_id}, ${item.item_id})">수정</button>
</div>
</div>
`).join('') : '<div class="empty-state" style="padding: 24px;">등록된 항목이 없습니다.</div>'}
</div>
<div class="add-item-form">
<input type="text" id="newItemName_${cat.category_id}" placeholder="새 항목 이름">
<button class="btn btn-primary btn-sm" onclick="quickAddItem(${cat.category_id})">추가</button>
<button class="btn btn-secondary btn-sm" onclick="openItemModal(${cat.category_id})">상세 추가</button>
</div>
</div>
</div>
`;
}).join('');
}
/**
* 카테고리 토글
*/
window.toggleCategory = function(categoryId) {
const section = document.querySelector(`.category-section[data-category-id="${categoryId}"]`);
if (section) {
section.classList.toggle('expanded');
}
};
/**
* 카테고리 모달 열기
*/
window.openCategoryModal = function(categoryId = null) {
const modal = document.getElementById('categoryModal');
const title = document.getElementById('categoryModalTitle');
const deleteBtn = document.getElementById('deleteCategoryBtn');
document.getElementById('categoryId').value = '';
document.getElementById('categoryName').value = '';
document.getElementById('categoryDescription').value = '';
document.getElementById('categorySeverity').value = 'medium';
if (categoryId) {
const category = categories.find(c => c.category_id === categoryId);
if (category) {
title.textContent = '카테고리 수정';
document.getElementById('categoryId').value = category.category_id;
document.getElementById('categoryName').value = category.category_name;
document.getElementById('categoryDescription').value = category.description || '';
document.getElementById('categorySeverity').value = category.severity || 'medium';
deleteBtn.style.display = 'block';
}
} else {
title.textContent = '새 카테고리';
deleteBtn.style.display = 'none';
}
modal.style.display = 'flex';
};
/**
* 카테고리 모달 닫기
*/
window.closeCategoryModal = function() {
document.getElementById('categoryModal').style.display = 'none';
};
/**
* 카테고리 저장
*/
window.saveCategory = async function() {
const categoryId = document.getElementById('categoryId').value;
const name = document.getElementById('categoryName').value.trim();
const description = document.getElementById('categoryDescription').value.trim();
const severity = document.getElementById('categorySeverity').value;
if (!name) {
alert('카테고리 이름을 입력하세요.');
return;
}
try {
const url = categoryId
? `${API}/work-issues/categories/${categoryId}`
: `${API}/work-issues/categories`;
const response = await fetch(url, {
method: categoryId ? 'PUT' : 'POST',
headers: {
...getAuthHeaders(),
'Content-Type': 'application/json'
},
body: JSON.stringify({
category_name: name,
category_type: currentType,
description,
severity
})
});
const data = await response.json();
if (response.ok && data.success) {
alert(categoryId ? '카테고리가 수정되었습니다.' : '카테고리가 추가되었습니다.');
closeCategoryModal();
await loadCategories();
} else {
throw new Error(data.error || '저장 실패');
}
} catch (error) {
console.error('카테고리 저장 실패:', error);
alert('카테고리 저장에 실패했습니다: ' + error.message);
}
};
/**
* 카테고리 삭제
*/
window.deleteCategory = async function() {
const categoryId = document.getElementById('categoryId').value;
if (!categoryId) return;
const catItems = items.filter(item => item.category_id == categoryId);
if (catItems.length > 0) {
alert(`이 카테고리에 ${catItems.length}개의 항목이 있습니다. 먼저 항목을 삭제하세요.`);
return;
}
if (!confirm('이 카테고리를 삭제하시겠습니까?')) return;
try {
const response = await fetch(`${API}/work-issues/categories/${categoryId}`, {
method: 'DELETE',
headers: getAuthHeaders()
});
const data = await response.json();
if (response.ok && data.success) {
alert('카테고리가 삭제되었습니다.');
closeCategoryModal();
await loadCategories();
} else {
throw new Error(data.error || '삭제 실패');
}
} catch (error) {
console.error('카테고리 삭제 실패:', error);
alert('카테고리 삭제에 실패했습니다: ' + error.message);
}
};
/**
* 항목 모달 열기
*/
window.openItemModal = function(categoryId, itemId = null) {
const modal = document.getElementById('itemModal');
const title = document.getElementById('itemModalTitle');
const deleteBtn = document.getElementById('deleteItemBtn');
document.getElementById('itemId').value = '';
document.getElementById('itemCategoryId').value = categoryId;
document.getElementById('itemName').value = '';
document.getElementById('itemDescription').value = '';
document.getElementById('itemSeverity').value = 'medium';
if (itemId) {
const item = items.find(i => i.item_id === itemId);
if (item) {
title.textContent = '항목 수정';
document.getElementById('itemId').value = item.item_id;
document.getElementById('itemName').value = item.item_name;
document.getElementById('itemDescription').value = item.description || '';
document.getElementById('itemSeverity').value = item.severity || 'medium';
deleteBtn.style.display = 'block';
}
} else {
const category = categories.find(c => c.category_id === categoryId);
title.textContent = `새 항목 (${category?.category_name || ''})`;
deleteBtn.style.display = 'none';
}
modal.style.display = 'flex';
};
/**
* 항목 모달 닫기
*/
window.closeItemModal = function() {
document.getElementById('itemModal').style.display = 'none';
};
/**
* 항목 저장
*/
window.saveItem = async function() {
const itemId = document.getElementById('itemId').value;
const categoryId = document.getElementById('itemCategoryId').value;
const name = document.getElementById('itemName').value.trim();
const description = document.getElementById('itemDescription').value.trim();
const severity = document.getElementById('itemSeverity').value;
if (!name) {
alert('항목 이름을 입력하세요.');
return;
}
try {
const url = itemId
? `${API}/work-issues/items/${itemId}`
: `${API}/work-issues/items`;
const response = await fetch(url, {
method: itemId ? 'PUT' : 'POST',
headers: {
...getAuthHeaders(),
'Content-Type': 'application/json'
},
body: JSON.stringify({
category_id: categoryId,
item_name: name,
description,
severity
})
});
const data = await response.json();
if (response.ok && data.success) {
alert(itemId ? '항목이 수정되었습니다.' : '항목이 추가되었습니다.');
closeItemModal();
await loadCategories();
} else {
throw new Error(data.error || '저장 실패');
}
} catch (error) {
console.error('항목 저장 실패:', error);
alert('항목 저장에 실패했습니다: ' + error.message);
}
};
/**
* 항목 삭제
*/
window.deleteItem = async function() {
const itemId = document.getElementById('itemId').value;
if (!itemId) return;
if (!confirm('이 항목을 삭제하시겠습니까?')) return;
try {
const response = await fetch(`${API}/work-issues/items/${itemId}`, {
method: 'DELETE',
headers: getAuthHeaders()
});
const data = await response.json();
if (response.ok && data.success) {
alert('항목이 삭제되었습니다.');
closeItemModal();
await loadCategories();
} else {
throw new Error(data.error || '삭제 실패');
}
} catch (error) {
console.error('항목 삭제 실패:', error);
alert('항목 삭제에 실패했습니다: ' + error.message);
}
};
/**
* 빠른 항목 추가
*/
window.quickAddItem = async function(categoryId) {
const input = document.getElementById(`newItemName_${categoryId}`);
const name = input.value.trim();
if (!name) {
alert('항목 이름을 입력하세요.');
input.focus();
return;
}
try {
const response = await fetch(`${API}/work-issues/items`, {
method: 'POST',
headers: {
...getAuthHeaders(),
'Content-Type': 'application/json'
},
body: JSON.stringify({
category_id: categoryId,
item_name: name,
severity: 'medium'
})
});
const data = await response.json();
if (response.ok && data.success) {
input.value = '';
await loadCategories();
// 카테고리 펼침 유지
toggleCategory(categoryId);
} else {
throw new Error(data.error || '추가 실패');
}
} catch (error) {
console.error('항목 추가 실패:', error);
alert('항목 추가에 실패했습니다: ' + error.message);
}
};

View File

@@ -0,0 +1,690 @@
/**
* 신고 상세 페이지 JavaScript
*/
const API_BASE = window.API_BASE_URL || 'http://localhost:20005/api';
let reportId = null;
let reportData = null;
let currentUser = null;
// 상태 한글명
const statusNames = {
reported: '신고',
received: '접수',
in_progress: '처리중',
completed: '완료',
closed: '종료'
};
// 유형 한글명
const typeNames = {
nonconformity: '부적합',
safety: '안전'
};
// 심각도 한글명
const severityNames = {
critical: '심각',
high: '높음',
medium: '보통',
low: '낮음'
};
// 초기화
document.addEventListener('DOMContentLoaded', async () => {
// URL에서 ID 가져오기
const urlParams = new URLSearchParams(window.location.search);
reportId = urlParams.get('id');
if (!reportId) {
alert('신고 ID가 없습니다.');
goBackToList();
return;
}
// 현재 사용자 정보 로드
await loadCurrentUser();
// 상세 데이터 로드
await loadReportDetail();
});
/**
* 현재 사용자 정보 로드
*/
async function loadCurrentUser() {
try {
const response = await fetch(`${API_BASE}/users/me`, {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
if (response.ok) {
const data = await response.json();
currentUser = data.data;
}
} catch (error) {
console.error('사용자 정보 로드 실패:', error);
}
}
/**
* 신고 상세 로드
*/
async function loadReportDetail() {
try {
const response = await fetch(`${API_BASE}/work-issues/${reportId}`, {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
if (!response.ok) {
throw new Error('신고를 찾을 수 없습니다.');
}
const data = await response.json();
if (!data.success) {
throw new Error(data.error || '데이터 조회 실패');
}
reportData = data.data;
renderDetail();
await loadStatusLogs();
} catch (error) {
console.error('상세 로드 실패:', error);
alert(error.message);
goBackToList();
}
}
/**
* 상세 정보 렌더링
*/
function renderDetail() {
const d = reportData;
// 헤더
document.getElementById('reportId').textContent = `#${d.report_id}`;
document.getElementById('reportTitle').textContent = d.issue_item_name || d.issue_category_name || '신고';
// 상태 배지
const statusBadge = document.getElementById('statusBadge');
statusBadge.className = `status-badge ${d.status}`;
statusBadge.textContent = statusNames[d.status] || d.status;
// 기본 정보
renderBasicInfo(d);
// 신고 내용
renderIssueContent(d);
// 사진
renderPhotos(d);
// 처리 정보
renderProcessInfo(d);
// 액션 버튼
renderActionButtons(d);
}
/**
* 기본 정보 렌더링
*/
function renderBasicInfo(d) {
const container = document.getElementById('basicInfo');
const formatDate = (dateStr) => {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
const validTypes = ['nonconformity', 'safety'];
const safeType = validTypes.includes(d.category_type) ? d.category_type : '';
const reporterName = escapeHtml(d.reporter_full_name || d.reporter_name || '-');
const locationText = escapeHtml(d.custom_location || d.workplace_name || '-');
const factoryText = d.factory_name ? ` (${escapeHtml(d.factory_name)})` : '';
container.innerHTML = `
<div class="info-item">
<div class="info-label">신고 유형</div>
<div class="info-value">
<span class="type-badge ${safeType}">${typeNames[d.category_type] || escapeHtml(d.category_type || '-')}</span>
</div>
</div>
<div class="info-item">
<div class="info-label">신고일시</div>
<div class="info-value">${formatDate(d.report_date)}</div>
</div>
<div class="info-item">
<div class="info-label">신고자</div>
<div class="info-value">${reporterName}</div>
</div>
<div class="info-item">
<div class="info-label">위치</div>
<div class="info-value">${locationText}${factoryText}</div>
</div>
`;
}
/**
* 신고 내용 렌더링
*/
function renderIssueContent(d) {
const container = document.getElementById('issueContent');
const validSeverities = ['critical', 'high', 'medium', 'low'];
const safeSeverity = validSeverities.includes(d.severity) ? d.severity : '';
let html = `
<div class="info-grid" style="margin-bottom: 1rem;">
<div class="info-item">
<div class="info-label">카테고리</div>
<div class="info-value">${escapeHtml(d.issue_category_name || '-')}</div>
</div>
<div class="info-item">
<div class="info-label">항목</div>
<div class="info-value">
${escapeHtml(d.issue_item_name || '-')}
${d.severity ? `<span class="severity-badge ${safeSeverity}">${severityNames[d.severity] || escapeHtml(d.severity)}</span>` : ''}
</div>
</div>
</div>
`;
if (d.additional_description) {
html += `
<div style="padding: 1rem; background: #f9fafb; border-radius: 0.5rem; white-space: pre-wrap; line-height: 1.6;">
${escapeHtml(d.additional_description)}
</div>
`;
}
container.innerHTML = html;
}
/**
* 사진 렌더링
*/
function renderPhotos(d) {
const section = document.getElementById('photoSection');
const gallery = document.getElementById('photoGallery');
const photos = [d.photo_path1, d.photo_path2, d.photo_path3, d.photo_path4, d.photo_path5].filter(Boolean);
if (photos.length === 0) {
section.style.display = 'none';
return;
}
section.style.display = 'block';
const baseUrl = (API_BASE).replace('/api', '');
gallery.innerHTML = photos.map(photo => {
const fullUrl = photo.startsWith('http') ? photo : `${baseUrl}${photo}`;
return `
<div class="photo-item" onclick="openPhotoModal('${fullUrl}')">
<img src="${fullUrl}" alt="첨부 사진">
</div>
`;
}).join('');
}
/**
* 처리 정보 렌더링
*/
function renderProcessInfo(d) {
const section = document.getElementById('processSection');
const container = document.getElementById('processInfo');
// 담당자 배정 또는 처리 정보가 있는 경우만 표시
if (!d.assigned_user_id && !d.resolution_notes) {
section.style.display = 'none';
return;
}
section.style.display = 'block';
const formatDate = (dateStr) => {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
let html = '<div class="info-grid">';
if (d.assigned_user_id) {
html += `
<div class="info-item">
<div class="info-label">담당자</div>
<div class="info-value">${escapeHtml(d.assigned_full_name || d.assigned_user_name || '-')}</div>
</div>
<div class="info-item">
<div class="info-label">담당 부서</div>
<div class="info-value">${escapeHtml(d.assigned_department || '-')}</div>
</div>
`;
}
if (d.resolved_at) {
html += `
<div class="info-item">
<div class="info-label">처리 완료일</div>
<div class="info-value">${formatDate(d.resolved_at)}</div>
</div>
<div class="info-item">
<div class="info-label">처리자</div>
<div class="info-value">${escapeHtml(d.resolved_by_name || '-')}</div>
</div>
`;
}
html += '</div>';
if (d.resolution_notes) {
html += `
<div style="margin-top: 1rem; padding: 1rem; background: #ecfdf5; border-radius: 0.5rem; border: 1px solid #a7f3d0;">
<div style="font-weight: 600; margin-bottom: 0.5rem; color: #047857;">처리 내용</div>
<div style="white-space: pre-wrap; line-height: 1.6;">${escapeHtml(d.resolution_notes)}</div>
</div>
`;
}
container.innerHTML = html;
}
/**
* 액션 버튼 렌더링
*/
function renderActionButtons(d) {
const container = document.getElementById('actionButtons');
if (!currentUser) {
container.innerHTML = '';
return;
}
const isAdmin = ['admin', 'system', 'support_team'].includes(currentUser.access_level);
const isOwner = d.reporter_id === currentUser.user_id;
const isAssignee = d.assigned_user_id === currentUser.user_id;
let buttons = [];
// 관리자 권한 버튼
if (isAdmin) {
if (d.status === 'reported') {
buttons.push(`<button class="action-btn primary" onclick="receiveReport()">접수하기</button>`);
}
if (d.status === 'received' || d.status === 'in_progress') {
buttons.push(`<button class="action-btn" onclick="openAssignModal()">담당자 배정</button>`);
}
if (d.status === 'received') {
buttons.push(`<button class="action-btn primary" onclick="startProcessing()">처리 시작</button>`);
}
if (d.status === 'in_progress') {
buttons.push(`<button class="action-btn success" onclick="openCompleteModal()">처리 완료</button>`);
}
if (d.status === 'completed') {
buttons.push(`<button class="action-btn" onclick="closeReport()">종료</button>`);
}
}
// 담당자 버튼
if (isAssignee && !isAdmin) {
if (d.status === 'received') {
buttons.push(`<button class="action-btn primary" onclick="startProcessing()">처리 시작</button>`);
}
if (d.status === 'in_progress') {
buttons.push(`<button class="action-btn success" onclick="openCompleteModal()">처리 완료</button>`);
}
}
// 신고자 버튼 (수정/삭제는 reported 상태에서만)
if (isOwner && d.status === 'reported') {
buttons.push(`<button class="action-btn danger" onclick="deleteReport()">삭제</button>`);
}
container.innerHTML = buttons.join('');
}
/**
* 상태 변경 이력 로드
*/
async function loadStatusLogs() {
try {
const response = await fetch(`${API_BASE}/work-issues/${reportId}/status-logs`, {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
if (!response.ok) return;
const data = await response.json();
if (data.success && data.data) {
renderStatusTimeline(data.data);
}
} catch (error) {
console.error('상태 이력 로드 실패:', error);
}
}
/**
* 상태 타임라인 렌더링
*/
function renderStatusTimeline(logs) {
const container = document.getElementById('statusTimeline');
if (!logs || logs.length === 0) {
container.innerHTML = '<p style="color: #6b7280;">상태 변경 이력이 없습니다.</p>';
return;
}
const formatDate = (dateStr) => {
const date = new Date(dateStr);
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
container.innerHTML = logs.map(log => `
<div class="timeline-item">
<div class="timeline-status">
${log.previous_status ? `${statusNames[log.previous_status] || escapeHtml(log.previous_status)}` : ''}${statusNames[log.new_status] || escapeHtml(log.new_status)}
</div>
<div class="timeline-meta">
${escapeHtml(log.changed_by_full_name || log.changed_by_name || '-')} | ${formatDate(log.changed_at)}
${log.change_reason ? `<br><small>${escapeHtml(log.change_reason)}</small>` : ''}
</div>
</div>
`).join('');
}
// ==================== 액션 함수 ====================
/**
* 신고 접수
*/
async function receiveReport() {
if (!confirm('이 신고를 접수하시겠습니까?')) return;
try {
const response = await fetch(`${API_BASE}/work-issues/${reportId}/receive`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
const data = await response.json();
if (data.success) {
alert('신고가 접수되었습니다.');
location.reload();
} else {
throw new Error(data.error || '접수 실패');
}
} catch (error) {
alert('접수 실패: ' + error.message);
}
}
/**
* 처리 시작
*/
async function startProcessing() {
if (!confirm('처리를 시작하시겠습니까?')) return;
try {
const response = await fetch(`${API_BASE}/work-issues/${reportId}/start`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
const data = await response.json();
if (data.success) {
alert('처리가 시작되었습니다.');
location.reload();
} else {
throw new Error(data.error || '처리 시작 실패');
}
} catch (error) {
alert('처리 시작 실패: ' + error.message);
}
}
/**
* 신고 종료
*/
async function closeReport() {
if (!confirm('이 신고를 종료하시겠습니까?')) return;
try {
const response = await fetch(`${API_BASE}/work-issues/${reportId}/close`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
const data = await response.json();
if (data.success) {
alert('신고가 종료되었습니다.');
location.reload();
} else {
throw new Error(data.error || '종료 실패');
}
} catch (error) {
alert('종료 실패: ' + error.message);
}
}
/**
* 신고 삭제
*/
async function deleteReport() {
if (!confirm('정말 이 신고를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.')) return;
try {
const response = await fetch(`${API_BASE}/work-issues/${reportId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
const data = await response.json();
if (data.success) {
alert('신고가 삭제되었습니다.');
goBackToList();
} else {
throw new Error(data.error || '삭제 실패');
}
} catch (error) {
alert('삭제 실패: ' + error.message);
}
}
// ==================== 담당자 배정 모달 ====================
async function openAssignModal() {
// 사용자 목록 로드
try {
const response = await fetch(`${API_BASE}/users`, {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
if (response.ok) {
const data = await response.json();
const select = document.getElementById('assignUser');
select.innerHTML = '<option value="">담당자 선택</option>';
if (data.success && data.data) {
data.data.forEach(user => {
const safeUserId = parseInt(user.user_id) || 0;
select.innerHTML += `<option value="${safeUserId}">${escapeHtml(user.name || '-')} (${escapeHtml(user.username || '-')})</option>`;
});
}
}
} catch (error) {
console.error('사용자 목록 로드 실패:', error);
}
document.getElementById('assignModal').classList.add('visible');
}
function closeAssignModal() {
document.getElementById('assignModal').classList.remove('visible');
}
async function submitAssign() {
const department = document.getElementById('assignDepartment').value;
const userId = document.getElementById('assignUser').value;
if (!userId) {
alert('담당자를 선택해주세요.');
return;
}
try {
const response = await fetch(`${API_BASE}/work-issues/${reportId}/assign`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({
assigned_department: department,
assigned_user_id: parseInt(userId)
})
});
const data = await response.json();
if (data.success) {
alert('담당자가 배정되었습니다.');
closeAssignModal();
location.reload();
} else {
throw new Error(data.error || '배정 실패');
}
} catch (error) {
alert('담당자 배정 실패: ' + error.message);
}
}
// ==================== 처리 완료 모달 ====================
function openCompleteModal() {
document.getElementById('completeModal').classList.add('visible');
}
function closeCompleteModal() {
document.getElementById('completeModal').classList.remove('visible');
}
async function submitComplete() {
const notes = document.getElementById('resolutionNotes').value;
if (!notes.trim()) {
alert('처리 내용을 입력해주세요.');
return;
}
try {
const response = await fetch(`${API_BASE}/work-issues/${reportId}/complete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({
resolution_notes: notes
})
});
const data = await response.json();
if (data.success) {
alert('처리가 완료되었습니다.');
closeCompleteModal();
location.reload();
} else {
throw new Error(data.error || '완료 처리 실패');
}
} catch (error) {
alert('처리 완료 실패: ' + error.message);
}
}
// ==================== 사진 모달 ====================
function openPhotoModal(src) {
document.getElementById('photoModalImg').src = src;
document.getElementById('photoModal').classList.add('visible');
}
function closePhotoModal() {
document.getElementById('photoModal').classList.remove('visible');
}
// ==================== 유틸리티 ====================
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* 목록으로 돌아가기
*/
function goBackToList() {
const urlParams = new URLSearchParams(window.location.search);
const from = urlParams.get('from');
if (from === 'nonconformity') {
window.location.href = '/pages/work/nonconformity.html';
} else if (from === 'safety') {
window.location.href = '/pages/safety/report-status.html';
} else {
if (window.history.length > 1) {
window.history.back();
} else {
window.location.href = '/pages/safety/report-status.html';
}
}
}
// 전역 함수 노출
window.goBackToList = goBackToList;
window.receiveReport = receiveReport;
window.startProcessing = startProcessing;
window.closeReport = closeReport;
window.deleteReport = deleteReport;
window.openAssignModal = openAssignModal;
window.closeAssignModal = closeAssignModal;
window.submitAssign = submitAssign;
window.openCompleteModal = openCompleteModal;
window.closeCompleteModal = closeCompleteModal;
window.submitComplete = submitComplete;
window.openPhotoModal = openPhotoModal;
window.closePhotoModal = closePhotoModal;

View File

@@ -0,0 +1,926 @@
/**
* 신고 등록 페이지 JavaScript
* URL 파라미터 ?type=nonconformity 또는 ?type=safety로 유형 사전 선택 지원
*/
// API 설정
const API_BASE = window.API_BASE_URL || 'http://localhost:20005/api';
// 상태 변수
let selectedFactoryId = null;
let selectedWorkplaceId = null;
let selectedWorkplaceName = null;
let selectedType = null; // 'nonconformity' | 'safety'
let selectedCategoryId = null;
let selectedCategoryName = null;
let selectedItemId = null;
let selectedTbmSessionId = null;
let selectedVisitRequestId = null;
let photos = [null, null, null, null, null];
let customItemName = null; // 직접 입력한 항목명
// 지도 관련 변수
let canvas, ctx, canvasImage;
let mapRegions = [];
let todayWorkers = [];
let todayVisitors = [];
// DOM 요소
let factorySelect, issueMapCanvas;
let photoInput, currentPhotoIndex;
// 초기화
document.addEventListener('DOMContentLoaded', async () => {
factorySelect = document.getElementById('factorySelect');
issueMapCanvas = document.getElementById('issueMapCanvas');
photoInput = document.getElementById('photoInput');
canvas = issueMapCanvas;
ctx = canvas.getContext('2d');
// 이벤트 리스너 설정
setupEventListeners();
// 공장 목록 로드
await loadFactories();
// URL 파라미터에서 유형 확인 및 자동 선택
const urlParams = new URLSearchParams(window.location.search);
const preselectedType = urlParams.get('type');
if (preselectedType === 'nonconformity' || preselectedType === 'safety') {
onTypeSelect(preselectedType);
}
});
/**
* 이벤트 리스너 설정
*/
function setupEventListeners() {
// 공장 선택
factorySelect.addEventListener('change', onFactoryChange);
// 지도 클릭
canvas.addEventListener('click', onMapClick);
// 기타 위치 토글
document.getElementById('useCustomLocation').addEventListener('change', (e) => {
const customInput = document.getElementById('customLocationInput');
customInput.classList.toggle('visible', e.target.checked);
if (e.target.checked) {
// 지도 선택 초기화
selectedWorkplaceId = null;
selectedWorkplaceName = null;
selectedTbmSessionId = null;
selectedVisitRequestId = null;
updateLocationInfo();
}
});
// 유형 버튼 클릭
document.querySelectorAll('.type-btn').forEach(btn => {
btn.addEventListener('click', () => onTypeSelect(btn.dataset.type));
});
// 사진 슬롯 클릭
document.querySelectorAll('.photo-slot').forEach(slot => {
slot.addEventListener('click', (e) => {
if (e.target.classList.contains('remove-btn')) return;
currentPhotoIndex = parseInt(slot.dataset.index);
photoInput.click();
});
});
// 사진 삭제 버튼
document.querySelectorAll('.photo-slot .remove-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const slot = btn.closest('.photo-slot');
const index = parseInt(slot.dataset.index);
removePhoto(index);
});
});
// 사진 선택
photoInput.addEventListener('change', onPhotoSelect);
}
/**
* 공장 목록 로드
*/
async function loadFactories() {
try {
const response = await fetch(`${API_BASE}/workplaces/categories/active/list`, {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
if (!response.ok) throw new Error('공장 목록 조회 실패');
const data = await response.json();
if (data.success && data.data) {
data.data.forEach(factory => {
const option = document.createElement('option');
option.value = factory.category_id;
option.textContent = factory.category_name;
factorySelect.appendChild(option);
});
// 첫 번째 공장 자동 선택
if (data.data.length > 0) {
factorySelect.value = data.data[0].category_id;
onFactoryChange();
}
}
} catch (error) {
console.error('공장 목록 로드 실패:', error);
}
}
/**
* 공장 변경 시
*/
async function onFactoryChange() {
selectedFactoryId = factorySelect.value;
if (!selectedFactoryId) return;
// 위치 선택 초기화
selectedWorkplaceId = null;
selectedWorkplaceName = null;
selectedTbmSessionId = null;
selectedVisitRequestId = null;
updateLocationInfo();
// 지도 데이터 로드
await Promise.all([
loadMapImage(),
loadMapRegions(),
loadTodayData()
]);
renderMap();
}
/**
* 배치도 이미지 로드
*/
async function loadMapImage() {
try {
const response = await fetch(`${API_BASE}/workplaces/categories`, {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
if (!response.ok) return;
const data = await response.json();
if (data.success && data.data) {
const selectedCategory = data.data.find(c => c.category_id == selectedFactoryId);
if (selectedCategory && selectedCategory.layout_image) {
const baseUrl = (window.API_BASE_URL || 'http://localhost:20005').replace('/api', '');
const fullImageUrl = selectedCategory.layout_image.startsWith('http')
? selectedCategory.layout_image
: `${baseUrl}${selectedCategory.layout_image}`;
canvasImage = new Image();
canvasImage.onload = () => renderMap();
canvasImage.src = fullImageUrl;
}
}
} catch (error) {
console.error('배치도 이미지 로드 실패:', error);
}
}
/**
* 지도 영역 로드
*/
async function loadMapRegions() {
try {
const response = await fetch(`${API_BASE}/workplaces/categories/${selectedFactoryId}/map-regions`, {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
if (!response.ok) return;
const data = await response.json();
if (data.success) {
mapRegions = data.data || [];
}
} catch (error) {
console.error('지도 영역 로드 실패:', error);
}
}
/**
* 오늘 TBM/출입신청 데이터 로드
*/
async function loadTodayData() {
// 로컬 시간대 기준으로 오늘 날짜 구하기 (UTC가 아닌 한국 시간 기준)
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const today = `${year}-${month}-${day}`;
console.log('[신고페이지] 조회 날짜 (로컬):', today);
try {
// TBM 세션 로드
const tbmResponse = await fetch(`${API_BASE}/tbm/sessions/date/${today}`, {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
if (tbmResponse.ok) {
const tbmData = await tbmResponse.json();
const sessions = tbmData.data || [];
// TBM 세션 데이터를 가공하여 member_count 계산
todayWorkers = sessions.map(session => {
const memberCount = session.team_member_count || 0;
const leaderCount = session.leader_id ? 1 : 0;
return {
...session,
member_count: memberCount + leaderCount
};
});
console.log('[신고페이지] 로드된 TBM 작업:', todayWorkers.length, '건');
}
// 출입 신청 로드
const visitResponse = await fetch(`${API_BASE}/workplace-visits/requests`, {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
if (visitResponse.ok) {
const visitData = await visitResponse.json();
todayVisitors = (visitData.data || []).filter(v => {
// 로컬 날짜로 비교
const visitDateObj = new Date(v.visit_date);
const visitYear = visitDateObj.getFullYear();
const visitMonth = String(visitDateObj.getMonth() + 1).padStart(2, '0');
const visitDay = String(visitDateObj.getDate()).padStart(2, '0');
const visitDate = `${visitYear}-${visitMonth}-${visitDay}`;
return visitDate === today &&
(v.status === 'approved' || v.status === 'training_completed');
});
console.log('[신고페이지] 로드된 방문자:', todayVisitors.length, '건');
}
} catch (error) {
console.error('오늘 데이터 로드 실패:', error);
}
}
/**
* 둥근 모서리 사각형 그리기 (Canvas roundRect 폴리필)
*/
function drawRoundRect(ctx, x, y, width, height, radius) {
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + width - radius, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
ctx.lineTo(x + width, y + height - radius);
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
ctx.lineTo(x + radius, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
}
/**
* 지도 렌더링
*/
function renderMap() {
if (!canvas || !ctx) return;
// 컨테이너 너비 가져오기
const container = canvas.parentElement;
const containerWidth = container.clientWidth - 2; // border 고려
const maxWidth = Math.min(containerWidth, 800);
// 이미지가 로드된 경우 이미지 비율에 맞춰 캔버스 크기 설정
if (canvasImage && canvasImage.complete && canvasImage.naturalWidth > 0) {
const imgWidth = canvasImage.naturalWidth;
const imgHeight = canvasImage.naturalHeight;
// 스케일 계산 (maxWidth에 맞춤)
const scale = imgWidth > maxWidth ? maxWidth / imgWidth : 1;
canvas.width = imgWidth * scale;
canvas.height = imgHeight * scale;
// 이미지 그리기
ctx.drawImage(canvasImage, 0, 0, canvas.width, canvas.height);
} else {
// 이미지가 없는 경우 기본 크기
canvas.width = maxWidth;
canvas.height = 400;
// 배경 그리기
ctx.fillStyle = '#f3f4f6';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 이미지 없음 안내
ctx.fillStyle = '#9ca3af';
ctx.font = '14px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('배치도 이미지가 없습니다', canvas.width / 2, canvas.height / 2);
}
// 작업장 영역 그리기 (퍼센트 좌표 사용)
mapRegions.forEach(region => {
const workers = todayWorkers.filter(w => w.workplace_id === region.workplace_id);
const visitors = todayVisitors.filter(v => v.workplace_id === region.workplace_id);
const workerCount = workers.reduce((sum, w) => sum + (w.member_count || 0), 0);
const visitorCount = visitors.reduce((sum, v) => sum + (v.visitor_count || 0), 0);
drawWorkplaceRegion(region, workerCount, visitorCount);
});
}
/**
* 작업장 영역 그리기
*/
function drawWorkplaceRegion(region, workerCount, visitorCount) {
const x1 = (region.x_start / 100) * canvas.width;
const y1 = (region.y_start / 100) * canvas.height;
const x2 = (region.x_end / 100) * canvas.width;
const y2 = (region.y_end / 100) * canvas.height;
const width = x2 - x1;
const height = y2 - y1;
// 선택된 작업장 하이라이트
const isSelected = region.workplace_id === selectedWorkplaceId;
// 색상 결정 (더 진하게 조정)
let fillColor, strokeColor, textColor;
if (isSelected) {
fillColor = 'rgba(34, 197, 94, 0.5)'; // 초록색 (선택됨)
strokeColor = 'rgb(22, 163, 74)';
textColor = '#15803d';
} else if (workerCount > 0 && visitorCount > 0) {
fillColor = 'rgba(34, 197, 94, 0.4)'; // 초록색 (작업+방문)
strokeColor = 'rgb(22, 163, 74)';
textColor = '#166534';
} else if (workerCount > 0) {
fillColor = 'rgba(59, 130, 246, 0.4)'; // 파란색 (작업만)
strokeColor = 'rgb(37, 99, 235)';
textColor = '#1e40af';
} else if (visitorCount > 0) {
fillColor = 'rgba(168, 85, 247, 0.4)'; // 보라색 (방문만)
strokeColor = 'rgb(147, 51, 234)';
textColor = '#7c3aed';
} else {
fillColor = 'rgba(107, 114, 128, 0.35)'; // 회색 (없음) - 더 진하게
strokeColor = 'rgb(75, 85, 99)';
textColor = '#374151';
}
ctx.fillStyle = fillColor;
ctx.strokeStyle = strokeColor;
ctx.lineWidth = isSelected ? 4 : 2.5;
ctx.beginPath();
ctx.rect(x1, y1, width, height);
ctx.fill();
ctx.stroke();
// 작업장명 표시 (배경 추가로 가독성 향상)
const centerX = x1 + width / 2;
const centerY = y1 + height / 2;
// 텍스트 배경
ctx.font = 'bold 13px sans-serif';
const textMetrics = ctx.measureText(region.workplace_name);
const textWidth = textMetrics.width + 12;
const textHeight = 20;
ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
drawRoundRect(ctx, centerX - textWidth / 2, centerY - textHeight / 2, textWidth, textHeight, 4);
ctx.fill();
// 텍스트
ctx.fillStyle = textColor;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(region.workplace_name, centerX, centerY);
// 인원수 표시
const total = workerCount + visitorCount;
if (total > 0) {
// 인원수 배경
ctx.font = 'bold 12px sans-serif';
const countText = `${total}`;
const countMetrics = ctx.measureText(countText);
const countWidth = countMetrics.width + 10;
const countHeight = 18;
ctx.fillStyle = strokeColor;
drawRoundRect(ctx, centerX - countWidth / 2, centerY + 12, countWidth, countHeight, 4);
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.fillText(countText, centerX, centerY + 21);
}
}
/**
* 지도 클릭 처리
*/
function onMapClick(e) {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// 클릭된 영역 찾기
for (const region of mapRegions) {
const x1 = (region.x_start / 100) * canvas.width;
const y1 = (region.y_start / 100) * canvas.height;
const x2 = (region.x_end / 100) * canvas.width;
const y2 = (region.y_end / 100) * canvas.height;
if (x >= x1 && x <= x2 && y >= y1 && y <= y2) {
selectWorkplace(region);
return;
}
}
}
/**
* 작업장 선택
*/
function selectWorkplace(region) {
// 기타 위치 체크박스 해제
document.getElementById('useCustomLocation').checked = false;
document.getElementById('customLocationInput').classList.remove('visible');
selectedWorkplaceId = region.workplace_id;
selectedWorkplaceName = region.workplace_name;
// 해당 작업장의 TBM/출입신청 확인
const workers = todayWorkers.filter(w => w.workplace_id === region.workplace_id);
const visitors = todayVisitors.filter(v => v.workplace_id === region.workplace_id);
if (workers.length > 0 || visitors.length > 0) {
// 작업 선택 모달 표시
showWorkSelectionModal(workers, visitors);
} else {
selectedTbmSessionId = null;
selectedVisitRequestId = null;
}
updateLocationInfo();
renderMap();
updateStepStatus();
}
/**
* 작업 선택 모달 표시
*/
function showWorkSelectionModal(workers, visitors) {
const modal = document.getElementById('workSelectionModal');
const optionsList = document.getElementById('workOptionsList');
optionsList.innerHTML = '';
// TBM 작업 옵션
workers.forEach(w => {
const option = document.createElement('div');
option.className = 'work-option';
const safeTaskName = escapeHtml(w.task_name || '작업');
const safeProjectName = escapeHtml(w.project_name || '');
const memberCount = parseInt(w.member_count) || 0;
option.innerHTML = `
<div class="work-option-title">TBM: ${safeTaskName}</div>
<div class="work-option-desc">${safeProjectName} - ${memberCount}명</div>
`;
option.onclick = () => {
selectedTbmSessionId = w.session_id;
selectedVisitRequestId = null;
closeWorkModal();
updateLocationInfo();
};
optionsList.appendChild(option);
});
// 출입신청 옵션
visitors.forEach(v => {
const option = document.createElement('div');
option.className = 'work-option';
const safeCompany = escapeHtml(v.visitor_company || '-');
const safePurpose = escapeHtml(v.purpose_name || '방문');
const visitorCount = parseInt(v.visitor_count) || 0;
option.innerHTML = `
<div class="work-option-title">출입: ${safeCompany}</div>
<div class="work-option-desc">${safePurpose} - ${visitorCount}명</div>
`;
option.onclick = () => {
selectedVisitRequestId = v.request_id;
selectedTbmSessionId = null;
closeWorkModal();
updateLocationInfo();
};
optionsList.appendChild(option);
});
modal.classList.add('visible');
}
/**
* 작업 선택 모달 닫기
*/
function closeWorkModal() {
document.getElementById('workSelectionModal').classList.remove('visible');
}
/**
* 선택된 위치 정보 업데이트
*/
function updateLocationInfo() {
const infoBox = document.getElementById('selectedLocationInfo');
const customLocation = document.getElementById('customLocation').value;
const useCustom = document.getElementById('useCustomLocation').checked;
if (useCustom && customLocation) {
infoBox.classList.remove('empty');
infoBox.innerHTML = `<strong>선택된 위치:</strong> ${escapeHtml(customLocation)}`;
} else if (selectedWorkplaceName) {
infoBox.classList.remove('empty');
let html = `<strong>선택된 위치:</strong> ${escapeHtml(selectedWorkplaceName)}`;
if (selectedTbmSessionId) {
const worker = todayWorkers.find(w => w.session_id === selectedTbmSessionId);
if (worker) {
html += `<br><span style="color: var(--primary-600);">연결 작업: ${escapeHtml(worker.task_name || '-')} (TBM)</span>`;
}
} else if (selectedVisitRequestId) {
const visitor = todayVisitors.find(v => v.request_id === selectedVisitRequestId);
if (visitor) {
html += `<br><span style="color: var(--primary-600);">연결 작업: ${escapeHtml(visitor.visitor_company || '-')} (출입)</span>`;
}
}
infoBox.innerHTML = html;
} else {
infoBox.classList.add('empty');
infoBox.textContent = '지도에서 작업장을 클릭하여 위치를 선택하세요';
}
}
/**
* 유형 선택
*/
function onTypeSelect(type) {
selectedType = type;
selectedCategoryId = null;
selectedCategoryName = null;
selectedItemId = null;
// 버튼 상태 업데이트
document.querySelectorAll('.type-btn').forEach(btn => {
btn.classList.toggle('selected', btn.dataset.type === type);
});
// 카테고리 로드
loadCategories(type);
updateStepStatus();
}
/**
* 카테고리 로드
*/
async function loadCategories(type) {
try {
const response = await fetch(`${API_BASE}/work-issues/categories/type/${type}`, {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
if (!response.ok) throw new Error('카테고리 조회 실패');
const data = await response.json();
if (data.success && data.data) {
renderCategories(data.data);
}
} catch (error) {
console.error('카테고리 로드 실패:', error);
}
}
/**
* 카테고리 렌더링
*/
function renderCategories(categories) {
const container = document.getElementById('categoryContainer');
const grid = document.getElementById('categoryGrid');
grid.innerHTML = '';
categories.forEach(cat => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'category-btn';
btn.textContent = cat.category_name;
btn.onclick = () => onCategorySelect(cat);
grid.appendChild(btn);
});
container.style.display = 'block';
}
/**
* 카테고리 선택
*/
function onCategorySelect(category) {
selectedCategoryId = category.category_id;
selectedCategoryName = category.category_name;
selectedItemId = null;
// 버튼 상태 업데이트
document.querySelectorAll('.category-btn').forEach(btn => {
btn.classList.toggle('selected', btn.textContent === category.category_name);
});
// 항목 로드
loadItems(category.category_id);
updateStepStatus();
}
/**
* 항목 로드
*/
async function loadItems(categoryId) {
try {
const response = await fetch(`${API_BASE}/work-issues/items/category/${categoryId}`, {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
if (!response.ok) throw new Error('항목 조회 실패');
const data = await response.json();
if (data.success && data.data) {
renderItems(data.data);
}
} catch (error) {
console.error('항목 로드 실패:', error);
}
}
/**
* 항목 렌더링
*/
function renderItems(items) {
const grid = document.getElementById('itemGrid');
grid.innerHTML = '';
// 기존 항목들 렌더링
items.forEach(item => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'item-btn';
btn.textContent = item.item_name;
btn.dataset.severity = item.severity;
btn.onclick = () => onItemSelect(item, btn);
grid.appendChild(btn);
});
// 직접 입력 버튼 추가
const customBtn = document.createElement('button');
customBtn.type = 'button';
customBtn.className = 'item-btn custom-input-btn';
customBtn.textContent = '+ 직접 입력';
customBtn.onclick = () => showCustomItemInput();
grid.appendChild(customBtn);
// 직접 입력 영역 숨기기
document.getElementById('customItemInput').style.display = 'none';
document.getElementById('customItemName').value = '';
customItemName = null;
}
/**
* 항목 선택
*/
function onItemSelect(item, btn) {
// 단일 선택 (기존 선택 해제)
document.querySelectorAll('.item-btn').forEach(b => b.classList.remove('selected'));
btn.classList.add('selected');
selectedItemId = item.item_id;
customItemName = null; // 기존 항목 선택 시 직접 입력 초기화
document.getElementById('customItemInput').style.display = 'none';
updateStepStatus();
}
/**
* 직접 입력 영역 표시
*/
function showCustomItemInput() {
// 기존 선택 해제
document.querySelectorAll('.item-btn').forEach(b => b.classList.remove('selected'));
document.querySelector('.custom-input-btn').classList.add('selected');
selectedItemId = null;
// 입력 영역 표시
document.getElementById('customItemInput').style.display = 'flex';
document.getElementById('customItemName').focus();
}
/**
* 직접 입력 확인
*/
function confirmCustomItem() {
const input = document.getElementById('customItemName');
const value = input.value.trim();
if (!value) {
alert('항목명을 입력해주세요.');
input.focus();
return;
}
customItemName = value;
selectedItemId = null; // 커스텀 항목이므로 ID는 null
// 입력 완료 표시
const customBtn = document.querySelector('.custom-input-btn');
customBtn.textContent = `${value}`;
customBtn.classList.add('selected');
updateStepStatus();
}
/**
* 직접 입력 취소
*/
function cancelCustomItem() {
document.getElementById('customItemInput').style.display = 'none';
document.getElementById('customItemName').value = '';
customItemName = null;
// 직접 입력 버튼 원상복구
const customBtn = document.querySelector('.custom-input-btn');
customBtn.textContent = '+ 직접 입력';
customBtn.classList.remove('selected');
updateStepStatus();
}
/**
* 사진 선택
*/
function onPhotoSelect(e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
photos[currentPhotoIndex] = event.target.result;
updatePhotoSlot(currentPhotoIndex);
updateStepStatus(); // 제출 버튼 상태 업데이트
};
reader.readAsDataURL(file);
// 입력 초기화
e.target.value = '';
}
/**
* 사진 슬롯 업데이트
*/
function updatePhotoSlot(index) {
const slot = document.querySelector(`.photo-slot[data-index="${index}"]`);
if (photos[index]) {
slot.classList.add('has-photo');
let img = slot.querySelector('img');
if (!img) {
img = document.createElement('img');
slot.insertBefore(img, slot.firstChild);
}
img.src = photos[index];
} else {
slot.classList.remove('has-photo');
const img = slot.querySelector('img');
if (img) img.remove();
}
}
/**
* 사진 삭제
*/
function removePhoto(index) {
photos[index] = null;
updatePhotoSlot(index);
updateStepStatus(); // 제출 버튼 상태 업데이트
}
/**
* 단계 상태 업데이트
*/
function updateStepStatus() {
const steps = document.querySelectorAll('.step');
const customLocation = document.getElementById('customLocation').value;
const useCustom = document.getElementById('useCustomLocation').checked;
// Step 1: 위치
const step1Complete = (useCustom && customLocation) || selectedWorkplaceId;
steps[0].classList.toggle('completed', step1Complete);
steps[1].classList.toggle('active', step1Complete);
// Step 2: 유형
const step2Complete = selectedType && selectedCategoryId;
steps[1].classList.toggle('completed', step2Complete);
steps[2].classList.toggle('active', step2Complete);
// Step 3: 항목 (기존 항목 선택 또는 직접 입력)
const step3Complete = selectedItemId || customItemName;
steps[2].classList.toggle('completed', step3Complete);
steps[3].classList.toggle('active', step3Complete);
// 제출 버튼 활성화
const submitBtn = document.getElementById('submitBtn');
const hasPhoto = photos.some(p => p !== null);
submitBtn.disabled = !(step1Complete && step2Complete && step3Complete && hasPhoto);
}
/**
* 신고 제출
*/
async function submitReport() {
const submitBtn = document.getElementById('submitBtn');
submitBtn.disabled = true;
submitBtn.textContent = '제출 중...';
try {
const useCustom = document.getElementById('useCustomLocation').checked;
const customLocation = document.getElementById('customLocation').value;
const additionalDescription = document.getElementById('additionalDescription').value;
const requestBody = {
factory_category_id: useCustom ? null : selectedFactoryId,
workplace_id: useCustom ? null : selectedWorkplaceId,
custom_location: useCustom ? customLocation : null,
tbm_session_id: selectedTbmSessionId,
visit_request_id: selectedVisitRequestId,
issue_category_id: selectedCategoryId,
issue_item_id: selectedItemId,
custom_item_name: customItemName, // 직접 입력한 항목명
additional_description: additionalDescription || null,
photos: photos.filter(p => p !== null)
};
const response = await fetch(`${API_BASE}/work-issues`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify(requestBody)
});
const data = await response.json();
if (data.success) {
alert('신고가 등록되었습니다.');
// 유형에 따라 다른 페이지로 리다이렉트
if (selectedType === 'nonconformity') {
window.location.href = '/pages/work/nonconformity.html';
} else if (selectedType === 'safety') {
window.location.href = '/pages/safety/report-status.html';
} else {
// 기본: 뒤로가기
history.back();
}
} else {
throw new Error(data.error || '신고 등록 실패');
}
} catch (error) {
console.error('신고 제출 실패:', error);
alert('신고 등록에 실패했습니다: ' + error.message);
} finally {
submitBtn.disabled = false;
submitBtn.textContent = '신고 제출';
}
}
// 기타 위치 입력 시 위치 정보 업데이트
document.addEventListener('DOMContentLoaded', () => {
const customLocationInput = document.getElementById('customLocation');
if (customLocationInput) {
customLocationInput.addEventListener('input', () => {
updateLocationInfo();
updateStepStatus();
});
}
});
// 전역 함수 노출 (HTML onclick에서 호출용)
window.closeWorkModal = closeWorkModal;
window.submitReport = submitReport;
window.showCustomItemInput = showCustomItemInput;
window.confirmCustomItem = confirmCustomItem;
window.cancelCustomItem = cancelCustomItem;

View File

@@ -0,0 +1,476 @@
// /js/load-navbar.js
import { getUser, clearAuthData } from './auth.js';
import { loadComponent } from './component-loader.js';
import { config } from './config.js';
// 역할 이름을 한글로 변환하는 맵
const ROLE_NAMES = {
'system admin': '시스템 관리자',
'admin': '관리자',
'system': '시스템 관리자',
'leader': '그룹장',
'user': '작업자',
'support': '지원팀',
'default': '사용자',
};
/**
* 네비게이션 바 DOM을 사용자 정보와 역할에 맞게 수정하는 프로세서입니다.
* @param {Document} doc - 파싱된 HTML 문서 객체
*/
async function processNavbarDom(doc) {
const currentUser = getUser();
if (!currentUser) return;
// 1. 역할 및 페이지 권한 기반 메뉴 필터링
await filterMenuByPageAccess(doc, currentUser);
// 2. 사용자 정보 채우기
populateUserInfo(doc, currentUser);
}
/**
* 사용자의 페이지 접근 권한에 따라 메뉴 항목을 필터링합니다.
* @param {Document} doc - 파싱된 HTML 문서 객체
* @param {object} currentUser - 현재 사용자 객체
*/
async function filterMenuByPageAccess(doc, currentUser) {
const userRole = (currentUser.role || '').toLowerCase();
// Admin은 모든 메뉴 표시 + .admin-only 요소 활성화
if (userRole === 'admin' || userRole === 'system admin') {
doc.querySelectorAll('.admin-only').forEach(el => el.classList.add('visible'));
return;
}
try {
// 사용자의 페이지 접근 권한 조회
const cached = localStorage.getItem('userPageAccess');
let accessiblePages = null;
if (cached) {
const cacheData = JSON.parse(cached);
// 캐시가 5분 이내인 경우 사용
if (Date.now() - cacheData.timestamp < 5 * 60 * 1000) {
accessiblePages = cacheData.pages;
}
}
// 캐시가 없으면 API 호출
if (!accessiblePages) {
const response = await fetch(`${window.API_BASE_URL}/users/${currentUser.user_id}/page-access`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (!response.ok) {
console.error('페이지 권한 조회 실패:', response.status);
return;
}
const data = await response.json();
accessiblePages = data.data.pageAccess || [];
// 캐시 저장
localStorage.setItem('userPageAccess', JSON.stringify({
pages: accessiblePages,
timestamp: Date.now()
}));
}
// 접근 가능한 페이지 키 목록
const accessiblePageKeys = accessiblePages
.filter(p => p.can_access === 1)
.map(p => p.page_key);
// 메뉴 항목에 data-page-key 속성이 있으면 해당 권한 체크
const menuItems = doc.querySelectorAll('[data-page-key]');
menuItems.forEach(item => {
const pageKey = item.getAttribute('data-page-key');
// 대시보드와 프로필 페이지는 모든 사용자 접근 가능
if (pageKey === 'dashboard' || pageKey.startsWith('profile.')) {
return;
}
// 권한이 없으면 메뉴 항목 제거
if (!accessiblePageKeys.includes(pageKey)) {
item.remove();
}
});
// Admin 전용 메뉴는 무조건 제거
doc.querySelectorAll('.admin-only').forEach(el => el.remove());
} catch (error) {
console.error('메뉴 필터링 오류:', error);
}
}
/**
* 네비게이션 바에 사용자 정보를 채웁니다.
* @param {Document} doc - 파싱된 HTML 문서 객체
* @param {object} user - 현재 사용자 객체
*/
function populateUserInfo(doc, user) {
const displayName = user.name || user.username;
// 대소문자 구분 없이 처리
const roleLower = (user.role || '').toLowerCase();
const roleName = ROLE_NAMES[roleLower] || ROLE_NAMES.default;
const elements = {
'userName': displayName,
'userRole': roleName,
'userInitial': displayName.charAt(0),
};
for (const id in elements) {
const el = doc.getElementById(id);
if (el) el.textContent = elements[id];
}
// 메인 대시보드 URL 설정
const dashboardBtn = doc.getElementById('dashboardBtn');
if (dashboardBtn) {
dashboardBtn.href = '/pages/dashboard.html';
}
}
/**
* 네비게이션 바와 관련된 모든 이벤트를 설정합니다.
*/
function setupNavbarEvents() {
const logoutButton = document.getElementById('logoutBtn');
if (logoutButton) {
logoutButton.addEventListener('click', () => {
if (confirm('로그아웃 하시겠습니까?')) {
clearAuthData();
window.location.href = config.paths.loginPage;
}
});
}
// 모바일 메뉴 버튼
const mobileMenuBtn = document.getElementById('mobileMenuBtn');
const sidebar = document.getElementById('sidebarNav');
const overlay = document.getElementById('sidebarOverlay');
if (mobileMenuBtn && sidebar) {
mobileMenuBtn.addEventListener('click', () => {
sidebar.classList.toggle('mobile-open');
overlay?.classList.toggle('show');
document.body.classList.toggle('sidebar-mobile-open');
});
}
// 오버레이 클릭시 닫기
if (overlay) {
overlay.addEventListener('click', () => {
sidebar?.classList.remove('mobile-open');
overlay.classList.remove('show');
document.body.classList.remove('sidebar-mobile-open');
});
}
// 사이드바 토글 버튼 (모바일에서 닫기)
const sidebarToggle = document.getElementById('sidebarToggle');
if (sidebarToggle && sidebar) {
sidebarToggle.addEventListener('click', () => {
if (window.innerWidth <= 1024) {
sidebar.classList.remove('mobile-open');
overlay?.classList.remove('show');
document.body.classList.remove('sidebar-mobile-open');
}
});
}
}
/**
* 현재 날짜와 시간을 업데이트하는 함수
*/
function updateDateTime() {
const now = new Date();
// 시간 업데이트 (시 분 초 형식으로 고정)
const timeElement = document.getElementById('timeValue');
if (timeElement) {
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
timeElement.textContent = `${hours}${minutes}${seconds}`;
}
// 날짜 업데이트
const dateElement = document.getElementById('dateValue');
if (dateElement) {
const days = ['일', '월', '화', '수', '목', '금', '토'];
const month = now.getMonth() + 1;
const date = now.getDate();
const day = days[now.getDay()];
dateElement.textContent = `${month}${date}일 (${day})`;
}
}
// 날씨 아이콘 매핑
const WEATHER_ICONS = {
clear: '☀️',
rain: '🌧️',
snow: '❄️',
heat: '🔥',
cold: '🥶',
wind: '💨',
fog: '🌫️',
dust: '😷',
cloudy: '⛅',
overcast: '☁️'
};
// 날씨 조건명
const WEATHER_NAMES = {
clear: '맑음',
rain: '비',
snow: '눈',
heat: '폭염',
cold: '한파',
wind: '강풍',
fog: '안개',
dust: '미세먼지',
cloudy: '구름많음',
overcast: '흐림'
};
/**
* 날씨 정보를 가져와서 업데이트하는 함수
*/
async function updateWeather() {
try {
const token = localStorage.getItem('token');
if (!token) return;
const response = await fetch(`${window.API_BASE_URL}/tbm/weather/current`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error('날씨 API 호출 실패');
}
const result = await response.json();
if (result.success && result.data) {
const { temperature, conditions, weatherData } = result.data;
// 온도 표시
const tempElement = document.getElementById('weatherTemp');
if (tempElement && temperature !== null && temperature !== undefined) {
tempElement.textContent = `${Math.round(temperature)}°C`;
}
// 날씨 아이콘 및 설명
const iconElement = document.getElementById('weatherIcon');
const descElement = document.getElementById('weatherDesc');
if (conditions && conditions.length > 0) {
const primaryCondition = conditions[0];
if (iconElement) {
iconElement.textContent = WEATHER_ICONS[primaryCondition] || '🌤️';
}
if (descElement) {
descElement.textContent = WEATHER_NAMES[primaryCondition] || '맑음';
}
} else {
if (iconElement) iconElement.textContent = '☀️';
if (descElement) descElement.textContent = '맑음';
}
// 날씨 섹션 표시
const weatherSection = document.getElementById('weatherSection');
if (weatherSection) {
weatherSection.style.opacity = '1';
}
}
} catch (error) {
console.warn('날씨 정보 로드 실패:', error.message);
// 실패해도 기본값 표시
const descElement = document.getElementById('weatherDesc');
if (descElement) {
descElement.textContent = '날씨 정보 없음';
}
}
}
// ==========================================
// 알림 시스템
// ==========================================
/**
* 알림 관련 이벤트 설정
*/
function setupNotificationEvents() {
const notificationBtn = document.getElementById('notificationBtn');
const notificationDropdown = document.getElementById('notificationDropdown');
const notificationWrapper = document.getElementById('notificationWrapper');
if (notificationBtn) {
notificationBtn.addEventListener('click', (e) => {
e.stopPropagation();
notificationDropdown?.classList.toggle('show');
});
}
// 외부 클릭시 드롭다운 닫기
document.addEventListener('click', (e) => {
if (notificationWrapper && notificationDropdown && !notificationWrapper.contains(e.target)) {
notificationDropdown.classList.remove('show');
}
});
}
/**
* 알림 목록 로드
*/
async function loadNotifications() {
try {
const token = localStorage.getItem('token');
if (!token) return;
const response = await fetch(`${window.API_BASE_URL}/notifications/unread`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) return;
const result = await response.json();
if (result.success) {
const notifications = result.data || [];
updateNotificationBadge(notifications.length);
renderNotificationList(notifications);
}
} catch (error) {
console.warn('알림 로드 오류:', error.message);
}
}
/**
* 배지 업데이트
*/
function updateNotificationBadge(count) {
const badge = document.getElementById('notificationBadge');
const btn = document.getElementById('notificationBtn');
if (!badge) return;
if (count > 0) {
badge.textContent = count > 99 ? '99+' : count;
badge.style.display = 'flex';
btn?.classList.add('has-notifications');
} else {
badge.style.display = 'none';
btn?.classList.remove('has-notifications');
}
}
/**
* 알림 목록 렌더링
*/
function renderNotificationList(notifications) {
const list = document.getElementById('notificationList');
if (!list) return;
if (notifications.length === 0) {
list.innerHTML = '<div class="notification-empty">새 알림이 없습니다.</div>';
return;
}
const NOTIF_ICONS = {
repair: '🔧',
safety: '⚠️',
system: '📢',
equipment: '🔩',
maintenance: '🛠️'
};
list.innerHTML = notifications.slice(0, 5).map(n => `
<div class="notification-item ${n.is_read ? '' : 'unread'}" data-id="${n.notification_id}" data-url="${n.link_url || ''}">
<div class="notification-item-icon ${n.type || 'repair'}">
${NOTIF_ICONS[n.type] || '🔔'}
</div>
<div class="notification-item-content">
<div class="notification-item-title">${escapeHtml(n.title)}</div>
<div class="notification-item-desc">${escapeHtml(n.message || '')}</div>
</div>
<div class="notification-item-time">${formatTimeAgo(n.created_at)}</div>
</div>
`).join('');
// 클릭 이벤트 추가
list.querySelectorAll('.notification-item').forEach(item => {
item.addEventListener('click', () => {
const linkUrl = item.dataset.url;
// 수리 알림은 클릭해도 읽음 처리 안함 (수리 처리 페이지에서 확인 처리)
window.location.href = linkUrl || '/pages/admin/notifications.html';
});
});
}
/**
* 시간 포맷팅
*/
function formatTimeAgo(dateString) {
const date = new Date(dateString);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return '방금 전';
if (diffMins < 60) return `${diffMins}분 전`;
if (diffHours < 24) return `${diffHours}시간 전`;
if (diffDays < 7) return `${diffDays}일 전`;
return date.toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' });
}
/**
* HTML 이스케이프
*/
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 메인 로직: DOMContentLoaded 시 실행
document.addEventListener('DOMContentLoaded', async () => {
if (getUser()) {
// 1. 컴포넌트 로드 및 DOM 수정
await loadComponent('navbar', '#navbar-container', processNavbarDom);
// 2. DOM에 삽입된 후에 이벤트 리스너 설정
setupNavbarEvents();
// 3. 실시간 날짜/시간 업데이트 시작
updateDateTime();
setInterval(updateDateTime, 1000);
// 4. 날씨 정보 로드 (10분마다 갱신)
updateWeather();
setInterval(updateWeather, 10 * 60 * 1000);
// 5. 알림 이벤트 설정 및 로드 (30초마다 갱신)
setupNotificationEvents();
loadNotifications();
setInterval(loadNotifications, 30000);
}
});

View 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);

Some files were not shown because too many files have changed in this diff Show More