feat: 다수 기능 개선 - 순찰, 출근, 작업분석, 모바일 UI 등
- 순찰/점검 기능 개선 (zone-detail 페이지 추가) - 출근/근태 시스템 개선 (연차 조회, 근무현황) - 작업분석 대분류 그룹화 및 마이그레이션 스크립트 - 모바일 네비게이션 UI 추가 - NAS 배포 도구 및 문서 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
135
web-ui/components/mobile-nav.html
Normal file
135
web-ui/components/mobile-nav.html
Normal 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>
|
||||
@@ -3,6 +3,10 @@
|
||||
<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">
|
||||
@@ -16,7 +20,7 @@
|
||||
<div class="datetime-weather-box">
|
||||
<div class="date-time-section">
|
||||
<span class="date-value" id="dateValue">--월 --일 (--)</span>
|
||||
<span class="time-value" id="timeValue">--:--:--</span>
|
||||
<span class="time-value" id="timeValue">--시 --분 --초</span>
|
||||
</div>
|
||||
<div class="weather-section" id="weatherSection">
|
||||
<span class="weather-icon" id="weatherIcon">🌤️</span>
|
||||
@@ -598,18 +602,61 @@ body {
|
||||
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-3) var(--space-4);
|
||||
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-lg);
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
font-size: var(--text-xs);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-center {
|
||||
@@ -620,6 +667,11 @@ body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.dashboard-btn .btn-text,
|
||||
.report-btn .btn-text {
|
||||
display: none;
|
||||
@@ -627,12 +679,55 @@ body {
|
||||
|
||||
.dashboard-btn,
|
||||
.report-btn {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
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 {
|
||||
width: 280px;
|
||||
right: -50px;
|
||||
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>
|
||||
@@ -89,6 +89,9 @@
|
||||
<span class="nav-arrow">▾</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>
|
||||
@@ -354,16 +357,68 @@ 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,
|
||||
@@ -372,5 +427,53 @@ body.has-sidebar.sidebar-collapsed > .main-content {
|
||||
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>
|
||||
|
||||
@@ -298,22 +298,26 @@
|
||||
/* 작업장 마커 */
|
||||
.workplace-marker {
|
||||
position: absolute;
|
||||
padding: 0.5rem 0.75rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
background: var(--surface-color, #fff);
|
||||
border: 2px solid var(--border-color, #cbd5e1);
|
||||
border-radius: 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.2s;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
|
||||
transition: all 0.15s ease-out;
|
||||
transform: translate(-50%, -50%);
|
||||
white-space: nowrap;
|
||||
/* z-index는 JS에서 y좌표 기반 설정 (아래에 있을수록 높음) */
|
||||
}
|
||||
|
||||
.workplace-marker:hover {
|
||||
transform: translate(-50%, -50%) scale(1.05);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
transform: translate(-50%, -50%) scale(1.15);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.25);
|
||||
z-index: 200 !important;
|
||||
background: #f0f9ff;
|
||||
border-color: var(--primary-color, #3b82f6);
|
||||
}
|
||||
|
||||
.workplace-marker.completed {
|
||||
@@ -330,7 +334,16 @@
|
||||
border-color: var(--primary-color, #3b82f6);
|
||||
background: var(--primary-color, #3b82f6);
|
||||
color: #fff;
|
||||
z-index: 10;
|
||||
z-index: 150 !important;
|
||||
transform: translate(-50%, -50%) scale(1.1);
|
||||
}
|
||||
|
||||
/* 작은 마커 (밀집 구역용) */
|
||||
.workplace-marker.compact {
|
||||
padding: 0.2rem 0.35rem;
|
||||
font-size: 0.6rem;
|
||||
border-radius: 4px;
|
||||
border-width: 1.5px;
|
||||
}
|
||||
|
||||
/* 작업장 목록 */
|
||||
@@ -665,6 +678,540 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* ==================== 작업장 상세 정보 패널 ==================== */
|
||||
.workplace-detail-panel {
|
||||
position: fixed;
|
||||
top: 90px;
|
||||
right: 20px;
|
||||
width: 400px;
|
||||
max-height: calc(100vh - 110px);
|
||||
background: var(--surface-color, #fff);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.detail-panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 1rem 1.25rem;
|
||||
background: linear-gradient(135deg, var(--primary-color, #3b82f6), #2563eb);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.detail-panel-title h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detail-panel-subtitle {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.detail-panel-close {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: none;
|
||||
color: #fff;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.detail-panel-close:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* 요약 정보 */
|
||||
.detail-panel-summary {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg-color, #f8fafc);
|
||||
border-bottom: 1px solid var(--border-color, #e2e8f0);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
flex: 1;
|
||||
min-width: 60px;
|
||||
text-align: center;
|
||||
padding: 0.5rem;
|
||||
background: var(--surface-color, #fff);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.summary-item.warning {
|
||||
background: #fef3c7;
|
||||
}
|
||||
|
||||
.summary-item.danger {
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
.summary-item.info {
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
display: block;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1e293b);
|
||||
}
|
||||
|
||||
.summary-item.warning .summary-value {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.summary-item.danger .summary-value {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.summary-item.info .summary-value {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-secondary, #64748b);
|
||||
}
|
||||
|
||||
/* 탭 */
|
||||
.detail-panel-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border-color, #e2e8f0);
|
||||
background: var(--surface-color, #fff);
|
||||
}
|
||||
|
||||
.detail-tab {
|
||||
flex: 1;
|
||||
padding: 0.6rem 0.5rem;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #64748b);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.detail-tab:hover {
|
||||
color: var(--primary-color, #3b82f6);
|
||||
}
|
||||
|
||||
.detail-tab.active {
|
||||
color: var(--primary-color, #3b82f6);
|
||||
border-bottom-color: var(--primary-color, #3b82f6);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tab-badge {
|
||||
display: none;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
background: var(--error-color, #dc2626);
|
||||
color: #fff;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
margin-left: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.tab-badge.show {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tab-badge.warning {
|
||||
background: var(--warning-color, #f59e0b);
|
||||
}
|
||||
|
||||
/* 탭 콘텐츠 */
|
||||
.detail-panel-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
background: var(--bg-color, #f8fafc);
|
||||
}
|
||||
|
||||
.detail-tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.detail-tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.detail-empty {
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
color: var(--text-secondary, #64748b);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* 신고/부적합 섹션 */
|
||||
.issue-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.issue-section-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.75rem;
|
||||
color: var(--text-primary, #1e293b);
|
||||
}
|
||||
|
||||
.issue-item {
|
||||
background: var(--surface-color, #fff);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border-left: 3px solid var(--border-color, #cbd5e1);
|
||||
}
|
||||
|
||||
.issue-item.pending {
|
||||
border-left-color: #f59e0b;
|
||||
}
|
||||
|
||||
.issue-item.info {
|
||||
border-left-color: #3b82f6;
|
||||
}
|
||||
|
||||
.issue-item.warning {
|
||||
border-left-color: #f59e0b;
|
||||
}
|
||||
|
||||
.issue-item.success {
|
||||
border-left-color: #16a34a;
|
||||
}
|
||||
|
||||
.issue-item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.issue-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.issue-status {
|
||||
font-size: 0.7rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--bg-color, #f1f5f9);
|
||||
}
|
||||
|
||||
.issue-status.pending {
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.issue-status.info {
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.issue-status.warning {
|
||||
background: #fed7aa;
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.issue-status.success {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.issue-item-meta {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.issue-category {
|
||||
color: var(--text-secondary, #64748b);
|
||||
}
|
||||
|
||||
.issue-severity {
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.issue-severity.critical {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.issue-severity.high {
|
||||
background: #fed7aa;
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.issue-severity.medium {
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.issue-severity.low {
|
||||
background: #d1fae5;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.issue-date {
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
}
|
||||
|
||||
.issue-desc {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary, #64748b);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.issue-reporter {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
}
|
||||
|
||||
/* 설비 섹션 */
|
||||
.equipment-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.equipment-section-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.75rem;
|
||||
color: var(--text-primary, #1e293b);
|
||||
}
|
||||
|
||||
.repair-item {
|
||||
background: var(--surface-color, #fff);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border-left: 3px solid #f59e0b;
|
||||
}
|
||||
|
||||
.repair-item.emergency {
|
||||
border-left-color: #dc2626;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.repair-item.high {
|
||||
border-left-color: #f97316;
|
||||
}
|
||||
|
||||
.repair-item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.repair-equipment {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.repair-priority {
|
||||
font-size: 0.7rem;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.repair-priority.emergency {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.repair-priority.high {
|
||||
background: #fed7aa;
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.repair-category, .repair-desc, .repair-date {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #64748b);
|
||||
}
|
||||
|
||||
.equipment-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.equipment-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--surface-color, #fff);
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.equipment-item.attention {
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.equipment-name {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.equipment-code {
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.equipment-status {
|
||||
font-size: 0.65rem;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.equipment-status.repair_needed {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.equipment-status.under_repair {
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.equipment-status.inactive {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* 출입 섹션 */
|
||||
.visits-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.visit-item {
|
||||
background: var(--surface-color, #fff);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.visit-item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.visit-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.visit-company {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #64748b);
|
||||
}
|
||||
|
||||
.visit-purpose {
|
||||
font-size: 0.8rem;
|
||||
color: var(--primary-color, #3b82f6);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.visit-time, .visit-companion, .visit-vehicle {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #64748b);
|
||||
}
|
||||
|
||||
/* TBM 섹션 */
|
||||
.tbm-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.tbm-item {
|
||||
background: var(--surface-color, #fff);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.tbm-item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.tbm-task {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.tbm-status {
|
||||
font-size: 0.7rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--bg-color, #f1f5f9);
|
||||
}
|
||||
|
||||
.tbm-status.completed {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.tbm-status.in_progress {
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.tbm-location, .tbm-leader, .tbm-content, .tbm-safety {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary, #64748b);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.tbm-team {
|
||||
font-size: 0.75rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px dashed var(--border-color, #e2e8f0);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.tbm-team-label {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #1e293b);
|
||||
}
|
||||
|
||||
.tbm-team-names {
|
||||
color: var(--text-secondary, #64748b);
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 768px) {
|
||||
.patrol-start-section {
|
||||
@@ -733,4 +1280,24 @@
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 모바일 상세 패널 */
|
||||
.workplace-detail-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
max-height: 100vh;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.detail-panel-tabs {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.detail-tab {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
713
web-ui/css/mobile.css
Normal file
713
web-ui/css/mobile.css
Normal 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) {
|
||||
/* 다크모드 색상 조정 필요시 여기에 추가 */
|
||||
}
|
||||
1491
web-ui/css/zone-detail.css
Normal file
1491
web-ui/css/zone-detail.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -91,14 +91,11 @@ function getAuthData() {
|
||||
// ========== 시간 업데이트 ========== //
|
||||
function updateCurrentTime() {
|
||||
const now = new Date();
|
||||
const timeString = now.toLocaleTimeString('ko-KR', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(now.getSeconds()).padStart(2, '0');
|
||||
if (elements.timeValue) {
|
||||
elements.timeValue.textContent = timeString;
|
||||
elements.timeValue.textContent = `${hours}시 ${minutes}분 ${seconds}초`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,412 +0,0 @@
|
||||
/**
|
||||
* annual-vacation-overview.js
|
||||
* 연간 연차 현황 페이지 로직 (2-탭 구조)
|
||||
*/
|
||||
|
||||
import { API_BASE_URL } from './api-config.js';
|
||||
|
||||
// 전역 변수
|
||||
let annualUsageChart = null;
|
||||
let currentYear = new Date().getFullYear();
|
||||
let vacationRequests = [];
|
||||
|
||||
/**
|
||||
* 페이지 초기화
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// 관리자 권한 체크
|
||||
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
const isAdmin = user.role === 'Admin' || [1, 2].includes(user.role_id);
|
||||
|
||||
if (!isAdmin) {
|
||||
alert('관리자만 접근할 수 있습니다');
|
||||
window.location.href = '/pages/dashboard.html';
|
||||
return;
|
||||
}
|
||||
|
||||
initializeYearSelector();
|
||||
initializeMonthSelector();
|
||||
initializeEventListeners();
|
||||
await loadAnnualUsageData();
|
||||
});
|
||||
|
||||
/**
|
||||
* 연도 선택 초기화
|
||||
*/
|
||||
function initializeYearSelector() {
|
||||
const yearSelect = document.getElementById('yearSelect');
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
// 최근 5년, 현재 연도, 다음 연도
|
||||
for (let year = currentYear - 5; year <= currentYear + 1; year++) {
|
||||
const option = document.createElement('option');
|
||||
option.value = year;
|
||||
option.textContent = `${year}년`;
|
||||
if (year === currentYear) {
|
||||
option.selected = true;
|
||||
}
|
||||
yearSelect.appendChild(option);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 월 선택 초기화
|
||||
*/
|
||||
function initializeMonthSelector() {
|
||||
const monthSelect = document.getElementById('monthSelect');
|
||||
const currentMonth = new Date().getMonth() + 1;
|
||||
|
||||
// 현재 월을 기본 선택
|
||||
monthSelect.value = currentMonth;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 리스너 초기화
|
||||
*/
|
||||
function initializeEventListeners() {
|
||||
// 탭 전환
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const tabName = e.target.dataset.tab;
|
||||
switchTab(tabName);
|
||||
});
|
||||
});
|
||||
|
||||
// 조회 버튼
|
||||
document.getElementById('refreshBtn').addEventListener('click', async () => {
|
||||
await loadAnnualUsageData();
|
||||
const activeTab = document.querySelector('.tab-btn.active').dataset.tab;
|
||||
if (activeTab === 'monthlyDetails') {
|
||||
await loadMonthlyDetails();
|
||||
}
|
||||
});
|
||||
|
||||
// 연도 변경 시 자동 조회
|
||||
document.getElementById('yearSelect').addEventListener('change', async () => {
|
||||
await loadAnnualUsageData();
|
||||
const activeTab = document.querySelector('.tab-btn.active').dataset.tab;
|
||||
if (activeTab === 'monthlyDetails') {
|
||||
await loadMonthlyDetails();
|
||||
}
|
||||
});
|
||||
|
||||
// 월 선택 변경 시
|
||||
document.getElementById('monthSelect').addEventListener('change', loadMonthlyDetails);
|
||||
|
||||
// 엑셀 다운로드
|
||||
document.getElementById('exportExcelBtn').addEventListener('click', exportToExcel);
|
||||
}
|
||||
|
||||
/**
|
||||
* 탭 전환
|
||||
*/
|
||||
function switchTab(tabName) {
|
||||
// 탭 버튼 활성화
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
if (btn.dataset.tab === tabName) {
|
||||
btn.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
// 탭 콘텐츠 활성화
|
||||
document.querySelectorAll('.tab-content').forEach(content => {
|
||||
content.classList.remove('active');
|
||||
});
|
||||
|
||||
if (tabName === 'annualUsage') {
|
||||
document.getElementById('annualUsageTab').classList.add('active');
|
||||
} else if (tabName === 'monthlyDetails') {
|
||||
document.getElementById('monthlyDetailsTab').classList.add('active');
|
||||
loadMonthlyDetails();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 연간 사용 데이터 로드 (탭 1)
|
||||
*/
|
||||
async function loadAnnualUsageData() {
|
||||
const year = document.getElementById('yearSelect').value;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
// 해당 연도의 모든 승인된 휴가 신청 조회
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/vacation-requests?start_date=${year}-01-01&end_date=${year}-12-31&status=approved`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('휴가 데이터를 불러오는데 실패했습니다');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
vacationRequests = result.data || [];
|
||||
|
||||
// 월별로 집계
|
||||
const monthlyData = aggregateMonthlyUsage(vacationRequests);
|
||||
|
||||
// 잔여 일수 계산 (올해 총 부여 - 사용)
|
||||
const remainingDays = await calculateRemainingDays(year);
|
||||
|
||||
updateAnnualUsageChart(monthlyData, remainingDays);
|
||||
} catch (error) {
|
||||
console.error('연간 사용 데이터 로드 오류:', error);
|
||||
showToast('데이터를 불러오는데 실패했습니다', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별 사용 일수 집계
|
||||
*/
|
||||
function aggregateMonthlyUsage(requests) {
|
||||
const monthlyUsage = Array(12).fill(0); // 1월~12월
|
||||
|
||||
requests.forEach(req => {
|
||||
const startDate = new Date(req.start_date);
|
||||
const endDate = new Date(req.end_date);
|
||||
const daysUsed = req.days_used || 0;
|
||||
|
||||
// 간단한 집계: 시작일의 월에 모든 일수를 할당
|
||||
// (더 정교한 계산이 필요하면 일자별로 쪼개야 함)
|
||||
const month = startDate.getMonth(); // 0-11
|
||||
monthlyUsage[month] += daysUsed;
|
||||
});
|
||||
|
||||
return monthlyUsage;
|
||||
}
|
||||
|
||||
/**
|
||||
* 잔여 일수 계산
|
||||
*/
|
||||
async function calculateRemainingDays(year) {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
// 전체 작업자의 휴가 잔액 조회
|
||||
const response = await fetch(`${API_BASE_URL}/api/vacation-balances/year/${year}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const balances = result.data || [];
|
||||
|
||||
// 전체 잔여 일수 합계
|
||||
const totalRemaining = balances.reduce((sum, item) => sum + (item.remaining_days || 0), 0);
|
||||
return totalRemaining;
|
||||
} catch (error) {
|
||||
console.error('잔여 일수 계산 오류:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 연간 사용 차트 업데이트
|
||||
*/
|
||||
function updateAnnualUsageChart(monthlyData, remainingDays) {
|
||||
const ctx = document.getElementById('annualUsageChart');
|
||||
|
||||
// 기존 차트 삭제
|
||||
if (annualUsageChart) {
|
||||
annualUsageChart.destroy();
|
||||
}
|
||||
|
||||
const labels = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월', '잔여'];
|
||||
const data = [...monthlyData, remainingDays];
|
||||
|
||||
annualUsageChart = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: '일수',
|
||||
data: data,
|
||||
backgroundColor: data.map((_, idx) =>
|
||||
idx === 12 ? 'rgba(16, 185, 129, 0.8)' : 'rgba(59, 130, 246, 0.8)'
|
||||
),
|
||||
borderColor: data.map((_, idx) =>
|
||||
idx === 12 ? 'rgba(16, 185, 129, 1)' : 'rgba(59, 130, 246, 1)'
|
||||
),
|
||||
borderWidth: 1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
return `${context.parsed.y}일`;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
stepSize: 5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별 상세 기록 로드 (탭 2)
|
||||
*/
|
||||
async function loadMonthlyDetails() {
|
||||
const year = document.getElementById('yearSelect').value;
|
||||
const month = document.getElementById('monthSelect').value;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
// 해당 월의 모든 휴가 신청 조회 (승인된 것만)
|
||||
const startDate = `${year}-${String(month).padStart(2, '0')}-01`;
|
||||
const lastDay = new Date(year, month, 0).getDate();
|
||||
const endDate = `${year}-${String(month).padStart(2, '0')}-${lastDay}`;
|
||||
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/vacation-requests?start_date=${startDate}&end_date=${endDate}&status=approved`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('월별 데이터를 불러오는데 실패했습니다');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const monthlyRequests = result.data || [];
|
||||
|
||||
updateMonthlyTable(monthlyRequests);
|
||||
} catch (error) {
|
||||
console.error('월별 상세 기록 로드 오류:', error);
|
||||
showToast('데이터를 불러오는데 실패했습니다', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별 테이블 업데이트
|
||||
*/
|
||||
function updateMonthlyTable(requests) {
|
||||
const tbody = document.getElementById('monthlyTableBody');
|
||||
|
||||
if (requests.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="7" class="loading-state">
|
||||
<p>데이터가 없습니다</p>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = requests.map(req => {
|
||||
const statusText = req.status === 'approved' ? '승인' : req.status === 'pending' ? '대기' : '거부';
|
||||
const statusClass = req.status === 'approved' ? 'success' : req.status === 'pending' ? 'warning' : 'danger';
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${req.worker_name}</td>
|
||||
<td>${req.vacation_type_name}</td>
|
||||
<td>${req.start_date}</td>
|
||||
<td>${req.end_date}</td>
|
||||
<td>${req.days_used}일</td>
|
||||
<td>${req.reason || '-'}</td>
|
||||
<td><span class="badge badge-${statusClass}">${statusText}</span></td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 엑셀 다운로드
|
||||
*/
|
||||
function exportToExcel() {
|
||||
const year = document.getElementById('yearSelect').value;
|
||||
const month = document.getElementById('monthSelect').value;
|
||||
const tbody = document.getElementById('monthlyTableBody');
|
||||
|
||||
// 테이블에 데이터가 없으면 중단
|
||||
if (!tbody.querySelector('tr:not(.loading-state)')) {
|
||||
showToast('다운로드할 데이터가 없습니다', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// CSV 형식으로 데이터 생성
|
||||
const headers = ['작업자명', '휴가유형', '시작일', '종료일', '사용일수', '사유', '상태'];
|
||||
const rows = Array.from(tbody.querySelectorAll('tr:not(.loading-state)')).map(tr => {
|
||||
const cells = tr.querySelectorAll('td');
|
||||
return Array.from(cells).map(cell => {
|
||||
// badge 클래스가 있으면 텍스트만 추출
|
||||
const badge = cell.querySelector('.badge');
|
||||
return badge ? badge.textContent : cell.textContent;
|
||||
});
|
||||
});
|
||||
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...rows.map(row => row.join(','))
|
||||
].join('\n');
|
||||
|
||||
// BOM 추가 (한글 깨짐 방지)
|
||||
const BOM = '\uFEFF';
|
||||
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', `월별_연차_상세_${year}_${month}월.csv`);
|
||||
link.style.visibility = 'hidden';
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
showToast('엑셀 파일이 다운로드되었습니다', 'success');
|
||||
}
|
||||
|
||||
/**
|
||||
* 토스트 메시지 표시
|
||||
*/
|
||||
function showToast(message, type = 'info') {
|
||||
const container = document.getElementById('toastContainer');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.textContent = message;
|
||||
|
||||
container.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.add('show');
|
||||
}, 10);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
setTimeout(() => {
|
||||
container.removeChild(toast);
|
||||
}, 300);
|
||||
}, 3000);
|
||||
}
|
||||
@@ -49,6 +49,13 @@
|
||||
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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
// ===== 캐시 설정 =====
|
||||
const CACHE_DURATION = 10 * 60 * 1000; // 10분
|
||||
const COMPONENT_CACHE_PREFIX = 'component_';
|
||||
const COMPONENT_CACHE_PREFIX = 'component_v3_';
|
||||
|
||||
// ===== 인증 함수 =====
|
||||
function isLoggedIn() {
|
||||
@@ -368,7 +368,12 @@
|
||||
function updateDateTime() {
|
||||
const now = new Date();
|
||||
const timeEl = document.getElementById('timeValue');
|
||||
if (timeEl) timeEl.textContent = now.toLocaleTimeString('ko-KR', { hour12: false });
|
||||
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) {
|
||||
|
||||
@@ -10,6 +10,18 @@ let selectedWorkplace = null;
|
||||
let itemTypes = []; // 물품 유형
|
||||
let workplaceItems = []; // 현재 작업장 물품
|
||||
let isItemEditMode = false;
|
||||
let workplaceDetail = null; // 작업장 상세 정보
|
||||
|
||||
// XSS 방지를 위한 HTML 이스케이프 함수
|
||||
function escapeHtml(str) {
|
||||
if (str === null || str === undefined) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// 이미지 URL 헬퍼 함수 (정적 파일용 - /api 경로 제외)
|
||||
function getImageUrl(path) {
|
||||
@@ -62,6 +74,77 @@ async function initializePage() {
|
||||
loadItemTypes(),
|
||||
loadTodayStatus()
|
||||
]);
|
||||
|
||||
// 저장된 세션 상태 복원 (구역 상세에서 돌아온 경우)
|
||||
await restoreSessionState();
|
||||
}
|
||||
|
||||
// 세션 상태 저장 (페이지 이동 전)
|
||||
function saveSessionState() {
|
||||
if (currentSession) {
|
||||
const state = {
|
||||
session: currentSession,
|
||||
categoryId: currentSession.category_id,
|
||||
checkRecords: checkRecords,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
sessionStorage.setItem('patrolSessionState', JSON.stringify(state));
|
||||
}
|
||||
}
|
||||
|
||||
// 세션 상태 복원
|
||||
async function restoreSessionState() {
|
||||
const savedState = sessionStorage.getItem('patrolSessionState');
|
||||
if (!savedState) return;
|
||||
|
||||
try {
|
||||
const state = JSON.parse(savedState);
|
||||
// 5분 이내의 상태만 복원
|
||||
if (Date.now() - state.timestamp > 5 * 60 * 1000) {
|
||||
sessionStorage.removeItem('patrolSessionState');
|
||||
return;
|
||||
}
|
||||
|
||||
// categories가 비어있으면 복원 불가
|
||||
if (!categories || categories.length === 0) {
|
||||
console.log('카테고리 목록이 없어 세션 복원 불가');
|
||||
sessionStorage.removeItem('patrolSessionState');
|
||||
return;
|
||||
}
|
||||
|
||||
// 해당 카테고리가 존재하는지 확인
|
||||
const category = categories.find(c => c.category_id == state.categoryId);
|
||||
if (!category) {
|
||||
console.log('저장된 카테고리를 찾을 수 없음:', state.categoryId);
|
||||
sessionStorage.removeItem('patrolSessionState');
|
||||
return;
|
||||
}
|
||||
|
||||
// 세션 복원
|
||||
currentSession = state.session;
|
||||
checkRecords = state.checkRecords || {};
|
||||
|
||||
// 작업장 목록 로드
|
||||
await loadWorkplaces(state.categoryId);
|
||||
|
||||
// 체크리스트 항목 로드
|
||||
await loadChecklistItems(state.categoryId);
|
||||
|
||||
// UI 표시
|
||||
document.getElementById('startPatrolBtn').style.display = 'none';
|
||||
document.getElementById('factorySelectionArea').style.display = 'none';
|
||||
document.getElementById('patrolArea').style.display = 'block';
|
||||
renderSessionInfo();
|
||||
renderWorkplaceMap();
|
||||
|
||||
console.log('세션 상태 복원 완료:', state.categoryId);
|
||||
|
||||
// 복원 후 저장 상태 삭제
|
||||
sessionStorage.removeItem('patrolSessionState');
|
||||
} catch (error) {
|
||||
console.error('세션 상태 복원 실패:', error);
|
||||
sessionStorage.removeItem('patrolSessionState');
|
||||
}
|
||||
}
|
||||
|
||||
// 공장(대분류) 목록 로드
|
||||
@@ -206,10 +289,36 @@ function renderTodayStatus(statusList) {
|
||||
// 작업장 목록 로드
|
||||
async function loadWorkplaces(categoryId) {
|
||||
try {
|
||||
// 작업장 목록 로드
|
||||
const response = await axios.get(`/workplaces?category_id=${categoryId}`);
|
||||
if (response.data.success) {
|
||||
workplaces = response.data.data;
|
||||
}
|
||||
|
||||
// 지도 영역(좌표) 로드
|
||||
try {
|
||||
const regionsResponse = await axios.get(`/workplaces/categories/${categoryId}/map-regions`);
|
||||
if (regionsResponse.data.success && regionsResponse.data.data) {
|
||||
// 작업장에 좌표 정보 병합
|
||||
const regions = regionsResponse.data.data;
|
||||
workplaces = workplaces.map(wp => {
|
||||
const region = regions.find(r => r.workplace_id === wp.workplace_id);
|
||||
if (region) {
|
||||
// x_start, y_start를 x_percent, y_percent로 매핑
|
||||
return {
|
||||
...wp,
|
||||
x_percent: region.x_start,
|
||||
y_percent: region.y_start,
|
||||
x_end: region.x_end,
|
||||
y_end: region.y_end
|
||||
};
|
||||
}
|
||||
return wp;
|
||||
});
|
||||
}
|
||||
} catch (regError) {
|
||||
console.log('지도 영역 로드 스킵:', regError.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('작업장 목록 로드 실패:', error);
|
||||
}
|
||||
@@ -271,30 +380,59 @@ function renderWorkplaceMap() {
|
||||
mapContainer.style.display = 'block';
|
||||
|
||||
// 좌표가 있는 작업장만 마커 추가
|
||||
const hasMarkers = workplaces.some(wp => wp.x_percent && wp.y_percent);
|
||||
const hasMarkers = workplaces.some(wp => wp.x_percent !== undefined && wp.y_percent !== undefined);
|
||||
|
||||
workplaces.forEach(wp => {
|
||||
if (wp.x_percent && wp.y_percent) {
|
||||
const marker = document.createElement('div');
|
||||
marker.className = 'workplace-marker';
|
||||
marker.style.left = `${parseFloat(wp.x_percent) || 0}%`;
|
||||
marker.style.top = `${parseFloat(wp.y_percent) || 0}%`;
|
||||
marker.textContent = wp.workplace_name; // textContent는 자동 이스케이프
|
||||
marker.dataset.workplaceId = wp.workplace_id;
|
||||
marker.onclick = () => selectWorkplace(wp.workplace_id);
|
||||
|
||||
// 점검 상태에 따른 스타일
|
||||
const records = checkRecords[wp.workplace_id];
|
||||
if (records && records.some(r => r.is_checked)) {
|
||||
marker.classList.add(records.every(r => r.is_checked) ? 'completed' : 'in-progress');
|
||||
// 마커 위치 정보를 먼저 계산
|
||||
const markerData = workplaces
|
||||
.filter(wp => wp.x_percent !== undefined && wp.y_percent !== undefined)
|
||||
.map(wp => {
|
||||
let centerX = parseFloat(wp.x_percent) || 0;
|
||||
let centerY = parseFloat(wp.y_percent) || 0;
|
||||
if (wp.x_end && wp.y_end) {
|
||||
centerX = (parseFloat(wp.x_percent) + parseFloat(wp.x_end)) / 2;
|
||||
centerY = (parseFloat(wp.y_percent) + parseFloat(wp.y_end)) / 2;
|
||||
}
|
||||
return { wp, centerX, centerY };
|
||||
});
|
||||
|
||||
mapContainer.appendChild(marker);
|
||||
// y좌표 기준 정렬 (아래에 있을수록 나중에 추가 = 위에 표시)
|
||||
markerData.sort((a, b) => a.centerY - b.centerY);
|
||||
|
||||
markerData.forEach((data, index) => {
|
||||
const { wp, centerX, centerY } = data;
|
||||
const marker = document.createElement('div');
|
||||
marker.className = 'workplace-marker';
|
||||
|
||||
// 밀집도 체크 - 근처에 다른 마커가 있으면 compact 클래스 추가
|
||||
const nearbyMarkers = markerData.filter(other =>
|
||||
other !== data &&
|
||||
Math.abs(other.centerX - centerX) < 12 &&
|
||||
Math.abs(other.centerY - centerY) < 12
|
||||
);
|
||||
if (nearbyMarkers.length > 0) {
|
||||
marker.classList.add('compact');
|
||||
}
|
||||
|
||||
marker.style.left = `${centerX}%`;
|
||||
marker.style.top = `${centerY}%`;
|
||||
// y좌표가 클수록 (아래쪽일수록) z-index가 높아서 위에 표시
|
||||
marker.style.zIndex = Math.floor(centerY) + 10;
|
||||
marker.textContent = wp.workplace_name;
|
||||
marker.dataset.workplaceId = wp.workplace_id;
|
||||
marker.onclick = () => goToZoneDetail(wp.workplace_id);
|
||||
|
||||
// 점검 상태에 따른 스타일
|
||||
const records = checkRecords[wp.workplace_id];
|
||||
if (records && records.some(r => r.is_checked)) {
|
||||
marker.classList.add(records.every(r => r.is_checked) ? 'completed' : 'in-progress');
|
||||
}
|
||||
|
||||
mapContainer.appendChild(marker);
|
||||
});
|
||||
|
||||
// 좌표가 없는 작업장이 있으면 카드 목록도 표시
|
||||
if (!hasMarkers || workplaces.some(wp => !wp.x_percent || !wp.y_percent)) {
|
||||
const hasWorkplacesWithoutCoords = workplaces.some(wp => wp.x_percent === undefined || wp.y_percent === undefined);
|
||||
if (!hasMarkers || hasWorkplacesWithoutCoords) {
|
||||
listContainer.style.display = 'grid';
|
||||
renderWorkplaceCards(listContainer);
|
||||
} else {
|
||||
@@ -308,6 +446,13 @@ function renderWorkplaceMap() {
|
||||
}
|
||||
}
|
||||
|
||||
// 구역 상세 페이지로 이동
|
||||
function goToZoneDetail(workplaceId) {
|
||||
// 현재 세션 상태 저장
|
||||
saveSessionState();
|
||||
window.location.href = `/pages/inspection/zone-detail.html?id=${workplaceId}`;
|
||||
}
|
||||
|
||||
// 작업장 카드 렌더링
|
||||
function renderWorkplaceCards(container) {
|
||||
container.innerHTML = workplaces.map(wp => {
|
||||
@@ -319,7 +464,7 @@ function renderWorkplaceCards(container) {
|
||||
return `
|
||||
<div class="workplace-card ${isCompleted ? 'completed' : ''} ${isInProgress && !isCompleted ? 'in-progress' : ''} ${selectedWorkplace?.workplace_id === wp.workplace_id ? 'selected' : ''}"
|
||||
data-workplace-id="${workplaceId}"
|
||||
onclick="selectWorkplace(${workplaceId})">
|
||||
onclick="goToZoneDetail(${workplaceId})">
|
||||
<div class="workplace-card-name">${escapeHtml(wp.workplace_name)}</div>
|
||||
<div class="workplace-card-status">
|
||||
${isCompleted ? '점검완료' : (isInProgress ? '점검중' : '미점검')}
|
||||
@@ -360,6 +505,9 @@ async function selectWorkplace(workplaceId) {
|
||||
// 물품 현황 로드 및 표시
|
||||
await loadWorkplaceItems(workplaceId);
|
||||
|
||||
// 작업장 상세 정보 로드 (신고, TBM, 출입 등)
|
||||
await loadWorkplaceDetail(workplaceId);
|
||||
|
||||
// 액션 버튼 표시
|
||||
document.getElementById('checklistActions').style.display = 'flex';
|
||||
}
|
||||
@@ -763,5 +911,339 @@ function formatDate(dateStr) {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeItemModal();
|
||||
closeWorkplaceDetailPanel();
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== 작업장 상세 정보 패널 ====================
|
||||
|
||||
// 작업장 상세 정보 로드
|
||||
async function loadWorkplaceDetail(workplaceId) {
|
||||
try {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const response = await axios.get(`/patrol/workplaces/${workplaceId}/detail?date=${today}`);
|
||||
if (response.data.success) {
|
||||
workplaceDetail = response.data.data;
|
||||
renderWorkplaceDetailPanel();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('작업장 상세 정보 로드 실패:', error);
|
||||
// 에러 발생 시에도 기본 패널 표시
|
||||
workplaceDetail = null;
|
||||
renderWorkplaceDetailPanel();
|
||||
}
|
||||
}
|
||||
|
||||
// 상세 정보 패널 렌더링
|
||||
function renderWorkplaceDetailPanel() {
|
||||
let panel = document.getElementById('workplaceDetailPanel');
|
||||
if (!panel) {
|
||||
panel = document.createElement('div');
|
||||
panel.id = 'workplaceDetailPanel';
|
||||
panel.className = 'workplace-detail-panel';
|
||||
document.body.appendChild(panel);
|
||||
}
|
||||
|
||||
if (!workplaceDetail || !selectedWorkplace) {
|
||||
panel.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const { workplace, equipments, repairRequests, workIssues, visitRecords, tbmSessions, recentPatrol, summary } = workplaceDetail;
|
||||
|
||||
panel.innerHTML = `
|
||||
<div class="detail-panel-header">
|
||||
<div class="detail-panel-title">
|
||||
<h3>${escapeHtml(workplace.workplace_name)}</h3>
|
||||
<span class="detail-panel-subtitle">${escapeHtml(workplace.category_name || '')}</span>
|
||||
</div>
|
||||
<button class="detail-panel-close" onclick="closeWorkplaceDetailPanel()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="detail-panel-summary">
|
||||
<div class="summary-item">
|
||||
<span class="summary-value">${summary.equipmentCount}</span>
|
||||
<span class="summary-label">설비</span>
|
||||
</div>
|
||||
<div class="summary-item ${summary.pendingRepairs > 0 ? 'warning' : ''}">
|
||||
<span class="summary-value">${summary.pendingRepairs}</span>
|
||||
<span class="summary-label">수리요청</span>
|
||||
</div>
|
||||
<div class="summary-item ${summary.openIssues > 0 ? 'danger' : ''}">
|
||||
<span class="summary-value">${summary.openIssues}</span>
|
||||
<span class="summary-label">미해결 신고</span>
|
||||
</div>
|
||||
<div class="summary-item info">
|
||||
<span class="summary-value">${summary.todayVisitors}</span>
|
||||
<span class="summary-label">금일 방문자</span>
|
||||
</div>
|
||||
<div class="summary-item info">
|
||||
<span class="summary-value">${summary.todayTbmSessions}</span>
|
||||
<span class="summary-label">금일 TBM</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-panel-tabs">
|
||||
<button class="detail-tab active" data-tab="issues" onclick="switchDetailTab('issues')">
|
||||
🚨 신고/부적합 <span class="tab-badge ${workIssues.all.length > 0 ? 'show' : ''}">${workIssues.all.length}</span>
|
||||
</button>
|
||||
<button class="detail-tab" data-tab="equipment" onclick="switchDetailTab('equipment')">
|
||||
⚙️ 설비 <span class="tab-badge ${summary.needsAttention > 0 ? 'show warning' : ''}">${summary.needsAttention}</span>
|
||||
</button>
|
||||
<button class="detail-tab" data-tab="visits" onclick="switchDetailTab('visits')">
|
||||
🚶 출입 <span class="tab-badge ${visitRecords.length > 0 ? 'show' : ''}">${visitRecords.length}</span>
|
||||
</button>
|
||||
<button class="detail-tab" data-tab="tbm" onclick="switchDetailTab('tbm')">
|
||||
📋 TBM <span class="tab-badge ${tbmSessions.length > 0 ? 'show' : ''}">${tbmSessions.length}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="detail-panel-content">
|
||||
<!-- 신고/부적합 탭 -->
|
||||
<div class="detail-tab-content active" id="tab-issues">
|
||||
${renderIssuesTab(workIssues)}
|
||||
</div>
|
||||
|
||||
<!-- 설비 탭 -->
|
||||
<div class="detail-tab-content" id="tab-equipment">
|
||||
${renderEquipmentTab(equipments, repairRequests)}
|
||||
</div>
|
||||
|
||||
<!-- 출입 탭 -->
|
||||
<div class="detail-tab-content" id="tab-visits">
|
||||
${renderVisitsTab(visitRecords)}
|
||||
</div>
|
||||
|
||||
<!-- TBM 탭 -->
|
||||
<div class="detail-tab-content" id="tab-tbm">
|
||||
${renderTbmTab(tbmSessions)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
panel.style.display = 'flex';
|
||||
}
|
||||
|
||||
// 신고/부적합 탭 렌더링
|
||||
function renderIssuesTab(workIssues) {
|
||||
if (!workIssues.all.length) {
|
||||
return `<div class="detail-empty">최근 30일간 신고 내역이 없습니다.</div>`;
|
||||
}
|
||||
|
||||
const safetyIssues = workIssues.safety;
|
||||
const nonconformityIssues = workIssues.nonconformity;
|
||||
|
||||
let html = '';
|
||||
|
||||
if (safetyIssues.length > 0) {
|
||||
html += `
|
||||
<div class="issue-section">
|
||||
<h4 class="issue-section-title">🛡️ 안전 신고 (${safetyIssues.length})</h4>
|
||||
${safetyIssues.map(issue => renderIssueItem(issue)).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (nonconformityIssues.length > 0) {
|
||||
html += `
|
||||
<div class="issue-section">
|
||||
<h4 class="issue-section-title">⚠️ 부적합 사항 (${nonconformityIssues.length})</h4>
|
||||
${nonconformityIssues.map(issue => renderIssueItem(issue)).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html || `<div class="detail-empty">신고 내역이 없습니다.</div>`;
|
||||
}
|
||||
|
||||
// 신고 항목 렌더링
|
||||
function renderIssueItem(issue) {
|
||||
const statusColors = {
|
||||
'pending': 'pending',
|
||||
'received': 'info',
|
||||
'in_progress': 'warning',
|
||||
'completed': 'success',
|
||||
'closed': 'muted'
|
||||
};
|
||||
const statusLabels = {
|
||||
'pending': '대기',
|
||||
'received': '접수',
|
||||
'in_progress': '처리중',
|
||||
'completed': '완료',
|
||||
'closed': '종료'
|
||||
};
|
||||
const severityLabels = {
|
||||
'low': '경미',
|
||||
'medium': '보통',
|
||||
'high': '중요',
|
||||
'critical': '긴급'
|
||||
};
|
||||
|
||||
return `
|
||||
<div class="issue-item ${statusColors[issue.status] || ''}">
|
||||
<div class="issue-item-header">
|
||||
<span class="issue-title">${escapeHtml(issue.title)}</span>
|
||||
<span class="issue-status ${statusColors[issue.status] || ''}">${statusLabels[issue.status] || issue.status}</span>
|
||||
</div>
|
||||
<div class="issue-item-meta">
|
||||
<span class="issue-category">${escapeHtml(issue.category_name || '')}</span>
|
||||
<span class="issue-severity ${issue.severity}">${severityLabels[issue.severity] || ''}</span>
|
||||
<span class="issue-date">${formatDateTime(issue.created_at)}</span>
|
||||
</div>
|
||||
${issue.description ? `<div class="issue-desc">${escapeHtml(issue.description).slice(0, 100)}${issue.description.length > 100 ? '...' : ''}</div>` : ''}
|
||||
<div class="issue-reporter">신고자: ${escapeHtml(issue.reporter_name || '익명')}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 설비 탭 렌더링
|
||||
function renderEquipmentTab(equipments, repairRequests) {
|
||||
let html = '';
|
||||
|
||||
// 수리 요청 먼저 표시
|
||||
if (repairRequests.length > 0) {
|
||||
html += `
|
||||
<div class="equipment-section">
|
||||
<h4 class="equipment-section-title">🔧 수리 요청 (${repairRequests.length})</h4>
|
||||
${repairRequests.map(req => `
|
||||
<div class="repair-item ${req.priority}">
|
||||
<div class="repair-item-header">
|
||||
<span class="repair-equipment">${escapeHtml(req.equipment_name)} (${escapeHtml(req.equipment_code)})</span>
|
||||
<span class="repair-priority ${req.priority}">${getPriorityLabel(req.priority)}</span>
|
||||
</div>
|
||||
<div class="repair-category">${escapeHtml(req.repair_category)}</div>
|
||||
<div class="repair-desc">${escapeHtml(req.description || '')}</div>
|
||||
<div class="repair-date">${formatDate(req.request_date)}</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 설비 목록
|
||||
if (equipments.length > 0) {
|
||||
html += `
|
||||
<div class="equipment-section">
|
||||
<h4 class="equipment-section-title">📦 설비 현황 (${equipments.length})</h4>
|
||||
<div class="equipment-list">
|
||||
${equipments.map(eq => `
|
||||
<div class="equipment-item ${eq.needs_attention ? 'attention' : ''}">
|
||||
<span class="equipment-name">${escapeHtml(eq.equipment_name)}</span>
|
||||
<span class="equipment-code">${escapeHtml(eq.equipment_code)}</span>
|
||||
<span class="equipment-status ${eq.status}">${getEquipmentStatusLabel(eq.status)}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
html += `<div class="detail-empty">등록된 설비가 없습니다.</div>`;
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
// 출입 탭 렌더링
|
||||
function renderVisitsTab(visitRecords) {
|
||||
if (!visitRecords.length) {
|
||||
return `<div class="detail-empty">금일 승인된 방문자가 없습니다.</div>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="visits-list">
|
||||
${visitRecords.map(visit => `
|
||||
<div class="visit-item">
|
||||
<div class="visit-item-header">
|
||||
<span class="visit-name">${escapeHtml(visit.visitor_name)}</span>
|
||||
<span class="visit-company">${escapeHtml(visit.visitor_company || '')}</span>
|
||||
</div>
|
||||
<div class="visit-purpose">${escapeHtml(visit.purpose_name || visit.visit_purpose || '')}</div>
|
||||
<div class="visit-time">
|
||||
🕐 ${escapeHtml(visit.visit_time_from || '')} ~ ${escapeHtml(visit.visit_time_to || '')}
|
||||
</div>
|
||||
${visit.companion_count > 0 ? `<div class="visit-companion">동행 ${visit.companion_count}명</div>` : ''}
|
||||
${visit.vehicle_number ? `<div class="visit-vehicle">🚗 ${escapeHtml(visit.vehicle_number)}</div>` : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// TBM 탭 렌더링
|
||||
function renderTbmTab(tbmSessions) {
|
||||
if (!tbmSessions.length) {
|
||||
return `<div class="detail-empty">금일 TBM 세션이 없습니다.</div>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="tbm-list">
|
||||
${tbmSessions.map(tbm => `
|
||||
<div class="tbm-item">
|
||||
<div class="tbm-item-header">
|
||||
<span class="tbm-task">${escapeHtml(tbm.task_name || tbm.work_type_name || '작업')}</span>
|
||||
<span class="tbm-status ${tbm.status}">${getTbmStatusLabel(tbm.status)}</span>
|
||||
</div>
|
||||
<div class="tbm-location">📍 ${escapeHtml(tbm.work_location || '')}</div>
|
||||
<div class="tbm-leader">👷 ${escapeHtml(tbm.leader_name || tbm.leader_worker_name || '')}</div>
|
||||
${tbm.work_content ? `<div class="tbm-content">작업내용: ${escapeHtml(tbm.work_content).slice(0, 80)}...</div>` : ''}
|
||||
${tbm.team && tbm.team.length > 0 ? `
|
||||
<div class="tbm-team">
|
||||
<span class="tbm-team-label">팀원 (${tbm.team.length}명):</span>
|
||||
<span class="tbm-team-names">${tbm.team.map(m => escapeHtml(m.worker_name)).join(', ')}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${tbm.safety_measures ? `<div class="tbm-safety">⚠️ ${escapeHtml(tbm.safety_measures).slice(0, 60)}...</div>` : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 탭 전환
|
||||
function switchDetailTab(tabName) {
|
||||
// 탭 버튼 활성화
|
||||
document.querySelectorAll('.detail-tab').forEach(tab => {
|
||||
tab.classList.toggle('active', tab.dataset.tab === tabName);
|
||||
});
|
||||
// 탭 콘텐츠 표시
|
||||
document.querySelectorAll('.detail-tab-content').forEach(content => {
|
||||
content.classList.toggle('active', content.id === `tab-${tabName}`);
|
||||
});
|
||||
}
|
||||
|
||||
// 상세 패널 닫기
|
||||
function closeWorkplaceDetailPanel() {
|
||||
const panel = document.getElementById('workplaceDetailPanel');
|
||||
if (panel) {
|
||||
panel.style.display = 'none';
|
||||
}
|
||||
workplaceDetail = null;
|
||||
}
|
||||
|
||||
// 헬퍼 함수들
|
||||
function getPriorityLabel(priority) {
|
||||
const labels = { 'emergency': '긴급', 'high': '높음', 'normal': '보통', 'low': '낮음' };
|
||||
return labels[priority] || priority;
|
||||
}
|
||||
|
||||
function getEquipmentStatusLabel(status) {
|
||||
const labels = {
|
||||
'active': '정상',
|
||||
'inactive': '비활성',
|
||||
'repair_needed': '수리필요',
|
||||
'under_repair': '수리중',
|
||||
'disposed': '폐기'
|
||||
};
|
||||
return labels[status] || status;
|
||||
}
|
||||
|
||||
function getTbmStatusLabel(status) {
|
||||
const labels = { 'draft': '작성중', 'in_progress': '진행중', 'completed': '완료' };
|
||||
return labels[status] || status;
|
||||
}
|
||||
|
||||
function formatDateTime(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString('ko-KR', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
@@ -267,9 +267,14 @@ function renderTbmWorkList() {
|
||||
// 수동 입력 섹션 먼저 추가 (맨 위)
|
||||
html += `
|
||||
<div class="tbm-session-group manual-input-section">
|
||||
<div class="tbm-session-header" style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);">
|
||||
<span class="tbm-session-badge" style="background-color: #92400e; color: white;">수동 입력</span>
|
||||
<span class="tbm-session-info" style="color: white; font-weight: 500;">TBM에 없는 작업을 추가로 입력할 수 있습니다</span>
|
||||
<div class="tbm-session-header" style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem;">
|
||||
<div>
|
||||
<span class="tbm-session-badge" style="background-color: #92400e; color: white;">수동 입력</span>
|
||||
<span class="tbm-session-info" style="color: white; font-weight: 500;">TBM에 없는 작업을 추가로 입력할 수 있습니다</span>
|
||||
</div>
|
||||
<button type="button" class="btn-batch-submit" onclick="submitAllManualWorkReports()" style="background: #fff; color: #d97706; border: none; padding: 0.4rem 0.8rem; border-radius: 4px; font-weight: 600; cursor: pointer; font-size: 0.8rem;">
|
||||
📤 일괄 제출
|
||||
</button>
|
||||
</div>
|
||||
<div class="tbm-table-container">
|
||||
<table class="tbm-work-table">
|
||||
@@ -550,7 +555,8 @@ window.submitTbmWorkReport = async function(index) {
|
||||
|
||||
// 총 부적합 시간 계산
|
||||
const errorHours = defects.reduce((sum, d) => sum + (parseFloat(d.defect_hours) || 0), 0);
|
||||
const errorTypeId = defects.length > 0 && defects[0].error_type_id ? defects[0].error_type_id : null;
|
||||
// item_id를 error_type_id로 사용 (issue_report_items.item_id)
|
||||
const errorTypeId = defects.length > 0 ? (defects[0].error_type_id || defects[0].item_id || null) : null;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!totalHours || totalHours <= 0) {
|
||||
@@ -573,7 +579,7 @@ window.submitTbmWorkReport = async function(index) {
|
||||
_saved: d._saved
|
||||
})));
|
||||
|
||||
const invalidDefects = defects.filter(d => d.defect_hours > 0 && !d.error_type_id && !d.issue_report_id && !d.category_id);
|
||||
const invalidDefects = defects.filter(d => d.defect_hours > 0 && !d.error_type_id && !d.issue_report_id && !d.category_id && !d.item_id);
|
||||
if (invalidDefects.length > 0) {
|
||||
console.error('❌ 유효하지 않은 부적합:', invalidDefects);
|
||||
showMessage('부적합 시간이 있는 항목은 원인을 선택해주세요.', 'error');
|
||||
@@ -592,7 +598,7 @@ window.submitTbmWorkReport = async function(index) {
|
||||
tbm_session_id: tbm.session_id,
|
||||
worker_id: tbm.worker_id,
|
||||
project_id: tbm.project_id,
|
||||
work_type_id: tbm.work_type_id,
|
||||
work_type_id: tbm.task_id, // task_id를 work_type_id 컬럼에 저장 (직접 작업보고서와 일관성 유지)
|
||||
report_date: reportDate,
|
||||
start_time: null,
|
||||
end_time: null,
|
||||
@@ -614,7 +620,7 @@ window.submitTbmWorkReport = async function(index) {
|
||||
|
||||
// 부적합 원인이 있으면 저장 (이슈 기반 또는 레거시)
|
||||
if (defects.length > 0 && response.data?.report_id) {
|
||||
const validDefects = defects.filter(d => (d.issue_report_id || d.category_id || d.error_type_id) && d.defect_hours > 0);
|
||||
const validDefects = defects.filter(d => (d.issue_report_id || d.category_id || d.item_id || d.error_type_id) && d.defect_hours > 0);
|
||||
console.log('📋 부적합 원인 필터링:', {
|
||||
전체: defects.length,
|
||||
유효: validDefects.length,
|
||||
@@ -729,7 +735,7 @@ window.batchSubmitTbmSession = async function(sessionKey) {
|
||||
tbm_session_id: tbm.session_id,
|
||||
worker_id: tbm.worker_id,
|
||||
project_id: tbm.project_id,
|
||||
work_type_id: tbm.work_type_id,
|
||||
work_type_id: tbm.task_id, // task_id를 work_type_id 컬럼에 저장 (일관성 유지)
|
||||
report_date: reportDate,
|
||||
start_time: null,
|
||||
end_time: null,
|
||||
@@ -1032,7 +1038,12 @@ window.openWorkplaceMapForManual = async function(manualIndex) {
|
||||
${safeName}
|
||||
</button>
|
||||
`;
|
||||
}).join('');
|
||||
}).join('') + `
|
||||
<button type="button" class="btn btn-secondary" style="width: 100%; text-align: left; margin-top: 0.5rem; background-color: #f0f9ff; border-color: #0ea5e9;" onclick='selectExternalWorkplace()'>
|
||||
<span style="margin-right: 0.5rem;">🌐</span>
|
||||
외부 (외근/연차/휴무 등)
|
||||
</button>
|
||||
`;
|
||||
|
||||
// 카테고리 선택 화면 표시
|
||||
document.getElementById('categorySelectionArea').style.display = 'block';
|
||||
@@ -1307,6 +1318,43 @@ window.closeWorkplaceModal = function() {
|
||||
mapRegions = [];
|
||||
};
|
||||
|
||||
/**
|
||||
* 외부 작업장소 선택 (외근/연차/휴무 등)
|
||||
*/
|
||||
window.selectExternalWorkplace = function() {
|
||||
const manualIndex = window.currentManualIndex;
|
||||
|
||||
// 외부 작업장소 ID는 0 또는 특별한 값으로 설정 (DB에 저장시 처리 필요)
|
||||
const externalCategoryId = 0;
|
||||
const externalCategoryName = '외부';
|
||||
const externalWorkplaceId = 0;
|
||||
const externalWorkplaceName = '외부 (외근/연차/휴무)';
|
||||
|
||||
// hidden input에 값 설정
|
||||
document.getElementById(`workplaceCategory_${manualIndex}`).value = externalCategoryId;
|
||||
document.getElementById(`workplace_${manualIndex}`).value = externalWorkplaceId;
|
||||
|
||||
// 선택 결과 표시
|
||||
const displayDiv = document.getElementById(`workplaceDisplay_${manualIndex}`);
|
||||
if (displayDiv) {
|
||||
displayDiv.innerHTML = `
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.75rem; color: #0284c7; font-weight: 600;">
|
||||
<span>✓</span>
|
||||
<span>외부 선택됨</span>
|
||||
</div>
|
||||
<div style="font-size: 0.8rem; color: #111827; font-weight: 500;">
|
||||
<div>🌐 ${escapeHtml(externalWorkplaceName)}</div>
|
||||
</div>
|
||||
`;
|
||||
displayDiv.style.background = '#f0f9ff';
|
||||
displayDiv.style.borderColor = '#0ea5e9';
|
||||
}
|
||||
|
||||
// 모달 닫기
|
||||
document.getElementById('workplaceModal').style.display = 'none';
|
||||
showMessage('외부 작업장소가 선택되었습니다.', 'success');
|
||||
};
|
||||
|
||||
/**
|
||||
* 수동 작업보고서 제출
|
||||
*/
|
||||
@@ -1323,7 +1371,8 @@ window.submitManualWorkReport = async function(manualIndex) {
|
||||
// 부적합 원인 가져오기
|
||||
const defects = tempDefects[manualIndex] || [];
|
||||
const errorHours = defects.reduce((sum, d) => sum + (parseFloat(d.defect_hours) || 0), 0);
|
||||
const errorTypeId = defects.length > 0 && defects[0].error_type_id ? defects[0].error_type_id : null;
|
||||
// item_id를 error_type_id로 사용 (issue_report_items.item_id)
|
||||
const errorTypeId = defects.length > 0 ? (defects[0].error_type_id || defects[0].item_id || null) : null;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!workerId) {
|
||||
@@ -1346,7 +1395,7 @@ window.submitManualWorkReport = async function(manualIndex) {
|
||||
showMessage('작업을 선택해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
if (!workplaceId) {
|
||||
if (workplaceId === '' || workplaceId === null || workplaceId === undefined) {
|
||||
showMessage('작업장소를 선택해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
@@ -1361,59 +1410,234 @@ window.submitManualWorkReport = async function(manualIndex) {
|
||||
}
|
||||
|
||||
// 부적합 원인 유효성 검사 (issue_report_id 또는 error_type_id 필요)
|
||||
const invalidDefects = defects.filter(d => d.defect_hours > 0 && !d.error_type_id && !d.issue_report_id && !d.category_id);
|
||||
const invalidDefects = defects.filter(d => d.defect_hours > 0 && !d.error_type_id && !d.issue_report_id && !d.category_id && !d.item_id);
|
||||
if (invalidDefects.length > 0) {
|
||||
showMessage('부적합 시간이 있는 항목은 원인을 선택해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 서비스 레이어가 기대하는 형식으로 변환
|
||||
// 주의: 서비스에서 task_id를 work_type_id 컬럼에 매핑함
|
||||
const reportData = {
|
||||
worker_id: parseInt(workerId),
|
||||
project_id: parseInt(projectId),
|
||||
work_type_id: parseInt(workTypeId),
|
||||
task_id: parseInt(taskId),
|
||||
report_date: reportDate,
|
||||
workplace_category_id: parseInt(workplaceCategoryId),
|
||||
workplace_id: parseInt(workplaceId),
|
||||
start_time: null,
|
||||
end_time: null,
|
||||
total_hours: totalHours,
|
||||
error_hours: errorHours,
|
||||
error_type_id: errorTypeId ? parseInt(errorTypeId) : null,
|
||||
work_status_id: errorHours > 0 ? 2 : 1
|
||||
worker_id: parseInt(workerId),
|
||||
work_entries: [{
|
||||
project_id: parseInt(projectId),
|
||||
task_id: parseInt(taskId), // 서비스에서 work_type_id로 매핑됨
|
||||
work_hours: totalHours,
|
||||
work_status_id: errorHours > 0 ? 2 : 1,
|
||||
error_type_id: errorTypeId ? parseInt(errorTypeId) : null
|
||||
}]
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await window.apiCall('/daily-work-reports', 'POST', reportData);
|
||||
// 429 오류 재시도 로직 포함
|
||||
let response;
|
||||
let retries = 3;
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
response = await window.apiCall('/daily-work-reports', 'POST', reportData);
|
||||
break;
|
||||
} catch (err) {
|
||||
if ((err.message?.includes('429') || err.message?.includes('너무 많은 요청')) && i < retries - 1) {
|
||||
const waitTime = (i + 1) * 2000;
|
||||
showMessage(`서버가 바쁩니다. ${waitTime/1000}초 후 재시도...`, 'loading');
|
||||
await new Promise(r => setTimeout(r, waitTime));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || '작업보고서 제출 실패');
|
||||
}
|
||||
|
||||
// 부적합 원인이 있으면 저장 (이슈 기반 또는 레거시)
|
||||
if (defects.length > 0 && response.data?.workReport_ids?.[0]) {
|
||||
const validDefects = defects.filter(d => (d.issue_report_id || d.category_id || d.error_type_id) && d.defect_hours > 0);
|
||||
const reportId = response.data?.inserted_ids?.[0] || response.data?.workReport_ids?.[0];
|
||||
if (defects.length > 0 && reportId) {
|
||||
const validDefects = defects.filter(d => (d.issue_report_id || d.category_id || d.item_id || d.error_type_id) && d.defect_hours > 0);
|
||||
if (validDefects.length > 0) {
|
||||
await window.apiCall(`/daily-work-reports/${response.data.workReport_ids[0]}/defects`, 'PUT', {
|
||||
await window.apiCall(`/daily-work-reports/${reportId}/defects`, 'PUT', {
|
||||
defects: validDefects
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
showSaveResultModal(
|
||||
'success',
|
||||
'작업보고서 제출 완료',
|
||||
'작업보고서가 성공적으로 제출되었습니다.'
|
||||
);
|
||||
|
||||
// 행 제거 (부적합 임시 데이터도 함께 삭제됨)
|
||||
removeManualWorkRow(manualIndex);
|
||||
|
||||
// 목록 새로고침
|
||||
await loadIncompleteTbms();
|
||||
showMessage('작업보고서가 제출되었습니다.', 'success');
|
||||
|
||||
// 남은 행이 없으면 완료 메시지
|
||||
const remainingRows = document.querySelectorAll('#manualWorkTableBody tr[data-index]');
|
||||
if (remainingRows.length === 0) {
|
||||
showSaveResultModal(
|
||||
'success',
|
||||
'작업보고서 제출 완료',
|
||||
'모든 작업보고서가 성공적으로 제출되었습니다.'
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('수동 작업보고서 제출 오류:', error);
|
||||
showSaveResultModal('error', '제출 실패', error.message);
|
||||
showMessage('제출 실패: ' + error.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 딜레이 함수
|
||||
*/
|
||||
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
/**
|
||||
* API 호출 (429 재시도 포함)
|
||||
*/
|
||||
async function apiCallWithRetry(url, method, data, maxRetries = 3) {
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const response = await window.apiCall(url, method, data);
|
||||
return response;
|
||||
} catch (error) {
|
||||
// 429 Rate Limit 오류인 경우 재시도
|
||||
if (error.message && error.message.includes('429') || error.message.includes('너무 많은 요청')) {
|
||||
if (attempt < maxRetries) {
|
||||
const waitTime = attempt * 2000; // 2초, 4초, 6초 대기
|
||||
console.log(`Rate limit 도달. ${waitTime/1000}초 후 재시도... (${attempt}/${maxRetries})`);
|
||||
await delay(waitTime);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 수동 작업보고서 일괄 제출
|
||||
*/
|
||||
window.submitAllManualWorkReports = async function() {
|
||||
const rows = document.querySelectorAll('#manualWorkTableBody tr[data-index]');
|
||||
|
||||
if (rows.length === 0) {
|
||||
showMessage('제출할 작업보고서가 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 확인 다이얼로그
|
||||
if (!confirm(`${rows.length}개의 작업보고서를 일괄 제출하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
const errors = [];
|
||||
let currentIndex = 0;
|
||||
|
||||
showMessage(`작업보고서 제출 중... (0/${rows.length})`, 'loading');
|
||||
|
||||
// 각 행을 순차적으로 제출 (딜레이 포함)
|
||||
for (const row of rows) {
|
||||
currentIndex++;
|
||||
const manualIndex = row.dataset.index;
|
||||
|
||||
// Rate Limit 방지를 위한 딜레이 (1초)
|
||||
if (currentIndex > 1) {
|
||||
await delay(1000);
|
||||
}
|
||||
|
||||
showMessage(`작업보고서 제출 중... (${currentIndex}/${rows.length})`, 'loading');
|
||||
|
||||
try {
|
||||
const workerId = document.getElementById(`worker_${manualIndex}`).value;
|
||||
const reportDate = document.getElementById(`date_${manualIndex}`).value;
|
||||
const projectId = document.getElementById(`project_${manualIndex}`).value;
|
||||
const workTypeId = document.getElementById(`workType_${manualIndex}`).value;
|
||||
const taskId = document.getElementById(`task_${manualIndex}`).value;
|
||||
const workplaceCategoryId = document.getElementById(`workplaceCategory_${manualIndex}`).value;
|
||||
const workplaceId = document.getElementById(`workplace_${manualIndex}`).value;
|
||||
const totalHours = parseFloat(document.getElementById(`totalHours_${manualIndex}`).value);
|
||||
|
||||
// 부적합 원인 가져오기
|
||||
const defects = tempDefects[manualIndex] || [];
|
||||
const errorHours = defects.reduce((sum, d) => sum + (parseFloat(d.defect_hours) || 0), 0);
|
||||
// item_id를 error_type_id로 사용 (issue_report_items.item_id)
|
||||
const errorTypeId = defects.length > 0 ? (defects[0].error_type_id || defects[0].item_id || null) : null;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!workerId || !reportDate || !projectId || !workTypeId || !taskId || !totalHours || totalHours <= 0) {
|
||||
errors.push(`행 ${manualIndex}: 필수 항목 누락`);
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (workplaceId === '' || workplaceId === null || workplaceId === undefined) {
|
||||
errors.push(`행 ${manualIndex}: 작업장소 미선택`);
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 서비스 레이어가 기대하는 형식으로 변환
|
||||
const reportData = {
|
||||
report_date: reportDate,
|
||||
worker_id: parseInt(workerId),
|
||||
work_entries: [{
|
||||
project_id: parseInt(projectId),
|
||||
task_id: parseInt(taskId),
|
||||
work_hours: totalHours,
|
||||
work_status_id: errorHours > 0 ? 2 : 1,
|
||||
error_type_id: errorTypeId ? parseInt(errorTypeId) : null
|
||||
}]
|
||||
};
|
||||
|
||||
const response = await apiCallWithRetry('/daily-work-reports', 'POST', reportData);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || '작업보고서 제출 실패');
|
||||
}
|
||||
|
||||
// 부적합 원인이 있으면 저장
|
||||
const reportId = response.data?.inserted_ids?.[0] || response.data?.workReport_ids?.[0];
|
||||
if (defects.length > 0 && reportId) {
|
||||
const validDefects = defects.filter(d => (d.issue_report_id || d.category_id || d.item_id || d.error_type_id) && d.defect_hours > 0);
|
||||
if (validDefects.length > 0) {
|
||||
await apiCallWithRetry(`/daily-work-reports/${reportId}/defects`, 'PUT', {
|
||||
defects: validDefects
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 성공 - 행 제거
|
||||
removeManualWorkRow(manualIndex);
|
||||
successCount++;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`행 ${manualIndex} 제출 오류:`, error);
|
||||
errors.push(`행 ${manualIndex}: ${error.message}`);
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 로딩 메시지 숨기기
|
||||
hideMessage();
|
||||
|
||||
// 결과 표시
|
||||
let resultMessage = `성공: ${successCount}건`;
|
||||
if (failCount > 0) {
|
||||
resultMessage += `, 실패: ${failCount}건`;
|
||||
}
|
||||
|
||||
if (failCount > 0 && errors.length > 0) {
|
||||
showSaveResultModal(
|
||||
'warning',
|
||||
'일괄 제출 완료 (일부 실패)',
|
||||
`${resultMessage}\n\n실패 원인:\n${errors.slice(0, 5).join('\n')}${errors.length > 5 ? `\n... 외 ${errors.length - 5}건` : ''}`
|
||||
);
|
||||
} else {
|
||||
showSaveResultModal(
|
||||
'success',
|
||||
'일괄 제출 완료',
|
||||
resultMessage
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1506,6 +1730,10 @@ function renderCompletedReports(reports) {
|
||||
<span class="label">공정:</span>
|
||||
<span class="value">${escapeHtml(report.work_type_name || '-')}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">작업:</span>
|
||||
<span class="value">${escapeHtml(report.task_name || '-')}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">작업시간:</span>
|
||||
<span class="value">${parseFloat(report.total_hours || report.work_hours || 0)}시간</span>
|
||||
@@ -1537,12 +1765,207 @@ function renderCompletedReports(reports) {
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="report-actions" style="display: flex; gap: 0.5rem; margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid #e5e7eb;">
|
||||
<button type="button" class="btn btn-sm btn-secondary" onclick='openEditReportModal(${JSON.stringify(report).replace(/'/g, "'")})' style="flex: 1; padding: 0.4rem 0.6rem; font-size: 0.75rem;">
|
||||
✏️ 수정
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-danger" onclick="deleteWorkReport(${report.id})" style="flex: 1; padding: 0.4rem 0.6rem; font-size: 0.75rem; background: #fee2e2; color: #dc2626; border: 1px solid #fecaca;">
|
||||
🗑️ 삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업보고서 수정 모달 열기
|
||||
*/
|
||||
window.openEditReportModal = function(report) {
|
||||
// 수정 모달이 없으면 동적 생성
|
||||
let modal = document.getElementById('editReportModal');
|
||||
if (!modal) {
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'editReportModal';
|
||||
modal.className = 'modal-overlay';
|
||||
modal.style.cssText = 'display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); z-index: 1003; align-items: center; justify-content: center;';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-container" style="background: white; border-radius: 8px; max-width: 500px; width: 90%; max-height: 90vh; overflow-y: auto; margin: auto; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);">
|
||||
<div class="modal-header" style="padding: 1rem 1.5rem; border-bottom: 1px solid #e5e7eb; display: flex; justify-content: space-between; align-items: center;">
|
||||
<h2 style="font-size: 1.1rem; font-weight: 600; color: #111827; margin: 0;">작업보고서 수정</h2>
|
||||
<button class="modal-close" onclick="closeEditReportModal()" style="background: none; border: none; font-size: 1.5rem; cursor: pointer; color: #6b7280;">×</button>
|
||||
</div>
|
||||
<div class="modal-body" style="padding: 1.5rem;">
|
||||
<form id="editReportForm">
|
||||
<input type="hidden" id="editReportId">
|
||||
|
||||
<div class="form-group" style="margin-bottom: 1rem;">
|
||||
<label style="display: block; font-size: 0.875rem; font-weight: 500; margin-bottom: 0.25rem;">작업자</label>
|
||||
<input type="text" id="editWorkerName" readonly style="width: 100%; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 4px; background: #f3f4f6;">
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom: 1rem;">
|
||||
<label style="display: block; font-size: 0.875rem; font-weight: 500; margin-bottom: 0.25rem;">프로젝트</label>
|
||||
<select id="editProjectId" class="form-input" style="width: 100%; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 4px;">
|
||||
${projects.map(p => `<option value="${p.project_id}">${escapeHtml(p.project_name)}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom: 1rem;">
|
||||
<label style="display: block; font-size: 0.875rem; font-weight: 500; margin-bottom: 0.25rem;">공정</label>
|
||||
<select id="editWorkTypeId" class="form-input" style="width: 100%; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 4px;" onchange="loadTasksForEdit()">
|
||||
${workTypes.map(wt => `<option value="${wt.id}">${escapeHtml(wt.name)}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom: 1rem;">
|
||||
<label style="display: block; font-size: 0.875rem; font-weight: 500; margin-bottom: 0.25rem;">작업</label>
|
||||
<select id="editTaskId" class="form-input" style="width: 100%; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 4px;">
|
||||
<option value="">작업 선택</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom: 1rem;">
|
||||
<label style="display: block; font-size: 0.875rem; font-weight: 500; margin-bottom: 0.25rem;">작업시간 (시간)</label>
|
||||
<input type="number" id="editWorkHours" step="0.5" min="0" max="24" class="form-input" style="width: 100%; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 4px;">
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom: 1rem;">
|
||||
<label style="display: block; font-size: 0.875rem; font-weight: 500; margin-bottom: 0.25rem;">작업상태</label>
|
||||
<select id="editWorkStatusId" class="form-input" style="width: 100%; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 4px;">
|
||||
${workStatusTypes.map(ws => `<option value="${ws.id}">${escapeHtml(ws.name)}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer" style="padding: 1rem 1.5rem; border-top: 1px solid #e5e7eb; display: flex; gap: 0.75rem; justify-content: flex-end;">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeEditReportModal()" style="padding: 0.5rem 1rem;">취소</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveEditedReport()" style="padding: 0.5rem 1rem; background: #f59e0b; border: none; color: white; border-radius: 4px; cursor: pointer;">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
// 폼에 데이터 채우기
|
||||
document.getElementById('editReportId').value = report.id;
|
||||
document.getElementById('editWorkerName').value = report.worker_name || '작업자';
|
||||
document.getElementById('editProjectId').value = report.project_id || '';
|
||||
document.getElementById('editWorkHours').value = report.work_hours || report.total_hours || 0;
|
||||
document.getElementById('editWorkStatusId').value = report.work_status_id || 1;
|
||||
|
||||
// 공정 선택 후 작업 목록 로드
|
||||
const workTypeSelect = document.getElementById('editWorkTypeId');
|
||||
|
||||
// work_type_id가 실제로는 task_id를 저장하고 있으므로, task에서 work_type을 찾아야 함
|
||||
// 일단 task 기반으로 찾기 시도
|
||||
loadTasksForEdit().then(() => {
|
||||
const taskSelect = document.getElementById('editTaskId');
|
||||
// work_type_id 컬럼에 저장된 값이 실제로는 task_id
|
||||
if (report.work_type_id) {
|
||||
taskSelect.value = report.work_type_id;
|
||||
}
|
||||
});
|
||||
|
||||
modal.style.display = 'flex';
|
||||
};
|
||||
|
||||
/**
|
||||
* 수정 모달용 작업 목록 로드
|
||||
*/
|
||||
window.loadTasksForEdit = async function() {
|
||||
const workTypeId = document.getElementById('editWorkTypeId').value;
|
||||
const taskSelect = document.getElementById('editTaskId');
|
||||
|
||||
if (!workTypeId) {
|
||||
taskSelect.innerHTML = '<option value="">공정을 먼저 선택하세요</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await window.apiCall(`/tasks?work_type_id=${workTypeId}`);
|
||||
const tasks = response.data || response || [];
|
||||
|
||||
taskSelect.innerHTML = '<option value="">작업 선택</option>' +
|
||||
tasks.map(t => `<option value="${t.task_id}">${escapeHtml(t.task_name)}</option>`).join('');
|
||||
} catch (error) {
|
||||
console.error('작업 목록 로드 오류:', error);
|
||||
taskSelect.innerHTML = '<option value="">로드 실패</option>';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 수정 모달 닫기
|
||||
*/
|
||||
window.closeEditReportModal = function() {
|
||||
const modal = document.getElementById('editReportModal');
|
||||
if (modal) {
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 수정된 보고서 저장
|
||||
*/
|
||||
window.saveEditedReport = async function() {
|
||||
const reportId = document.getElementById('editReportId').value;
|
||||
const projectId = document.getElementById('editProjectId').value;
|
||||
const taskId = document.getElementById('editTaskId').value;
|
||||
const workHours = parseFloat(document.getElementById('editWorkHours').value);
|
||||
const workStatusId = document.getElementById('editWorkStatusId').value;
|
||||
|
||||
if (!projectId || !taskId || !workHours) {
|
||||
showMessage('필수 항목을 모두 입력해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const updateData = {
|
||||
project_id: parseInt(projectId),
|
||||
work_type_id: parseInt(taskId), // task_id가 work_type_id 컬럼에 저장됨
|
||||
work_hours: workHours,
|
||||
work_status_id: parseInt(workStatusId)
|
||||
};
|
||||
|
||||
const response = await window.apiCall(`/daily-work-reports/${reportId}`, 'PUT', updateData);
|
||||
|
||||
if (response.success) {
|
||||
showMessage('작업보고서가 수정되었습니다.', 'success');
|
||||
closeEditReportModal();
|
||||
loadCompletedReports(); // 목록 새로고침
|
||||
} else {
|
||||
throw new Error(response.message || '수정 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('작업보고서 수정 오류:', error);
|
||||
showMessage('수정 실패: ' + error.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 작업보고서 삭제
|
||||
*/
|
||||
window.deleteWorkReport = async function(reportId) {
|
||||
if (!confirm('이 작업보고서를 삭제하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await window.apiCall(`/daily-work-reports/${reportId}`, 'DELETE');
|
||||
|
||||
if (response.success) {
|
||||
showMessage('작업보고서가 삭제되었습니다.', 'success');
|
||||
loadCompletedReports(); // 목록 새로고침
|
||||
} else {
|
||||
throw new Error(response.message || '삭제 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('작업보고서 삭제 오류:', error);
|
||||
showMessage('삭제 실패: ' + error.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// =================================================================
|
||||
// 기존 함수들
|
||||
// =================================================================
|
||||
@@ -1780,15 +2203,16 @@ async function loadProjects() {
|
||||
|
||||
async function loadWorkTypes() {
|
||||
try {
|
||||
const data = await window.apiCall(`/daily-work-reports/work-types`);
|
||||
const response = await window.apiCall(`/daily-work-reports/work-types`);
|
||||
const data = response.data || response;
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
workTypes = data;
|
||||
console.log('✅ 작업 유형 API 사용 (통합 설정)');
|
||||
console.log('✅ 작업 유형 API 사용 (통합 설정):', workTypes.length + '개');
|
||||
return;
|
||||
}
|
||||
throw new Error('API 실패');
|
||||
} catch (error) {
|
||||
console.log('⚠️ 작업 유형 API 사용 불가, 기본값 사용');
|
||||
console.log('⚠️ 작업 유형 API 사용 불가, 기본값 사용:', error.message);
|
||||
workTypes = [
|
||||
{ id: 1, name: 'Base' },
|
||||
{ id: 2, name: 'Vessel' },
|
||||
@@ -1799,10 +2223,11 @@ async function loadWorkTypes() {
|
||||
|
||||
async function loadWorkStatusTypes() {
|
||||
try {
|
||||
const data = await window.apiCall(`/daily-work-reports/work-status-types`);
|
||||
const response = await window.apiCall(`/daily-work-reports/work-status-types`);
|
||||
const data = response.data || response;
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
workStatusTypes = data;
|
||||
console.log('✅ 업무 상태 유형 API 사용 (통합 설정)');
|
||||
console.log('✅ 업무 상태 유형 API 사용 (통합 설정):', workStatusTypes.length + '개');
|
||||
return;
|
||||
}
|
||||
throw new Error('API 실패');
|
||||
@@ -1818,7 +2243,8 @@ async function loadWorkStatusTypes() {
|
||||
async function loadErrorTypes() {
|
||||
// 레거시 에러 유형 로드 (호환성)
|
||||
try {
|
||||
const data = await window.apiCall(`/daily-work-reports/error-types`);
|
||||
const response = await window.apiCall(`/daily-work-reports/error-types`);
|
||||
const data = response.data || response;
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
errorTypes = data;
|
||||
}
|
||||
@@ -3610,10 +4036,11 @@ function updateHiddenDefectFields(index) {
|
||||
// 총 부적합 시간 계산
|
||||
const totalErrorHours = defects.reduce((sum, d) => sum + (parseFloat(d.defect_hours) || 0), 0);
|
||||
|
||||
// hidden input에 대표 error_type_id 저장 (첫 번째 값)
|
||||
// hidden input에 대표 error_type_id 저장 (첫 번째 값, item_id fallback)
|
||||
const errorTypeInput = document.getElementById(`errorType_${index}`);
|
||||
if (errorTypeInput && defects.length > 0 && defects[0].error_type_id) {
|
||||
errorTypeInput.value = defects[0].error_type_id;
|
||||
const errorTypeId = defects.length > 0 ? (defects[0].error_type_id || defects[0].item_id || null) : null;
|
||||
if (errorTypeInput && errorTypeId) {
|
||||
errorTypeInput.value = errorTypeId;
|
||||
} else if (errorTypeInput) {
|
||||
errorTypeInput.value = '';
|
||||
}
|
||||
@@ -3635,8 +4062,8 @@ function updateDefectSummary(index) {
|
||||
if (!summaryEl) return;
|
||||
|
||||
const defects = tempDefects[index] || [];
|
||||
// 이슈 기반 또는 레거시 부적합 중 시간이 입력된 것만 유효
|
||||
const validDefects = defects.filter(d => (d.issue_report_id || d.category_id || d.error_type_id) && d.defect_hours > 0);
|
||||
// 이슈 기반 또는 레거시 부적합 중 시간이 입력된 것만 유효 (item_id도 체크)
|
||||
const validDefects = defects.filter(d => (d.issue_report_id || d.category_id || d.item_id || d.error_type_id) && d.defect_hours > 0);
|
||||
|
||||
if (validDefects.length === 0) {
|
||||
summaryEl.textContent = '없음';
|
||||
@@ -3655,9 +4082,16 @@ function updateDefectSummary(index) {
|
||||
const issue = issues.find(i => i.report_id == validDefects[0].issue_report_id);
|
||||
typeName = issue?.issue_item_name || issue?.issue_category_name || '부적합';
|
||||
}
|
||||
} else if (validDefects[0].item_id) {
|
||||
// 신규 방식 - issue_report_items에서 이름 찾기
|
||||
typeName = issueItems.find(i => i.item_id == validDefects[0].item_id)?.item_name || '부적합';
|
||||
} else if (validDefects[0].category_id) {
|
||||
// 카테고리만 선택된 경우
|
||||
typeName = issueCategories.find(c => c.category_id == validDefects[0].category_id)?.category_name || '부적합';
|
||||
} else if (validDefects[0].error_type_id) {
|
||||
// 레거시 - error_types에서 이름 찾기
|
||||
typeName = errorTypes.find(et => et.id == validDefects[0].error_type_id)?.name || '부적합';
|
||||
// 레거시 - error_types에서 이름 찾기 또는 issue_report_items에서 찾기
|
||||
typeName = issueItems.find(i => i.item_id == validDefects[0].error_type_id)?.item_name ||
|
||||
errorTypes.find(et => et.id == validDefects[0].error_type_id)?.name || '부적합';
|
||||
}
|
||||
summaryEl.textContent = `${typeName} ${totalHours}h`;
|
||||
} else {
|
||||
|
||||
@@ -152,6 +152,40 @@ function setupNavbarEvents() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 모바일 메뉴 버튼
|
||||
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');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -160,10 +194,13 @@ function setupNavbarEvents() {
|
||||
function updateDateTime() {
|
||||
const now = new Date();
|
||||
|
||||
// 시간 업데이트
|
||||
// 시간 업데이트 (시 분 초 형식으로 고정)
|
||||
const timeElement = document.getElementById('timeValue');
|
||||
if (timeElement) {
|
||||
timeElement.textContent = now.toLocaleTimeString('ko-KR', { hour12: false });
|
||||
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}초`;
|
||||
}
|
||||
|
||||
// 날짜 업데이트
|
||||
|
||||
@@ -122,13 +122,10 @@ function updateCurrentTime() {
|
||||
// Navbar 컴포넌트가 시간을 처리하므로 여기서는 timeValue가 있을 때만 업데이트
|
||||
if (elements.timeValue) {
|
||||
const now = new Date();
|
||||
const timeString = now.toLocaleTimeString('ko-KR', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
elements.timeValue.textContent = timeString;
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(now.getSeconds()).padStart(2, '0');
|
||||
elements.timeValue.textContent = `${hours}시 ${minutes}분 ${seconds}초`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,16 +33,14 @@ function initializePage() {
|
||||
setupSearchInput();
|
||||
}
|
||||
|
||||
// 현재 시간 업데이트
|
||||
// 현재 시간 업데이트 (시 분 초 형식으로 고정)
|
||||
function updateCurrentTime() {
|
||||
const now = new Date();
|
||||
const timeString = now.toLocaleTimeString('ko-KR', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(now.getSeconds()).padStart(2, '0');
|
||||
const timeString = `${hours}시 ${minutes}분 ${seconds}초`;
|
||||
|
||||
const timeElement = document.getElementById('timeValue');
|
||||
if (timeElement) {
|
||||
timeElement.textContent = timeString;
|
||||
|
||||
@@ -38,16 +38,14 @@ function initializePage() {
|
||||
console.log('✅ 작업 분석 페이지 초기화 완료');
|
||||
}
|
||||
|
||||
// 현재 시간 업데이트
|
||||
// 현재 시간 업데이트 (시 분 초 형식으로 고정)
|
||||
function updateCurrentTime() {
|
||||
const now = new Date();
|
||||
const timeString = now.toLocaleTimeString('ko-KR', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(now.getSeconds()).padStart(2, '0');
|
||||
const timeString = `${hours}시 ${minutes}분 ${seconds}초`;
|
||||
|
||||
const timeElement = document.getElementById('timeValue');
|
||||
if (timeElement) {
|
||||
timeElement.textContent = timeString;
|
||||
|
||||
@@ -32,16 +32,14 @@ function initializePage() {
|
||||
setupLogoutButton();
|
||||
}
|
||||
|
||||
// 현재 시간 업데이트
|
||||
// 현재 시간 업데이트 (시 분 초 형식으로 고정)
|
||||
function updateCurrentTime() {
|
||||
const now = new Date();
|
||||
const timeString = now.toLocaleTimeString('ko-KR', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(now.getSeconds()).padStart(2, '0');
|
||||
const timeString = `${hours}시 ${minutes}분 ${seconds}초`;
|
||||
|
||||
const timeElement = document.getElementById('timeValue');
|
||||
if (timeElement) {
|
||||
timeElement.textContent = timeString;
|
||||
|
||||
@@ -658,8 +658,8 @@ async function loadEquipmentMarkers(workplaceId) {
|
||||
const width = eq.map_width_percent || 8;
|
||||
const height = eq.map_height_percent || 6;
|
||||
|
||||
// 표시 이름: [코드] 이름
|
||||
const displayName = `[${eq.equipment_code}] ${eq.equipment_name}`;
|
||||
// 표시 이름: 설비 이름만
|
||||
const displayName = eq.equipment_name;
|
||||
const movedBadge = eq.is_temporarily_moved ? ' 🚚' : '';
|
||||
|
||||
markersHtml += `
|
||||
@@ -814,9 +814,8 @@ const STATUS_LABELS = {
|
||||
async function openEquipmentPanel(equipment) {
|
||||
currentPanelEquipment = equipment;
|
||||
|
||||
// 패널 헤더 설정
|
||||
document.getElementById('panelEquipmentTitle').textContent =
|
||||
`[${equipment.equipment_code}] ${equipment.equipment_name}`;
|
||||
// 패널 헤더 설정 (설비 이름만)
|
||||
document.getElementById('panelEquipmentTitle').textContent = equipment.equipment_name;
|
||||
|
||||
const statusEl = document.getElementById('panelEquipmentStatus');
|
||||
statusEl.textContent = STATUS_LABELS[equipment.status] || equipment.status;
|
||||
@@ -1286,7 +1285,7 @@ async function renderMoveDetailMap() {
|
||||
style="left: ${eq.map_x_percent}%; top: ${eq.map_y_percent}%;
|
||||
width: ${width}%; height: ${height}%;"
|
||||
title="${eq.equipment_name}">
|
||||
<span class="eq-label">${eq.equipment_code || eq.equipment_name}</span>
|
||||
<span class="eq-label">${eq.equipment_name}</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
@@ -1331,7 +1330,7 @@ function onMoveDetailMapClick(event) {
|
||||
targetMarker.style.width = width + '%';
|
||||
targetMarker.style.height = height + '%';
|
||||
targetMarker.style.display = 'flex';
|
||||
targetMarker.innerHTML = `<span class="eq-label">${currentPanelEquipment.equipment_code || currentPanelEquipment.equipment_name}</span>`;
|
||||
targetMarker.innerHTML = `<span class="eq-label">${currentPanelEquipment.equipment_name}</span>`;
|
||||
|
||||
document.getElementById('panelMoveConfirmBtn').disabled = false;
|
||||
}
|
||||
|
||||
1423
web-ui/js/zone-detail.js
Normal file
1423
web-ui/js/zone-detail.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,10 @@
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
}
|
||||
.panel-header .btn { padding: 0.25rem 0.5rem; font-size: 0.7rem; }
|
||||
.panel-header .count {
|
||||
background: #e5e7eb; padding: 0.1rem 0.5rem; border-radius: 0.25rem;
|
||||
font-size: 0.75rem; font-weight: 500;
|
||||
}
|
||||
.work-type-list { padding: 0; margin: 0; list-style: none; }
|
||||
.work-type-item {
|
||||
padding: 0.6rem 0.75rem; border-bottom: 1px solid #f3f4f6;
|
||||
@@ -156,12 +160,9 @@
|
||||
<div class="work-type-panel">
|
||||
<div class="panel-header">
|
||||
<span>공정 목록</span>
|
||||
<span class="count" id="totalTaskCount">0</span>
|
||||
</div>
|
||||
<ul class="work-type-list" id="workTypeList">
|
||||
<li class="work-type-item active" data-id="" onclick="filterByWorkType('')">
|
||||
<span>전체</span>
|
||||
<span class="count" id="totalCount">0</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -322,7 +323,8 @@
|
||||
`;
|
||||
});
|
||||
list.innerHTML = html;
|
||||
document.getElementById('totalCount').textContent = tasks.length;
|
||||
const totalEl = document.getElementById('totalTaskCount');
|
||||
if (totalEl) totalEl.textContent = tasks.length;
|
||||
}
|
||||
|
||||
function filterByWorkType(id) {
|
||||
|
||||
@@ -1,143 +1,762 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>연간 연차 현황 | 테크니컬코리아</title>
|
||||
|
||||
<!-- 모던 디자인 시스템 적용 -->
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/annual-vacation-overview.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
|
||||
<!-- 스크립트 -->
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0"></script>
|
||||
<script type="module" src="/js/annual-vacation-overview.js" defer></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<style>
|
||||
.page-wrapper {
|
||||
padding: 1.5rem;
|
||||
max-width: 1200px;
|
||||
}
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.page-title { font-size: 1.5rem; font-weight: 700; margin: 0; }
|
||||
.page-desc { color: #6b7280; font-size: 0.875rem; margin-top: 0.25rem; }
|
||||
.controls { display: flex; gap: 0.5rem; align-items: center; }
|
||||
.controls select {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.btn-primary { background: #3b82f6; color: white; }
|
||||
.btn-success { background: #10b981; color: white; }
|
||||
.btn-sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; }
|
||||
|
||||
/* 범례 */
|
||||
.legend-box {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #f9fafb;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: #6b7280;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.legend-item { display: flex; align-items: center; gap: 0.25rem; }
|
||||
.legend-dot {
|
||||
width: 12px; height: 12px; border-radius: 2px;
|
||||
}
|
||||
.dot-carryover { background: #fef3c7; border: 1px solid #f59e0b; }
|
||||
.dot-annual { background: #dbeafe; border: 1px solid #3b82f6; }
|
||||
.dot-longservice { background: #f3e8ff; border: 1px solid #a855f7; }
|
||||
.dot-special { background: #fce7f3; border: 1px solid #ec4899; }
|
||||
|
||||
/* 테이블 */
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
background: white;
|
||||
}
|
||||
.data-table th, .data-table td {
|
||||
padding: 0.6rem 0.5rem;
|
||||
text-align: center;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
.data-table th {
|
||||
background: #f9fafb;
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.data-table th.col-carryover { background: #fef3c7; color: #92400e; }
|
||||
.data-table th.col-annual { background: #dbeafe; color: #1e40af; }
|
||||
.data-table th.col-longservice { background: #f3e8ff; color: #7c3aed; }
|
||||
.data-table th.col-special { background: #fce7f3; color: #be185d; }
|
||||
.data-table th.col-total { background: #dcfce7; color: #166534; }
|
||||
.data-table td.worker-name { text-align: left; font-weight: 500; }
|
||||
.data-table tr:hover { background: #f9fafb; }
|
||||
|
||||
/* 입력 필드 */
|
||||
.num-input {
|
||||
width: 55px;
|
||||
padding: 0.25rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.25rem;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.num-input:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
.num-input.negative { color: #dc2626; }
|
||||
|
||||
/* 경조사 버튼 */
|
||||
.special-btn {
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
background: #fce7f3;
|
||||
color: #be185d;
|
||||
border: 1px solid #f9a8d4;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.special-btn:hover { background: #fbcfe8; }
|
||||
.special-count {
|
||||
display: inline-block;
|
||||
min-width: 20px;
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: #be185d;
|
||||
color: white;
|
||||
border-radius: 10px;
|
||||
font-size: 0.7rem;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
/* 잔여 */
|
||||
.remaining { font-weight: 700; }
|
||||
.remaining.positive { color: #059669; }
|
||||
.remaining.zero { color: #6b7280; }
|
||||
.remaining.negative { color: #dc2626; }
|
||||
|
||||
/* 저장 바 */
|
||||
.save-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.save-status { font-size: 0.875rem; color: #6b7280; }
|
||||
|
||||
/* 경조사 모달 */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 1000;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.modal-overlay.active { display: flex; }
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 0.75rem;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
.modal-title { font-size: 1rem; font-weight: 600; }
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
}
|
||||
.special-list { margin-bottom: 1rem; }
|
||||
.special-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
.special-item select {
|
||||
flex: 1;
|
||||
padding: 0.375rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.special-item input {
|
||||
width: 60px;
|
||||
padding: 0.375rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.25rem;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.special-item .delete-btn {
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.add-special-btn {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
background: #f3f4f6;
|
||||
border: 1px dashed #9ca3af;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
.add-special-btn:hover { background: #e5e7eb; }
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.loading { text-align: center; padding: 2rem; color: #6b7280; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="has-sidebar">
|
||||
<div id="navbar-container"></div>
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<body>
|
||||
<!-- 메인 컨테이너 -->
|
||||
<div class="page-container">
|
||||
|
||||
<!-- 네비게이션 헤더 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="main-content">
|
||||
<div class="content-wrapper">
|
||||
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="page-header">
|
||||
<main class="main-content">
|
||||
<div class="page-wrapper">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">연간 연차 현황</h1>
|
||||
<p class="page-description">모든 작업자의 연간 휴가 현황을 차트로 확인합니다</p>
|
||||
<p class="page-desc">작업자별 연차 발생 및 사용 현황을 관리합니다</p>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<select id="yearSelect"></select>
|
||||
<button class="btn btn-primary" onclick="loadData()">조회</button>
|
||||
</div>
|
||||
|
||||
<!-- 필터 섹션 -->
|
||||
<section class="filter-section">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="filter-controls">
|
||||
<div class="form-group">
|
||||
<label for="yearSelect">조회 연도</label>
|
||||
<select id="yearSelect" class="form-select">
|
||||
<!-- JavaScript로 동적 생성 -->
|
||||
</select>
|
||||
</div>
|
||||
<button id="refreshBtn" class="btn btn-primary">
|
||||
조회
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 탭 네비게이션 -->
|
||||
<section class="tabs-section">
|
||||
<div class="tabs-nav">
|
||||
<button class="tab-btn active" data-tab="annualUsage">연간 사용 기록</button>
|
||||
<button class="tab-btn" data-tab="monthlyDetails">월별 상세 기록</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 탭 1: 연간 사용 기록 -->
|
||||
<section id="annualUsageTab" class="tab-content active">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">월별 휴가 사용 현황</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="chart-container">
|
||||
<canvas id="annualUsageChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 탭 2: 월별 상세 기록 -->
|
||||
<section id="monthlyDetailsTab" class="tab-content">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">월별 상세 기록</h2>
|
||||
<div class="month-controls">
|
||||
<select id="monthSelect" class="form-select">
|
||||
<option value="1">1월</option>
|
||||
<option value="2">2월</option>
|
||||
<option value="3">3월</option>
|
||||
<option value="4">4월</option>
|
||||
<option value="5">5월</option>
|
||||
<option value="6">6월</option>
|
||||
<option value="7">7월</option>
|
||||
<option value="8">8월</option>
|
||||
<option value="9">9월</option>
|
||||
<option value="10">10월</option>
|
||||
<option value="11">11월</option>
|
||||
<option value="12">12월</option>
|
||||
</select>
|
||||
<button id="exportExcelBtn" class="btn btn-sm btn-secondary">
|
||||
엑셀 다운로드
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>작업자명</th>
|
||||
<th>휴가 유형</th>
|
||||
<th>시작일</th>
|
||||
<th>종료일</th>
|
||||
<th>사용 일수</th>
|
||||
<th>사유</th>
|
||||
<th>상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="monthlyTableBody">
|
||||
<tr>
|
||||
<td colspan="7" class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>데이터를 불러오는 중...</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 범례 -->
|
||||
<div class="legend-box">
|
||||
<span style="font-weight:500;">차감 우선순위:</span>
|
||||
<div class="legend-item"><div class="legend-dot dot-carryover"></div>1. 이월</div>
|
||||
<div class="legend-item"><div class="legend-dot dot-annual"></div>2. 정기연차</div>
|
||||
<div class="legend-item"><div class="legend-dot dot-longservice"></div>3. 장기근속</div>
|
||||
<div class="legend-item"><div class="legend-dot dot-special"></div>4. 경조사</div>
|
||||
</div>
|
||||
|
||||
<!-- 테이블 -->
|
||||
<div class="card">
|
||||
<div class="card-body" style="padding:0;overflow-x:auto;">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:40px">No</th>
|
||||
<th style="min-width:70px">이름</th>
|
||||
<th class="col-carryover" style="width:80px">이월</th>
|
||||
<th class="col-annual" style="width:80px">정기연차</th>
|
||||
<th class="col-longservice" style="width:80px">장기근속</th>
|
||||
<th class="col-special" style="width:100px">경조사</th>
|
||||
<th class="col-total" style="width:70px">총 발생</th>
|
||||
<th class="col-total" style="width:70px">총 사용</th>
|
||||
<th class="col-total" style="width:70px">잔여</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tableBody">
|
||||
<tr><td colspan="9" class="loading">로딩 중...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 저장 바 -->
|
||||
<div class="save-bar">
|
||||
<span class="save-status" id="saveStatus">변경사항이 있으면 저장 버튼을 눌러주세요</span>
|
||||
<button class="btn btn-success" onclick="saveAll()">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 경조사 모달 -->
|
||||
<div class="modal-overlay" id="specialModal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title" id="specialModalTitle">경조사 휴가</h3>
|
||||
<button class="modal-close" onclick="closeSpecialModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="special-list" id="specialList">
|
||||
<!-- 동적 생성 -->
|
||||
</div>
|
||||
<button class="add-special-btn" onclick="addSpecialItem()">+ 경조사 추가</button>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-primary" onclick="saveSpecialAndClose()">확인</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 알림 토스트 -->
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script>
|
||||
// axios 설정
|
||||
(function() {
|
||||
const check = setInterval(() => {
|
||||
if (window.API_BASE_URL) {
|
||||
clearInterval(check);
|
||||
axios.defaults.baseURL = window.API_BASE_URL;
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
}, 50);
|
||||
})();
|
||||
|
||||
// 전역 변수
|
||||
let workers = [];
|
||||
let currentYear = new Date().getFullYear();
|
||||
let vacationData = {}; // { workerId: { carryover, annual, longService, specials: [{type, days}], totalUsed } }
|
||||
let currentWorkerId = null;
|
||||
|
||||
// 경조사 유형
|
||||
const specialTypes = [
|
||||
{ code: 'WEDDING', name: '결혼', defaultDays: 5 },
|
||||
{ code: 'SPOUSE_BIRTH', name: '배우자 출산', defaultDays: 10 },
|
||||
{ code: 'CHILD_WEDDING', name: '자녀 결혼', defaultDays: 1 },
|
||||
{ code: 'CONDOLENCE_PARENT', name: '부모 사망', defaultDays: 5 },
|
||||
{ code: 'CONDOLENCE_SPOUSE_PARENT', name: '배우자 부모 사망', defaultDays: 5 },
|
||||
{ code: 'CONDOLENCE_GRANDPARENT', name: '조부모 사망', defaultDays: 3 },
|
||||
{ code: 'CONDOLENCE_SIBLING', name: '형제자매 사망', defaultDays: 3 },
|
||||
{ code: 'OTHER', name: '기타', defaultDays: 1 }
|
||||
];
|
||||
|
||||
// 초기화
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await waitForAxios();
|
||||
initYearSelector();
|
||||
await loadData();
|
||||
});
|
||||
|
||||
function waitForAxios() {
|
||||
return new Promise(resolve => {
|
||||
const check = setInterval(() => {
|
||||
if (axios.defaults.baseURL) { clearInterval(check); resolve(); }
|
||||
}, 50);
|
||||
setTimeout(() => { clearInterval(check); resolve(); }, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
function initYearSelector() {
|
||||
const select = document.getElementById('yearSelect');
|
||||
const now = new Date().getFullYear();
|
||||
for (let y = now - 2; y <= now + 1; y++) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = y;
|
||||
opt.textContent = `${y}년`;
|
||||
if (y === now) opt.selected = true;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
currentYear = parseInt(document.getElementById('yearSelect').value);
|
||||
document.getElementById('tableBody').innerHTML = '<tr><td colspan="9" class="loading">로딩 중...</td></tr>';
|
||||
|
||||
try {
|
||||
// 작업자 로드
|
||||
const workersRes = await axios.get('/workers?limit=100');
|
||||
workers = (workersRes.data.data || [])
|
||||
.filter(w => w.status === 'active' && w.employment_status === 'employed')
|
||||
.sort((a, b) => a.worker_name.localeCompare(b.worker_name, 'ko'));
|
||||
|
||||
// 휴가 잔액 로드
|
||||
let balances = [];
|
||||
try {
|
||||
const balancesRes = await axios.get(`/vacation-balances/year/${currentYear}`);
|
||||
balances = balancesRes.data.data || [];
|
||||
} catch (e) { console.log('휴가 잔액 로드 실패'); }
|
||||
|
||||
// 데이터 정리
|
||||
vacationData = {};
|
||||
workers.forEach(w => {
|
||||
vacationData[w.worker_id] = {
|
||||
carryover: 0,
|
||||
annual: 0,
|
||||
longService: 0,
|
||||
specials: [],
|
||||
totalUsed: 0
|
||||
};
|
||||
});
|
||||
|
||||
// 잔액 데이터 매핑
|
||||
balances.forEach(b => {
|
||||
if (!vacationData[b.worker_id]) return;
|
||||
const code = b.type_code || '';
|
||||
const data = vacationData[b.worker_id];
|
||||
|
||||
if (code === 'CARRYOVER' || b.type_name === '이월') {
|
||||
data.carryover = b.total_days || 0;
|
||||
data.totalUsed += b.used_days || 0;
|
||||
} else if (code === 'ANNUAL' || b.type_name === '정기연차' || b.type_name === '연차') {
|
||||
data.annual = b.total_days || 0;
|
||||
data.totalUsed += b.used_days || 0;
|
||||
} else if (code === 'LONG_SERVICE' || b.type_name === '장기근속') {
|
||||
data.longService = b.total_days || 0;
|
||||
data.totalUsed += b.used_days || 0;
|
||||
} else if (code.startsWith('SPECIAL_') || specialTypes.some(st => st.code === code)) {
|
||||
data.specials.push({
|
||||
type: code,
|
||||
typeName: b.type_name,
|
||||
days: b.total_days || 0,
|
||||
id: b.id
|
||||
});
|
||||
data.totalUsed += b.used_days || 0;
|
||||
}
|
||||
});
|
||||
|
||||
renderTable();
|
||||
} catch (error) {
|
||||
console.error('데이터 로드 오류:', error);
|
||||
document.getElementById('tableBody').innerHTML = '<tr><td colspan="9" class="loading" style="color:#ef4444;">데이터 로드 실패</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const tbody = document.getElementById('tableBody');
|
||||
|
||||
if (workers.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="9" class="loading">작업자가 없습니다</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = workers.map((w, idx) => {
|
||||
const d = vacationData[w.worker_id] || { carryover: 0, annual: 0, longService: 0, specials: [], totalUsed: 0 };
|
||||
const carryover = parseFloat(d.carryover) || 0;
|
||||
const annual = parseFloat(d.annual) || 0;
|
||||
const longService = parseFloat(d.longService) || 0;
|
||||
const totalUsed = parseFloat(d.totalUsed) || 0;
|
||||
const specialTotal = (d.specials || []).reduce((sum, s) => sum + (parseFloat(s.days) || 0), 0);
|
||||
const totalGenerated = carryover + annual + longService + specialTotal;
|
||||
const remaining = totalGenerated - totalUsed;
|
||||
const remainingClass = remaining > 0 ? 'positive' : remaining < 0 ? 'negative' : 'zero';
|
||||
|
||||
return `
|
||||
<tr data-worker-id="${w.worker_id}">
|
||||
<td>${idx + 1}</td>
|
||||
<td class="worker-name">${w.worker_name}</td>
|
||||
<td>
|
||||
<input type="number" class="num-input ${carryover < 0 ? 'negative' : ''}"
|
||||
value="${carryover}" step="0.5"
|
||||
data-field="carryover"
|
||||
onchange="updateField(${w.worker_id}, 'carryover', this.value)">
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" class="num-input"
|
||||
value="${annual}" step="0.5"
|
||||
data-field="annual"
|
||||
onchange="updateField(${w.worker_id}, 'annual', this.value)">
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" class="num-input"
|
||||
value="${longService}" step="0.5"
|
||||
data-field="longService"
|
||||
onchange="updateField(${w.worker_id}, 'longService', this.value)">
|
||||
</td>
|
||||
<td>
|
||||
<button class="special-btn" onclick="openSpecialModal(${w.worker_id}, '${w.worker_name}')">
|
||||
${(d.specials || []).length > 0 ? `${specialTotal}일` : '추가'}
|
||||
${(d.specials || []).length > 0 ? `<span class="special-count">${d.specials.length}</span>` : ''}
|
||||
</button>
|
||||
</td>
|
||||
<td style="font-weight:600;color:#059669;">${totalGenerated.toFixed(2)}</td>
|
||||
<td style="color:#6b7280;">${totalUsed > 0 ? totalUsed.toFixed(2) : '-'}</td>
|
||||
<td class="remaining ${remainingClass}">${remaining.toFixed(2)}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function updateField(workerId, field, value) {
|
||||
const val = parseFloat(value) || 0;
|
||||
if (!vacationData[workerId]) {
|
||||
vacationData[workerId] = { carryover: 0, annual: 0, longService: 0, specials: [], totalUsed: 0 };
|
||||
}
|
||||
vacationData[workerId][field] = val;
|
||||
|
||||
// 입력 스타일 업데이트
|
||||
const input = document.querySelector(`tr[data-worker-id="${workerId}"] input[data-field="${field}"]`);
|
||||
if (input) {
|
||||
input.classList.toggle('negative', val < 0);
|
||||
}
|
||||
|
||||
// 행 합계 업데이트
|
||||
updateRowTotals(workerId);
|
||||
markChanged();
|
||||
}
|
||||
|
||||
function updateRowTotals(workerId) {
|
||||
const row = document.querySelector(`tr[data-worker-id="${workerId}"]`);
|
||||
if (!row) return;
|
||||
|
||||
const d = vacationData[workerId];
|
||||
if (!d) return;
|
||||
|
||||
const carryover = parseFloat(d.carryover) || 0;
|
||||
const annual = parseFloat(d.annual) || 0;
|
||||
const longService = parseFloat(d.longService) || 0;
|
||||
const totalUsed = parseFloat(d.totalUsed) || 0;
|
||||
const specialTotal = (d.specials || []).reduce((sum, s) => sum + (parseFloat(s.days) || 0), 0);
|
||||
const totalGenerated = carryover + annual + longService + specialTotal;
|
||||
const remaining = totalGenerated - totalUsed;
|
||||
|
||||
const cells = row.querySelectorAll('td');
|
||||
cells[6].textContent = totalGenerated.toFixed(2);
|
||||
cells[7].textContent = totalUsed > 0 ? totalUsed.toFixed(2) : '-';
|
||||
cells[8].textContent = remaining.toFixed(2);
|
||||
cells[8].className = `remaining ${remaining > 0 ? 'positive' : remaining < 0 ? 'negative' : 'zero'}`;
|
||||
}
|
||||
|
||||
function markChanged() {
|
||||
document.getElementById('saveStatus').textContent = '변경사항이 있습니다. 저장 버튼을 눌러주세요.';
|
||||
document.getElementById('saveStatus').style.color = '#f59e0b';
|
||||
}
|
||||
|
||||
// ===== 경조사 모달 =====
|
||||
function openSpecialModal(workerId, workerName) {
|
||||
currentWorkerId = workerId;
|
||||
document.getElementById('specialModalTitle').textContent = `${workerName} - 경조사 휴가`;
|
||||
renderSpecialList();
|
||||
document.getElementById('specialModal').classList.add('active');
|
||||
}
|
||||
|
||||
function closeSpecialModal() {
|
||||
document.getElementById('specialModal').classList.remove('active');
|
||||
currentWorkerId = null;
|
||||
}
|
||||
|
||||
function renderSpecialList() {
|
||||
const container = document.getElementById('specialList');
|
||||
const specials = vacationData[currentWorkerId]?.specials || [];
|
||||
|
||||
if (specials.length === 0) {
|
||||
container.innerHTML = '<p style="text-align:center;color:#9ca3af;padding:1rem;">경조사 휴가가 없습니다</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = specials.map((s, idx) => `
|
||||
<div class="special-item" data-idx="${idx}">
|
||||
<select onchange="updateSpecialType(${idx}, this.value)">
|
||||
${specialTypes.map(st => `
|
||||
<option value="${st.code}" ${s.type === st.code ? 'selected' : ''}>${st.name}</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
<input type="number" value="${s.days}" step="0.5" min="0"
|
||||
onchange="updateSpecialDays(${idx}, this.value)">
|
||||
<span>일</span>
|
||||
<button class="delete-btn" onclick="deleteSpecialItem(${idx})">삭제</button>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function addSpecialItem() {
|
||||
if (!vacationData[currentWorkerId]) return;
|
||||
vacationData[currentWorkerId].specials.push({
|
||||
type: 'WEDDING',
|
||||
typeName: '결혼',
|
||||
days: 5
|
||||
});
|
||||
renderSpecialList();
|
||||
}
|
||||
|
||||
function updateSpecialType(idx, typeCode) {
|
||||
const specials = vacationData[currentWorkerId]?.specials;
|
||||
if (!specials || !specials[idx]) return;
|
||||
|
||||
const typeInfo = specialTypes.find(st => st.code === typeCode);
|
||||
specials[idx].type = typeCode;
|
||||
specials[idx].typeName = typeInfo?.name || typeCode;
|
||||
specials[idx].days = typeInfo?.defaultDays || specials[idx].days;
|
||||
|
||||
renderSpecialList();
|
||||
}
|
||||
|
||||
function updateSpecialDays(idx, value) {
|
||||
const specials = vacationData[currentWorkerId]?.specials;
|
||||
if (!specials || !specials[idx]) return;
|
||||
specials[idx].days = parseFloat(value) || 0;
|
||||
}
|
||||
|
||||
function deleteSpecialItem(idx) {
|
||||
const specials = vacationData[currentWorkerId]?.specials;
|
||||
if (!specials) return;
|
||||
specials.splice(idx, 1);
|
||||
renderSpecialList();
|
||||
}
|
||||
|
||||
function saveSpecialAndClose() {
|
||||
updateRowTotals(currentWorkerId);
|
||||
renderTable(); // 경조사 버튼 텍스트 업데이트
|
||||
markChanged();
|
||||
closeSpecialModal();
|
||||
}
|
||||
|
||||
// ESC로 모달 닫기
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') closeSpecialModal();
|
||||
});
|
||||
document.getElementById('specialModal').addEventListener('click', e => {
|
||||
if (e.target.id === 'specialModal') closeSpecialModal();
|
||||
});
|
||||
|
||||
// ===== 저장 =====
|
||||
async function saveAll() {
|
||||
const balancesToSave = [];
|
||||
|
||||
// 휴가 유형 ID 매핑 (서버에서 가져와야 하지만 일단 하드코딩)
|
||||
// 실제로는 vacation_types 테이블에서 조회해야 함
|
||||
const typeIdMap = {
|
||||
'CARRYOVER': null,
|
||||
'ANNUAL': null,
|
||||
'LONG_SERVICE': null
|
||||
};
|
||||
|
||||
// 휴가 유형 ID 조회
|
||||
try {
|
||||
const typesRes = await axios.get('/vacation-types');
|
||||
const types = typesRes.data.data || [];
|
||||
types.forEach(t => {
|
||||
if (t.type_code === 'CARRYOVER' || t.type_name === '이월') typeIdMap['CARRYOVER'] = t.id;
|
||||
if (t.type_code === 'ANNUAL' || t.type_name === '정기연차' || t.type_name === '연차') typeIdMap['ANNUAL'] = t.id;
|
||||
if (t.type_code === 'LONG_SERVICE' || t.type_name === '장기근속') typeIdMap['LONG_SERVICE'] = t.id;
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('휴가 유형 로드 실패:', e);
|
||||
}
|
||||
|
||||
// 필요한 유형이 없으면 생성 (deduct_days 필수)
|
||||
if (!typeIdMap['CARRYOVER']) {
|
||||
try {
|
||||
const res = await axios.post('/vacation-types', { type_code: 'CARRYOVER', type_name: '이월', deduct_days: 1, priority: 1 });
|
||||
typeIdMap['CARRYOVER'] = res.data.data?.id;
|
||||
} catch (e) { console.error('이월 유형 생성 실패'); }
|
||||
}
|
||||
if (!typeIdMap['ANNUAL']) {
|
||||
try {
|
||||
const res = await axios.post('/vacation-types', { type_code: 'ANNUAL', type_name: '정기연차', deduct_days: 1, priority: 2 });
|
||||
typeIdMap['ANNUAL'] = res.data.data?.id;
|
||||
} catch (e) { console.error('정기연차 유형 생성 실패'); }
|
||||
}
|
||||
if (!typeIdMap['LONG_SERVICE']) {
|
||||
try {
|
||||
const res = await axios.post('/vacation-types', { type_code: 'LONG_SERVICE', type_name: '장기근속', deduct_days: 1, priority: 3 });
|
||||
typeIdMap['LONG_SERVICE'] = res.data.data?.id;
|
||||
} catch (e) { console.error('장기근속 유형 생성 실패'); }
|
||||
}
|
||||
|
||||
// 데이터 수집
|
||||
for (const w of workers) {
|
||||
const d = vacationData[w.worker_id];
|
||||
if (!d) continue;
|
||||
|
||||
if (typeIdMap['CARRYOVER']) {
|
||||
balancesToSave.push({
|
||||
worker_id: w.worker_id,
|
||||
vacation_type_id: typeIdMap['CARRYOVER'],
|
||||
year: currentYear,
|
||||
total_days: d.carryover
|
||||
});
|
||||
}
|
||||
if (typeIdMap['ANNUAL']) {
|
||||
balancesToSave.push({
|
||||
worker_id: w.worker_id,
|
||||
vacation_type_id: typeIdMap['ANNUAL'],
|
||||
year: currentYear,
|
||||
total_days: d.annual
|
||||
});
|
||||
}
|
||||
if (typeIdMap['LONG_SERVICE']) {
|
||||
balancesToSave.push({
|
||||
worker_id: w.worker_id,
|
||||
vacation_type_id: typeIdMap['LONG_SERVICE'],
|
||||
year: currentYear,
|
||||
total_days: d.longService
|
||||
});
|
||||
}
|
||||
|
||||
// 경조사 데이터 저장
|
||||
for (const special of d.specials) {
|
||||
if (special.days > 0) {
|
||||
// 경조사 유형 ID 조회 또는 생성
|
||||
let specialTypeId = typeIdMap[special.type];
|
||||
if (!specialTypeId) {
|
||||
try {
|
||||
const typesRes = await axios.get('/vacation-types');
|
||||
const existingType = (typesRes.data.data || []).find(t => t.type_code === special.type);
|
||||
if (existingType) {
|
||||
specialTypeId = existingType.id;
|
||||
typeIdMap[special.type] = specialTypeId;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
if (!specialTypeId) {
|
||||
try {
|
||||
const typeInfo = specialTypes.find(st => st.code === special.type);
|
||||
const res = await axios.post('/vacation-types', {
|
||||
type_code: special.type,
|
||||
type_name: typeInfo?.name || special.type,
|
||||
deduct_days: typeInfo?.defaultDays || 1,
|
||||
priority: 4
|
||||
});
|
||||
specialTypeId = res.data.data?.id;
|
||||
typeIdMap[special.type] = specialTypeId;
|
||||
} catch (e) { console.error(`${special.type} 유형 생성 실패`); }
|
||||
}
|
||||
if (specialTypeId) {
|
||||
balancesToSave.push({
|
||||
worker_id: w.worker_id,
|
||||
vacation_type_id: specialTypeId,
|
||||
year: currentYear,
|
||||
total_days: special.days,
|
||||
notes: special.typeName || special.type
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (balancesToSave.length === 0) {
|
||||
alert('저장할 데이터가 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await axios.post('/vacation-balances/bulk-upsert', { balances: balancesToSave });
|
||||
if (res.data.success) {
|
||||
alert(res.data.message);
|
||||
document.getElementById('saveStatus').textContent = '저장 완료';
|
||||
document.getElementById('saveStatus').style.color = '#10b981';
|
||||
await loadData();
|
||||
} else {
|
||||
alert('저장 실패: ' + res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('저장 오류:', error);
|
||||
alert('저장 중 오류: ' + (error.response?.data?.message || error.message));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<title>출근 체크 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||
<link rel="stylesheet" href="/css/mobile.css?v=1">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
@@ -394,5 +395,19 @@
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- 모바일 하단 네비게이션 -->
|
||||
<div id="mobile-nav-container"></div>
|
||||
<script>
|
||||
if (window.innerWidth <= 768) {
|
||||
fetch('/components/mobile-nav.html')
|
||||
.then(r => r.text())
|
||||
.then(html => {
|
||||
document.getElementById('mobile-nav-container').innerHTML = html;
|
||||
const scripts = document.getElementById('mobile-nav-container').querySelectorAll('script');
|
||||
scripts.forEach(s => eval(s.textContent));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
554
web-ui/pages/attendance/my-vacation-info.html
Normal file
554
web-ui/pages/attendance/my-vacation-info.html
Normal file
@@ -0,0 +1,554 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>내 연차 정보 | 테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<style>
|
||||
.page-wrapper {
|
||||
padding: 1.5rem;
|
||||
max-width: 1000px;
|
||||
}
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.page-title { font-size: 1.5rem; font-weight: 700; margin: 0; }
|
||||
.page-desc { color: #6b7280; font-size: 0.875rem; margin-top: 0.25rem; }
|
||||
|
||||
/* 작업자 선택 (관리자용) */
|
||||
.admin-controls {
|
||||
display: none;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #fef3c7;
|
||||
border: 1px solid #f59e0b;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.admin-controls.visible { display: flex; }
|
||||
.admin-controls label { font-weight: 500; color: #92400e; }
|
||||
.admin-controls select {
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
/* 카드 그리드 */
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* 연차 카드 */
|
||||
.vacation-card {
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
.vacation-card h3 {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 1rem 0;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid #3b82f6;
|
||||
color: #1e40af;
|
||||
}
|
||||
.vacation-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
.vacation-item:last-child { border-bottom: none; }
|
||||
.vacation-item .label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #374151;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.vacation-item .dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.dot-carryover { background: #fbbf24; }
|
||||
.dot-annual { background: #3b82f6; }
|
||||
.dot-longservice { background: #a855f7; }
|
||||
.dot-special { background: #ec4899; }
|
||||
.vacation-item .days {
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.days.positive { color: #059669; }
|
||||
.days.zero { color: #9ca3af; }
|
||||
.days.negative { color: #dc2626; }
|
||||
|
||||
/* 총 합계 */
|
||||
.vacation-total {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 2px solid #e5e7eb;
|
||||
font-weight: 600;
|
||||
}
|
||||
.vacation-total .label { font-size: 0.9rem; color: #111827; }
|
||||
.vacation-total .days { font-size: 1.25rem; }
|
||||
|
||||
/* 연장근로 카드 */
|
||||
.overtime-card {
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
.overtime-card h3 {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 1rem 0;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid #f97316;
|
||||
color: #c2410c;
|
||||
}
|
||||
.overtime-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.overtime-controls select {
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.overtime-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.overtime-stat {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
background: #fff7ed;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.overtime-stat .value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #ea580c;
|
||||
}
|
||||
.overtime-stat .label {
|
||||
font-size: 0.75rem;
|
||||
color: #9a3412;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* 월별 상세 */
|
||||
.overtime-detail {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.overtime-day {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.4rem 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.overtime-day:last-child { border-bottom: none; }
|
||||
.overtime-day .date { color: #6b7280; }
|
||||
.overtime-day .hours { font-weight: 600; color: #ea580c; }
|
||||
|
||||
/* 로딩/에러 */
|
||||
.loading, .error, .no-data {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
.error { color: #dc2626; }
|
||||
|
||||
/* 안내 메시지 */
|
||||
.info-message {
|
||||
padding: 1rem;
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 0.5rem;
|
||||
color: #1e40af;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="has-sidebar">
|
||||
<div id="navbar-container"></div>
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<main class="main-content">
|
||||
<div class="page-wrapper">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">내 연차 정보</h1>
|
||||
<p class="page-desc" id="workerNameDisplay">연차 잔여 현황 및 월간 연장근로 시간을 확인합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 관리자용 작업자 선택 -->
|
||||
<div class="admin-controls" id="adminControls">
|
||||
<label>작업자 선택:</label>
|
||||
<select id="workerSelect" onchange="onWorkerChange()">
|
||||
<option value="">-- 선택 --</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 작업자 미연결 안내 -->
|
||||
<div class="info-message" id="noWorkerMessage" style="display:none;">
|
||||
현재 계정에 연결된 작업자 정보가 없습니다. 관리자에게 문의하세요.
|
||||
</div>
|
||||
|
||||
<!-- 정보 그리드 -->
|
||||
<div class="info-grid" id="infoGrid" style="display:none;">
|
||||
<!-- 연차 잔여 현황 -->
|
||||
<div class="vacation-card">
|
||||
<h3 id="vacationCardTitle">연차 잔여 현황</h3>
|
||||
<div id="vacationList">
|
||||
<div class="loading">로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 월간 연장근로 -->
|
||||
<div class="overtime-card">
|
||||
<h3>월간 연장근로 현황</h3>
|
||||
<div class="overtime-controls">
|
||||
<select id="yearSelect" onchange="loadOvertimeData()"></select>
|
||||
<select id="monthSelect" onchange="loadOvertimeData()"></select>
|
||||
</div>
|
||||
<div id="overtimeContent">
|
||||
<div class="loading">로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script>
|
||||
// axios 설정
|
||||
(function() {
|
||||
const check = setInterval(() => {
|
||||
if (window.API_BASE_URL) {
|
||||
clearInterval(check);
|
||||
axios.defaults.baseURL = window.API_BASE_URL;
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
}, 50);
|
||||
})();
|
||||
|
||||
// 전역 변수
|
||||
let currentUser = null;
|
||||
let currentWorkerId = null;
|
||||
let isAdmin = false;
|
||||
let workers = [];
|
||||
|
||||
// 초기화
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await waitForAxios();
|
||||
await initPage();
|
||||
});
|
||||
|
||||
function waitForAxios() {
|
||||
return new Promise(resolve => {
|
||||
const check = setInterval(() => {
|
||||
if (axios.defaults.baseURL) { clearInterval(check); resolve(); }
|
||||
}, 50);
|
||||
setTimeout(() => { clearInterval(check); resolve(); }, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
async function initPage() {
|
||||
// 현재 사용자 정보 가져오기
|
||||
const userStr = localStorage.getItem('user');
|
||||
if (userStr) {
|
||||
try {
|
||||
currentUser = JSON.parse(userStr);
|
||||
} catch (e) {
|
||||
console.error('사용자 정보 파싱 실패');
|
||||
}
|
||||
}
|
||||
|
||||
// 관리자 여부 확인
|
||||
const userRole = (currentUser?.role || currentUser?.access_level || '').toLowerCase();
|
||||
isAdmin = ['admin', 'system admin', 'system', 'system_admin'].includes(userRole);
|
||||
|
||||
// 연도/월 선택기 초기화
|
||||
initDateSelectors();
|
||||
|
||||
if (isAdmin) {
|
||||
// 관리자: 작업자 선택 UI 표시
|
||||
document.getElementById('adminControls').classList.add('visible');
|
||||
await loadWorkers();
|
||||
} else {
|
||||
// 일반 사용자: 본인 worker_id 사용
|
||||
if (currentUser?.worker_id) {
|
||||
currentWorkerId = currentUser.worker_id;
|
||||
document.getElementById('infoGrid').style.display = 'grid';
|
||||
await loadAllData();
|
||||
} else {
|
||||
// worker_id가 없는 경우
|
||||
document.getElementById('noWorkerMessage').style.display = 'block';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function initDateSelectors() {
|
||||
const now = new Date();
|
||||
const yearSelect = document.getElementById('yearSelect');
|
||||
const monthSelect = document.getElementById('monthSelect');
|
||||
|
||||
// 연도 (올해 ± 1년)
|
||||
for (let y = now.getFullYear() - 1; y <= now.getFullYear() + 1; y++) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = y;
|
||||
opt.textContent = `${y}년`;
|
||||
if (y === now.getFullYear()) opt.selected = true;
|
||||
yearSelect.appendChild(opt);
|
||||
}
|
||||
|
||||
// 월
|
||||
for (let m = 1; m <= 12; m++) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m;
|
||||
opt.textContent = `${m}월`;
|
||||
if (m === now.getMonth() + 1) opt.selected = true;
|
||||
monthSelect.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorkers() {
|
||||
try {
|
||||
const res = await axios.get('/workers?limit=100');
|
||||
workers = (res.data.data || [])
|
||||
.filter(w => w.status === 'active' && w.employment_status === 'employed')
|
||||
.sort((a, b) => a.worker_name.localeCompare(b.worker_name, 'ko'));
|
||||
|
||||
const select = document.getElementById('workerSelect');
|
||||
workers.forEach(w => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = w.worker_id;
|
||||
opt.textContent = w.worker_name;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('작업자 목록 로드 실패:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function onWorkerChange() {
|
||||
const workerId = document.getElementById('workerSelect').value;
|
||||
if (!workerId) {
|
||||
document.getElementById('infoGrid').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
currentWorkerId = parseInt(workerId);
|
||||
const worker = workers.find(w => w.worker_id === currentWorkerId);
|
||||
document.getElementById('workerNameDisplay').textContent =
|
||||
`${worker?.worker_name || ''}님의 연차 잔여 현황 및 월간 연장근로 시간`;
|
||||
document.getElementById('infoGrid').style.display = 'grid';
|
||||
await loadAllData();
|
||||
}
|
||||
|
||||
async function loadAllData() {
|
||||
await Promise.all([
|
||||
loadVacationData(),
|
||||
loadOvertimeData()
|
||||
]);
|
||||
}
|
||||
|
||||
// ===== 연차 잔여 현황 =====
|
||||
async function loadVacationData() {
|
||||
const container = document.getElementById('vacationList');
|
||||
container.innerHTML = '<div class="loading">로딩 중...</div>';
|
||||
|
||||
const year = new Date().getFullYear();
|
||||
document.getElementById('vacationCardTitle').textContent = `연차 잔여 현황 (${year}년)`;
|
||||
|
||||
try {
|
||||
const res = await axios.get(`/vacation-balances/worker/${currentWorkerId}/year/${year}`);
|
||||
const balances = res.data.data || [];
|
||||
|
||||
if (balances.length === 0) {
|
||||
container.innerHTML = '<div class="no-data">등록된 연차 정보가 없습니다</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// 유형별 정리
|
||||
const typeOrder = ['CARRYOVER', 'ANNUAL', 'LONG_SERVICE'];
|
||||
const typeNames = {
|
||||
'CARRYOVER': '이월',
|
||||
'ANNUAL': '정기연차',
|
||||
'LONG_SERVICE': '장기근속'
|
||||
};
|
||||
const dotClasses = {
|
||||
'CARRYOVER': 'dot-carryover',
|
||||
'ANNUAL': 'dot-annual',
|
||||
'LONG_SERVICE': 'dot-longservice'
|
||||
};
|
||||
|
||||
let totalDays = 0;
|
||||
let usedDays = 0;
|
||||
let html = '';
|
||||
|
||||
// 정렬된 순서로 표시
|
||||
const sortedBalances = balances.sort((a, b) => {
|
||||
const aIdx = typeOrder.indexOf(a.type_code);
|
||||
const bIdx = typeOrder.indexOf(b.type_code);
|
||||
if (aIdx === -1 && bIdx === -1) return 0;
|
||||
if (aIdx === -1) return 1;
|
||||
if (bIdx === -1) return -1;
|
||||
return aIdx - bIdx;
|
||||
});
|
||||
|
||||
sortedBalances.forEach(b => {
|
||||
const total = parseFloat(b.total_days) || 0;
|
||||
const used = parseFloat(b.used_days) || 0;
|
||||
const remaining = total - used;
|
||||
totalDays += total;
|
||||
usedDays += used;
|
||||
|
||||
const dotClass = dotClasses[b.type_code] || 'dot-special';
|
||||
const typeName = typeNames[b.type_code] || b.type_name || b.type_code;
|
||||
const remainingClass = remaining > 0 ? 'positive' : remaining < 0 ? 'negative' : 'zero';
|
||||
|
||||
html += `
|
||||
<div class="vacation-item">
|
||||
<span class="label">
|
||||
<span class="dot ${dotClass}"></span>
|
||||
${typeName}
|
||||
</span>
|
||||
<span class="days ${remainingClass}">
|
||||
${remaining.toFixed(1)}일
|
||||
<small style="color:#9ca3af;font-weight:normal;">(${total.toFixed(1)} - ${used.toFixed(1)})</small>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
// 총 합계
|
||||
const totalRemaining = totalDays - usedDays;
|
||||
const totalClass = totalRemaining > 0 ? 'positive' : totalRemaining < 0 ? 'negative' : 'zero';
|
||||
|
||||
html += `
|
||||
<div class="vacation-total">
|
||||
<span class="label">총 잔여</span>
|
||||
<span class="days ${totalClass}">${totalRemaining.toFixed(1)}일</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
} catch (e) {
|
||||
console.error('연차 데이터 로드 실패:', e);
|
||||
container.innerHTML = '<div class="error">데이터를 불러오는데 실패했습니다</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 월간 연장근로 =====
|
||||
async function loadOvertimeData() {
|
||||
const container = document.getElementById('overtimeContent');
|
||||
container.innerHTML = '<div class="loading">로딩 중...</div>';
|
||||
|
||||
const year = parseInt(document.getElementById('yearSelect').value);
|
||||
const month = parseInt(document.getElementById('monthSelect').value);
|
||||
|
||||
// 해당 월의 시작일/종료일
|
||||
const startDate = `${year}-${String(month).padStart(2, '0')}-01`;
|
||||
const lastDay = new Date(year, month, 0).getDate();
|
||||
const endDate = `${year}-${String(month).padStart(2, '0')}-${lastDay}`;
|
||||
|
||||
try {
|
||||
// 근태 기록에서 연장근로 데이터 조회
|
||||
const res = await axios.get(`/attendance/records?start_date=${startDate}&end_date=${endDate}&worker_id=${currentWorkerId}`);
|
||||
const records = res.data.data || [];
|
||||
|
||||
// 8시간 초과분 계산
|
||||
let totalOvertimeHours = 0;
|
||||
const overtimeDays = [];
|
||||
|
||||
records.forEach(r => {
|
||||
const hours = parseFloat(r.total_work_hours) || 0;
|
||||
if (hours > 8) {
|
||||
const overtime = hours - 8;
|
||||
totalOvertimeHours += overtime;
|
||||
overtimeDays.push({
|
||||
date: r.record_date,
|
||||
hours: overtime
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 총 근무일수
|
||||
const workDays = records.filter(r => (parseFloat(r.total_work_hours) || 0) > 0).length;
|
||||
|
||||
// 렌더링
|
||||
let html = `
|
||||
<div class="overtime-summary">
|
||||
<div class="overtime-stat">
|
||||
<div class="value">${totalOvertimeHours.toFixed(1)}h</div>
|
||||
<div class="label">총 연장근로</div>
|
||||
</div>
|
||||
<div class="overtime-stat">
|
||||
<div class="value">${overtimeDays.length}일</div>
|
||||
<div class="label">연장근로 일수</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (overtimeDays.length > 0) {
|
||||
html += '<div class="overtime-detail">';
|
||||
overtimeDays.sort((a, b) => a.date.localeCompare(b.date)).forEach(d => {
|
||||
const dateObj = new Date(d.date);
|
||||
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
const dayName = dayNames[dateObj.getDay()];
|
||||
const displayDate = `${dateObj.getMonth() + 1}/${dateObj.getDate()} (${dayName})`;
|
||||
|
||||
html += `
|
||||
<div class="overtime-day">
|
||||
<span class="date">${displayDate}</span>
|
||||
<span class="hours">+${d.hours.toFixed(1)}h</span>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
html += '</div>';
|
||||
} else {
|
||||
html += '<div class="no-data" style="padding:1rem;">연장근로 기록이 없습니다</div>';
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
} catch (e) {
|
||||
console.error('연장근로 데이터 로드 실패:', e);
|
||||
container.innerHTML = '<div class="error">데이터를 불러오는데 실패했습니다</div>';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -6,6 +6,7 @@
|
||||
<title>근무 현황 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||
<link rel="stylesheet" href="/css/mobile.css?v=1">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
@@ -93,9 +94,25 @@
|
||||
.data-table tr.saved {
|
||||
background: #f0fdf4;
|
||||
}
|
||||
.data-table tr.absent {
|
||||
background: #fef2f2;
|
||||
.data-table tr.leave {
|
||||
background: #fefce8; /* 연한 노랑 - 연차/반차/반반차 */
|
||||
}
|
||||
.data-table tr.absent {
|
||||
background: #fef2f2; /* 연한 빨강 - 결근 (연차정보 없음) */
|
||||
}
|
||||
.data-table tr.absent-no-leave {
|
||||
background: #fee2e2; /* 더 진한 빨강 - 출근체크 안하고 연차정보도 없음 */
|
||||
}
|
||||
.leave-tag {
|
||||
font-size: 0.65rem;
|
||||
color: #a16207;
|
||||
background: #fef3c7;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
.status-leave { color: #a16207; }
|
||||
.status-absent-warning { color: #dc2626; font-weight: 600; }
|
||||
.worker-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -128,6 +145,23 @@
|
||||
}
|
||||
.status-present { color: #10b981; }
|
||||
.status-absent { color: #ef4444; }
|
||||
.status-not-hired { color: #9ca3af; font-style: italic; }
|
||||
.data-table tr.not-hired {
|
||||
background: #f3f4f6;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.data-table tr.not-hired .type-select,
|
||||
.data-table tr.not-hired .overtime-input {
|
||||
display: none;
|
||||
}
|
||||
.not-hired-tag {
|
||||
font-size: 0.65rem;
|
||||
color: #6b7280;
|
||||
background: #e5e7eb;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* 저장 영역 */
|
||||
.save-bar {
|
||||
@@ -196,6 +230,7 @@
|
||||
<span><span class="dot dot-quarter"></span> 반반차 <strong id="quarterCount">0</strong></span>
|
||||
<span><span class="dot dot-early"></span> 조퇴 <strong id="earlyCount">0</strong></span>
|
||||
<span><span class="dot dot-overtime"></span> 연장 <strong id="overtimeCount">0</strong></span>
|
||||
<span><span class="dot" style="background:#dc2626;"></span> 결근 <strong id="absentCount">0</strong></span>
|
||||
</div>
|
||||
|
||||
<table class="data-table">
|
||||
@@ -243,12 +278,12 @@
|
||||
let isSaving = false;
|
||||
|
||||
const attendanceTypes = [
|
||||
{ value: 'normal', label: '정시근무', hours: 8 },
|
||||
{ value: 'annual', label: '연차', hours: 0 },
|
||||
{ value: 'half', label: '반차', hours: 4 },
|
||||
{ value: 'quarter', label: '반반차', hours: 6 },
|
||||
{ value: 'early', label: '조퇴', hours: 2 },
|
||||
{ value: 'overtime', label: '연장근로', hours: 8 }
|
||||
{ value: 'normal', label: '정시근무', hours: 8, isLeave: false },
|
||||
{ value: 'annual', label: '연차', hours: 0, isLeave: true },
|
||||
{ value: 'half', label: '반차', hours: 4, isLeave: true },
|
||||
{ value: 'quarter', label: '반반차', hours: 6, isLeave: true },
|
||||
{ value: 'early', label: '조퇴', hours: 0, isLeave: false },
|
||||
{ value: 'overtime', label: '연장근로', hours: 8, isLeave: false }
|
||||
];
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
@@ -269,6 +304,12 @@
|
||||
});
|
||||
}
|
||||
|
||||
function formatDisplayDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
const [year, month, day] = dateStr.split('-');
|
||||
return `${year}.${month}.${day}`;
|
||||
}
|
||||
|
||||
async function loadWorkStatus() {
|
||||
const selectedDate = document.getElementById('selectedDate').value;
|
||||
if (!selectedDate) return alert('날짜를 선택해주세요.');
|
||||
@@ -290,44 +331,88 @@
|
||||
workers.forEach(w => {
|
||||
const record = records.find(r => r.worker_id === w.worker_id);
|
||||
|
||||
// 입사일 이전인지 확인
|
||||
const joinDate = w.join_date ? w.join_date.split('T')[0] : null;
|
||||
const isBeforeJoin = joinDate && selectedDate < joinDate;
|
||||
|
||||
if (isBeforeJoin) {
|
||||
// 입사 전 날짜
|
||||
workStatus[w.worker_id] = {
|
||||
isPresent: false,
|
||||
type: 'not_hired',
|
||||
hours: 0,
|
||||
overtimeHours: 0,
|
||||
isSaved: false,
|
||||
hasLeaveInfo: false,
|
||||
isNotHired: true,
|
||||
joinDate: joinDate
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (record) {
|
||||
let type = 'normal';
|
||||
let overtimeHours = 0;
|
||||
|
||||
if (record.is_present === 0) {
|
||||
type = 'annual';
|
||||
} else {
|
||||
if (record.attendance_type_code) {
|
||||
const codeMap = {
|
||||
'REGULAR': 'normal',
|
||||
'VACATION': 'annual',
|
||||
'HALF_LEAVE': 'half',
|
||||
'QUARTER_LEAVE': 'quarter',
|
||||
'PARTIAL': 'early',
|
||||
'OVERTIME': 'overtime'
|
||||
};
|
||||
type = codeMap[record.attendance_type_code] || 'normal';
|
||||
}
|
||||
if (record.total_work_hours > 8) {
|
||||
type = 'overtime';
|
||||
overtimeHours = record.total_work_hours - 8;
|
||||
}
|
||||
// 1. 휴가 유형 확인 (vacation_type_id 또는 vacation_type_code)
|
||||
if (record.vacation_type_id || record.vacation_type_code) {
|
||||
const vacationCodeMap = {
|
||||
'ANNUAL_FULL': 'annual',
|
||||
'ANNUAL_HALF': 'half',
|
||||
'ANNUAL_QUARTER': 'quarter',
|
||||
1: 'annual',
|
||||
2: 'half',
|
||||
3: 'quarter'
|
||||
};
|
||||
type = vacationCodeMap[record.vacation_type_code] || vacationCodeMap[record.vacation_type_id] || 'annual';
|
||||
}
|
||||
// 2. 근태 유형 확인
|
||||
// work_attendance_types: 1=NORMAL, 2=LATE, 3=EARLY_LEAVE, 4=ABSENT, 5=VACATION
|
||||
else if (record.attendance_type_code || record.attendance_type_id) {
|
||||
const codeMap = {
|
||||
'NORMAL': 'normal',
|
||||
'REGULAR': 'normal',
|
||||
'VACATION': 'annual',
|
||||
'EARLY_LEAVE': 'early',
|
||||
'ABSENT': 'normal', // 결근은 화면에서 따로 처리
|
||||
1: 'normal', // NORMAL
|
||||
2: 'normal', // LATE (지각도 출근으로 처리)
|
||||
3: 'early', // EARLY_LEAVE
|
||||
4: 'normal', // ABSENT (결근은 화면에서 따로 처리)
|
||||
5: 'annual' // VACATION
|
||||
};
|
||||
type = codeMap[record.attendance_type_code] || codeMap[record.attendance_type_id] || 'normal';
|
||||
}
|
||||
// 3. 출근 안 한 경우 (is_present가 0이고 휴가 정보 없으면 결근 상태로 표시)
|
||||
else if (record.is_present === 0) {
|
||||
type = 'normal'; // 기본값, 사용자가 수정해야 함
|
||||
}
|
||||
|
||||
// 연장근로 확인
|
||||
if (record.total_work_hours > 8 && type === 'normal') {
|
||||
type = 'overtime';
|
||||
overtimeHours = record.total_work_hours - 8;
|
||||
}
|
||||
|
||||
const typeInfo = attendanceTypes.find(t => t.value === type);
|
||||
|
||||
workStatus[w.worker_id] = {
|
||||
isPresent: record.is_present === 1,
|
||||
isPresent: record.is_present === 1 || typeInfo?.isLeave,
|
||||
type: type,
|
||||
hours: attendanceTypes.find(t => t.value === type)?.hours || 8,
|
||||
hours: typeInfo !== undefined ? typeInfo.hours : 8,
|
||||
overtimeHours: overtimeHours,
|
||||
isSaved: record.attendance_type_id != null || record.total_work_hours > 0
|
||||
isSaved: record.attendance_type_id != null || record.total_work_hours > 0 || record.vacation_type_id != null,
|
||||
hasLeaveInfo: typeInfo?.isLeave || false
|
||||
};
|
||||
} else {
|
||||
// 출근 체크 기록이 없는 경우 - 결근 상태
|
||||
workStatus[w.worker_id] = {
|
||||
isPresent: true,
|
||||
isPresent: false,
|
||||
type: 'normal',
|
||||
hours: 8,
|
||||
overtimeHours: 0,
|
||||
isSaved: false
|
||||
isSaved: false,
|
||||
hasLeaveInfo: false
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -351,20 +436,71 @@
|
||||
|
||||
tbody.innerHTML = workers.map((w, idx) => {
|
||||
const s = workStatus[w.worker_id];
|
||||
|
||||
// 미입사 상태 처리
|
||||
if (s.isNotHired) {
|
||||
return `
|
||||
<tr class="not-hired">
|
||||
<td>${idx + 1}</td>
|
||||
<td>
|
||||
<span class="worker-name">${w.worker_name}</span>
|
||||
<span class="not-hired-tag">미입사</span>
|
||||
</td>
|
||||
<td class="status-not-hired">-</td>
|
||||
<td><span style="color:#9ca3af;font-size:0.8rem;">입사일: ${formatDisplayDate(s.joinDate)}</span></td>
|
||||
<td class="hours-cell">-</td>
|
||||
<td class="hours-cell">-</td>
|
||||
<td class="hours-cell">-</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
const typeInfo = attendanceTypes.find(t => t.value === s.type);
|
||||
const isLeaveType = typeInfo?.isLeave || false;
|
||||
const showOvertimeInput = s.type === 'overtime';
|
||||
const baseHours = s.hours;
|
||||
const totalHours = s.type === 'overtime' ? baseHours + s.overtimeHours : baseHours;
|
||||
const rowClass = s.isSaved ? 'saved' : (!s.isPresent ? 'absent' : '');
|
||||
|
||||
// 행 클래스 결정
|
||||
let rowClass = '';
|
||||
if (s.isSaved) {
|
||||
rowClass = isLeaveType ? 'leave' : 'saved';
|
||||
} else if (!s.isPresent) {
|
||||
// 출근 안 했는데 연차 정보도 없으면 경고
|
||||
rowClass = isLeaveType ? 'leave' : 'absent-no-leave';
|
||||
}
|
||||
|
||||
// 출근 상태 텍스트 및 클래스 결정
|
||||
let statusText = '';
|
||||
let statusClass = '';
|
||||
if (isLeaveType) {
|
||||
statusText = typeInfo.label;
|
||||
statusClass = 'status-leave';
|
||||
} else if (s.isPresent) {
|
||||
statusText = '출근';
|
||||
statusClass = 'status-present';
|
||||
} else {
|
||||
statusText = '⚠️ 결근';
|
||||
statusClass = 'status-absent-warning';
|
||||
}
|
||||
|
||||
// 태그 표시
|
||||
let tag = '';
|
||||
if (s.isSaved) {
|
||||
tag = '<span class="saved-tag">저장됨</span>';
|
||||
} else if (isLeaveType) {
|
||||
tag = '<span class="leave-tag">연차</span>';
|
||||
}
|
||||
|
||||
return `
|
||||
<tr class="${rowClass}">
|
||||
<td>${idx + 1}</td>
|
||||
<td>
|
||||
<span class="worker-name">${w.worker_name}</span>
|
||||
${s.isSaved ? '<span class="saved-tag">저장됨</span>' : ''}
|
||||
${tag}
|
||||
</td>
|
||||
<td class="${s.isPresent ? 'status-present' : 'status-absent'}">
|
||||
${s.isPresent ? '출근' : '결근'}
|
||||
<td class="${statusClass}">
|
||||
${statusText}
|
||||
</td>
|
||||
<td>
|
||||
<select class="type-select" onchange="updateType(${w.worker_id}, this.value)">
|
||||
@@ -387,9 +523,15 @@
|
||||
}
|
||||
|
||||
function updateType(workerId, value) {
|
||||
const type = attendanceTypes.find(t => t.value === value);
|
||||
const typeInfo = attendanceTypes.find(t => t.value === value);
|
||||
workStatus[workerId].type = value;
|
||||
workStatus[workerId].hours = type ? type.hours : 8;
|
||||
workStatus[workerId].hours = typeInfo !== undefined ? typeInfo.hours : 8;
|
||||
workStatus[workerId].hasLeaveInfo = typeInfo?.isLeave || false;
|
||||
|
||||
// 연차 유형 선택 시 출근 상태로 간주 (결근 아님)
|
||||
if (typeInfo?.isLeave) {
|
||||
workStatus[workerId].isPresent = true;
|
||||
}
|
||||
|
||||
if (value === 'overtime') {
|
||||
workStatus[workerId].overtimeHours = workStatus[workerId].overtimeHours || 2;
|
||||
@@ -418,11 +560,25 @@
|
||||
}
|
||||
|
||||
function updateSummary() {
|
||||
let normal = 0, annual = 0, half = 0, quarter = 0, early = 0, overtime = 0;
|
||||
let normal = 0, annual = 0, half = 0, quarter = 0, early = 0, overtime = 0, absent = 0, notHired = 0;
|
||||
|
||||
Object.values(workStatus).forEach(s => {
|
||||
// 미입사자 제외
|
||||
if (s.isNotHired) {
|
||||
notHired++;
|
||||
return;
|
||||
}
|
||||
|
||||
const typeInfo = attendanceTypes.find(t => t.value === s.type);
|
||||
const isLeaveType = typeInfo?.isLeave || false;
|
||||
|
||||
// 출근 안 했고 연차 정보도 없으면 결근
|
||||
if (!s.isPresent && !isLeaveType) {
|
||||
absent++;
|
||||
}
|
||||
|
||||
switch (s.type) {
|
||||
case 'normal': normal++; break;
|
||||
case 'normal': if (s.isPresent) normal++; break;
|
||||
case 'annual': annual++; break;
|
||||
case 'half': half++; break;
|
||||
case 'quarter': quarter++; break;
|
||||
@@ -437,6 +593,7 @@
|
||||
document.getElementById('quarterCount').textContent = quarter;
|
||||
document.getElementById('earlyCount').textContent = early;
|
||||
document.getElementById('overtimeCount').textContent = overtime;
|
||||
document.getElementById('absentCount').textContent = absent;
|
||||
}
|
||||
|
||||
function updateSaveStatus() {
|
||||
@@ -462,14 +619,14 @@
|
||||
|
||||
const saveBtn = document.getElementById('saveBtn');
|
||||
|
||||
// work_attendance_types: 1=REGULAR, 2=OVERTIME, 3=PARTIAL, 4=VACATION
|
||||
// work_attendance_types: 1=NORMAL, 2=LATE, 3=EARLY_LEAVE, 4=ABSENT, 5=VACATION
|
||||
const typeIdMap = {
|
||||
'normal': 1,
|
||||
'annual': 4,
|
||||
'half': 4,
|
||||
'quarter': 4,
|
||||
'early': 3,
|
||||
'overtime': 2
|
||||
'normal': 1, // NORMAL
|
||||
'annual': 5, // VACATION
|
||||
'half': 5, // VACATION
|
||||
'quarter': 5, // VACATION
|
||||
'early': 3, // EARLY_LEAVE
|
||||
'overtime': 1 // NORMAL (시간으로 구분)
|
||||
};
|
||||
|
||||
// vacation_types: 1=ANNUAL_FULL, 2=ANNUAL_HALF, 3=ANNUAL_QUARTER
|
||||
@@ -479,20 +636,23 @@
|
||||
'quarter': 3,
|
||||
};
|
||||
|
||||
const recordsToSave = workers.map(w => {
|
||||
const s = workStatus[w.worker_id];
|
||||
const totalHours = s.type === 'overtime' ? s.hours + s.overtimeHours : s.hours;
|
||||
// 미입사자 제외하고 저장할 데이터 생성
|
||||
const recordsToSave = workers
|
||||
.filter(w => !workStatus[w.worker_id]?.isNotHired)
|
||||
.map(w => {
|
||||
const s = workStatus[w.worker_id];
|
||||
const totalHours = s.type === 'overtime' ? s.hours + s.overtimeHours : s.hours;
|
||||
|
||||
return {
|
||||
record_date: date,
|
||||
worker_id: w.worker_id,
|
||||
attendance_type_id: typeIdMap[s.type] || 1,
|
||||
vacation_type_id: vacationTypeIdMap[s.type] || null,
|
||||
total_work_hours: totalHours,
|
||||
overtime_approved: s.type === 'overtime',
|
||||
notes: s.type === 'overtime' ? `연장근로 ${s.overtimeHours}시간` : null
|
||||
};
|
||||
});
|
||||
return {
|
||||
record_date: date,
|
||||
worker_id: w.worker_id,
|
||||
attendance_type_id: typeIdMap[s.type] || 1,
|
||||
vacation_type_id: vacationTypeIdMap[s.type] || null,
|
||||
total_work_hours: totalHours,
|
||||
overtime_approved: s.type === 'overtime',
|
||||
notes: s.type === 'overtime' ? `연장근로 ${s.overtimeHours}시간` : null
|
||||
};
|
||||
});
|
||||
|
||||
isSaving = true;
|
||||
saveBtn.disabled = true;
|
||||
@@ -535,5 +695,19 @@
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- 모바일 하단 네비게이션 -->
|
||||
<div id="mobile-nav-container"></div>
|
||||
<script>
|
||||
if (window.innerWidth <= 768) {
|
||||
fetch('/components/mobile-nav.html')
|
||||
.then(r => r.text())
|
||||
.then(html => {
|
||||
document.getElementById('mobile-nav-container').innerHTML = html;
|
||||
const scripts = document.getElementById('mobile-nav-container').querySelectorAll('script');
|
||||
scripts.forEach(s => eval(s.textContent));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<!-- 모던 디자인 시스템 적용 -->
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/modern-dashboard.css?v=2">
|
||||
<link rel="stylesheet" href="/css/mobile.css?v=1">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
|
||||
<!-- 최적화된 로딩: API 설정 → 앱 초기화 (병렬 컴포넌트 로딩) -->
|
||||
@@ -509,6 +510,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 모바일 하단 네비게이션 -->
|
||||
<div id="mobile-nav-container"></div>
|
||||
<script>
|
||||
// 모바일에서만 하단 네비게이션 로드
|
||||
if (window.innerWidth <= 768) {
|
||||
fetch('/components/mobile-nav.html')
|
||||
.then(r => r.text())
|
||||
.then(html => {
|
||||
document.getElementById('mobile-nav-container').innerHTML = html;
|
||||
// 스크립트 실행
|
||||
const scripts = document.getElementById('mobile-nav-container').querySelectorAll('script');
|
||||
scripts.forEach(s => eval(s.textContent));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -6,7 +6,7 @@
|
||||
<title>일일순회점검 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
|
||||
<link rel="stylesheet" href="/css/daily-patrol.css?v=3">
|
||||
<link rel="stylesheet" href="/css/daily-patrol.css?v=4">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
@@ -197,6 +197,6 @@
|
||||
}, 50);
|
||||
})();
|
||||
</script>
|
||||
<script src="/js/daily-patrol.js?v=3"></script>
|
||||
<script src="/js/daily-patrol.js?v=6"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
297
web-ui/pages/inspection/zone-detail.html
Normal file
297
web-ui/pages/inspection/zone-detail.html
Normal file
@@ -0,0 +1,297 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>구역 상세 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
|
||||
<link rel="stylesheet" href="/css/zone-detail.css?v=3">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 레이아웃 -->
|
||||
<div class="page-container">
|
||||
<main class="main-content">
|
||||
<div class="dashboard-main">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="zone-header">
|
||||
<div class="zone-header-left">
|
||||
<button class="btn btn-back" onclick="goBack()">
|
||||
<span>←</span> 돌아가기
|
||||
</button>
|
||||
</div>
|
||||
<div class="zone-header-center">
|
||||
<h1 id="zoneName" class="zone-title">작업장</h1>
|
||||
<p id="zoneCategory" class="zone-subtitle">공장</p>
|
||||
</div>
|
||||
<div class="zone-header-right">
|
||||
<span id="currentDate" class="current-date"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 요약 카드 -->
|
||||
<div id="summaryCards" class="summary-cards">
|
||||
<!-- JS에서 렌더링 -->
|
||||
</div>
|
||||
|
||||
<!-- 탭 네비게이션 -->
|
||||
<div class="tab-navigation">
|
||||
<button class="tab-btn active" data-tab="map" onclick="switchTab('map')">
|
||||
🗺️ 구역 현황
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="issues" onclick="switchTab('issues')">
|
||||
🚨 안전신고/부적합
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="equipment" onclick="switchTab('equipment')">
|
||||
⚙️ 설비/수리
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="visits" onclick="switchTab('visits')">
|
||||
🚶 출입현황
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="tbm" onclick="switchTab('tbm')">
|
||||
📋 TBM
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="patrol" onclick="switchTab('patrol')">
|
||||
🔍 순회점검
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 탭 콘텐츠 -->
|
||||
<div class="tab-contents">
|
||||
<!-- 구역 현황 탭 -->
|
||||
<div id="tab-map" class="tab-content active">
|
||||
<div class="map-editor-section">
|
||||
<div class="map-editor-header">
|
||||
<h3>구역 현황</h3>
|
||||
<div class="map-editor-actions">
|
||||
<button class="btn btn-primary btn-sm" id="addItemBtn" onclick="startAddItem()">
|
||||
➕ 현황 등록
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="map-editor-container">
|
||||
<div id="zoneMapContainer" class="zone-map-container">
|
||||
<div class="map-placeholder">지도를 로딩 중...</div>
|
||||
</div>
|
||||
<div class="map-legend">
|
||||
<div class="legend-title">주의 수준</div>
|
||||
<div class="legend-items">
|
||||
<div class="legend-item"><span class="legend-color" style="background: #10b981;"></span> 양호</div>
|
||||
<div class="legend-item"><span class="legend-color" style="background: #f59e0b;"></span> 주의</div>
|
||||
<div class="legend-item"><span class="legend-color" style="background: #ef4444;"></span> 관리필요</div>
|
||||
</div>
|
||||
<div class="legend-title" style="margin-top: 1rem;">설비 상태</div>
|
||||
<div class="legend-items">
|
||||
<div class="legend-item"><span style="margin-right: 4px;">⚙️</span> 정상 가동</div>
|
||||
<div class="legend-item"><span style="margin-right: 4px;">🔧</span> 수리 필요</div>
|
||||
<div class="legend-item"><span style="margin-right: 4px;">⚠️</span> 점검중</div>
|
||||
<div class="legend-item"><span style="margin-right: 4px;">📤</span> 타 작업장 이동</div>
|
||||
<div class="legend-item"><span style="margin-right: 4px;">📥</span> 임시 배치</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="zoneItemsList" class="zone-items-list">
|
||||
<!-- JS에서 렌더링 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 안전신고/부적합 탭 -->
|
||||
<div id="tab-issues" class="tab-content">
|
||||
<div id="issuesContent" class="content-loading">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 설비/수리 탭 -->
|
||||
<div id="tab-equipment" class="tab-content">
|
||||
<div id="equipmentContent" class="content-loading">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 출입현황 탭 -->
|
||||
<div id="tab-visits" class="tab-content">
|
||||
<div id="visitsContent" class="content-loading">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TBM 탭 -->
|
||||
<div id="tab-tbm" class="tab-content">
|
||||
<div id="tbmContent" class="content-loading">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 순회점검 탭 -->
|
||||
<div id="tab-patrol" class="tab-content">
|
||||
<div id="patrolContent" class="content-loading">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 현황 등록/수정 모달 -->
|
||||
<div id="zoneItemModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 520px;">
|
||||
<div class="modal-header">
|
||||
<h2 id="zoneItemModalTitle">현황 등록</h2>
|
||||
<button class="btn-close" onclick="closeZoneItemModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="zoneItemForm">
|
||||
<input type="hidden" id="zoneItemId">
|
||||
<input type="hidden" id="zoneItemX">
|
||||
<input type="hidden" id="zoneItemY">
|
||||
<input type="hidden" id="zoneItemWidth">
|
||||
<input type="hidden" id="zoneItemHeight">
|
||||
|
||||
<!-- 프로젝트 여부 -->
|
||||
<div class="form-group">
|
||||
<label>프로젝트 여부 *</label>
|
||||
<div class="radio-group">
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="zoneItemProjectType" value="project" onchange="onProjectTypeChange(this.value)">
|
||||
<span class="radio-text">프로젝트</span>
|
||||
</label>
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="zoneItemProjectType" value="non_project" onchange="onProjectTypeChange(this.value)" checked>
|
||||
<span class="radio-text">프로젝트 아님</span>
|
||||
</label>
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="zoneItemProjectType" value="unknown" onchange="onProjectTypeChange(this.value)">
|
||||
<span class="radio-text">판단 못함</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 프로젝트 선택 (프로젝트일 경우만 표시) -->
|
||||
<div class="form-group" id="projectSelectGroup" style="display: none;">
|
||||
<label for="zoneItemProject">프로젝트 선택</label>
|
||||
<select id="zoneItemProject" class="form-control">
|
||||
<option value="">프로젝트를 선택하세요</option>
|
||||
<!-- JS에서 동적 로드 -->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 명칭 -->
|
||||
<div class="form-group">
|
||||
<label for="zoneItemName">명칭 *</label>
|
||||
<input type="text" id="zoneItemName" class="form-control" placeholder="예: A사 제품, 작업 자재, 이동 설비" required>
|
||||
</div>
|
||||
|
||||
<!-- 상태/유형 + 주의수준 -->
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="flex: 1.5;">
|
||||
<label for="zoneItemType">상태/유형</label>
|
||||
<div class="select-with-add">
|
||||
<select id="zoneItemType" class="form-control">
|
||||
<option value="working">작업중</option>
|
||||
<option value="temp_storage">임시적치</option>
|
||||
<option value="moved_equipment">이동설비</option>
|
||||
<option value="unreported">미신고품</option>
|
||||
</select>
|
||||
<button type="button" class="btn-add-option" onclick="addCustomType()" title="유형 추가">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="flex: 1;">
|
||||
<label for="zoneItemWarning">주의 수준</label>
|
||||
<select id="zoneItemWarning" class="form-control">
|
||||
<option value="good">양호</option>
|
||||
<option value="caution">주의</option>
|
||||
<option value="needs_management">관리필요</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 상세 설명 -->
|
||||
<div class="form-group">
|
||||
<label for="zoneItemDesc">상세 설명</label>
|
||||
<textarea id="zoneItemDesc" class="form-control" rows="2" placeholder="현황에 대한 상세 설명, 주의사항, 담당자 등"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- 사진 등록 -->
|
||||
<div class="form-group">
|
||||
<label>사진</label>
|
||||
<div class="photo-upload-area">
|
||||
<input type="file" id="zoneItemPhoto" accept="image/*" multiple onchange="onPhotoSelected(event)" style="display: none;">
|
||||
<div id="photoPreviewList" class="photo-preview-list">
|
||||
<!-- 미리보기 이미지들 -->
|
||||
</div>
|
||||
<button type="button" class="btn-add-photo" onclick="document.getElementById('zoneItemPhoto').click()">
|
||||
<span class="photo-icon">📷</span>
|
||||
<span>사진 추가</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 표시 색상 -->
|
||||
<div class="form-group">
|
||||
<label>표시 색상</label>
|
||||
<div class="color-picker-row">
|
||||
<input type="color" id="zoneItemColor" class="form-control color-input" value="#3b82f6">
|
||||
<div class="color-presets">
|
||||
<button type="button" class="color-preset" style="background: #10b981;" onclick="setItemColor('#10b981')" title="양호"></button>
|
||||
<button type="button" class="color-preset" style="background: #f59e0b;" onclick="setItemColor('#f59e0b')" title="주의"></button>
|
||||
<button type="button" class="color-preset" style="background: #ef4444;" onclick="setItemColor('#ef4444')" title="관리필요"></button>
|
||||
<button type="button" class="color-preset" style="background: #3b82f6;" onclick="setItemColor('#3b82f6')" title="기본"></button>
|
||||
<button type="button" class="color-preset" style="background: #8b5cf6;" onclick="setItemColor('#8b5cf6')" title="기타"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeZoneItemModal()">취소</button>
|
||||
<button type="button" class="btn btn-danger" id="deleteZoneItemBtn" onclick="deleteZoneItem()" style="display: none;">삭제</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveZoneItem()">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script type="module">
|
||||
import '/js/api-config.js?v=3';
|
||||
</script>
|
||||
<script>
|
||||
(function() {
|
||||
const checkApiConfig = setInterval(() => {
|
||||
if (window.API_BASE_URL) {
|
||||
clearInterval(checkApiConfig);
|
||||
axios.defaults.baseURL = window.API_BASE_URL;
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
axios.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
if (error.response?.status === 401) {
|
||||
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
|
||||
window.location.href = '/pages/login.html';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
}, 50);
|
||||
})();
|
||||
</script>
|
||||
<script src="/js/zone-detail.js?v=6"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -702,13 +702,11 @@
|
||||
|
||||
updateTime: function() {
|
||||
const now = new Date();
|
||||
const timeString = now.toLocaleTimeString('ko-KR', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(now.getSeconds()).padStart(2, '0');
|
||||
const timeString = `${hours}시 ${minutes}분 ${seconds}초`;
|
||||
|
||||
const timeElement = document.querySelector('.time-value');
|
||||
if (timeElement) {
|
||||
timeElement.textContent = timeString;
|
||||
@@ -1490,9 +1488,10 @@
|
||||
}
|
||||
|
||||
const hours = parseFloat(work.work_hours) || 0;
|
||||
if (work.work_status === 'error' || work.error_type_id) {
|
||||
// work_status_id: 1=정상, 2=오류
|
||||
if (work.work_status_id === 2 || work.error_type_id) {
|
||||
vacationData.errorHours += hours;
|
||||
const errorTypeName = work.error_type_name || work.error_description || '설계미스';
|
||||
const errorTypeName = work.error_type_name || work.error_category_name || work.error_description || '미지정 오류';
|
||||
if (!vacationData.errorDetails.has(errorTypeName)) {
|
||||
vacationData.errorDetails.set(errorTypeName, 0);
|
||||
}
|
||||
@@ -1527,11 +1526,12 @@
|
||||
const workTypeData = workTypeMap.get(combinedKey);
|
||||
const hours = parseFloat(work.work_hours) || 0;
|
||||
|
||||
if (work.work_status === 'error' || work.error_type_id) {
|
||||
// work_status_id: 1=정상, 2=오류
|
||||
if (work.work_status_id === 2 || work.error_type_id) {
|
||||
workTypeData.errorHours += hours;
|
||||
|
||||
// 오류 유형별 세분화
|
||||
const errorTypeName = work.error_type_name || work.error_description || '설계미스';
|
||||
const errorTypeName = work.error_type_name || work.error_category_name || work.error_description || '미지정 오류';
|
||||
if (!workTypeData.errorDetails.has(errorTypeName)) {
|
||||
workTypeData.errorDetails.set(errorTypeName, 0);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<title>일일 작업보고서 작성 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/daily-work-report.css?v=12">
|
||||
<link rel="stylesheet" href="/css/mobile.css?v=1">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<!-- 최적화된 로딩 -->
|
||||
<script src="/js/api-base.js"></script>
|
||||
@@ -178,5 +179,19 @@
|
||||
|
||||
<!-- 기존 UI 로직 (점진적 마이그레이션) -->
|
||||
<script type="module" src="/js/daily-work-report.js?v=29"></script>
|
||||
|
||||
<!-- 모바일 하단 네비게이션 -->
|
||||
<div id="mobile-nav-container"></div>
|
||||
<script>
|
||||
if (window.innerWidth <= 768) {
|
||||
fetch('/components/mobile-nav.html')
|
||||
.then(r => r.text())
|
||||
.then(html => {
|
||||
document.getElementById('mobile-nav-container').innerHTML = html;
|
||||
const scripts = document.getElementById('mobile-nav-container').querySelectorAll('script');
|
||||
scripts.forEach(s => eval(s.textContent));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -7,6 +7,7 @@
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/common.css?v=2">
|
||||
<link rel="stylesheet" href="/css/tbm.css?v=1">
|
||||
<link rel="stylesheet" href="/css/mobile.css?v=1">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<!-- 최적화된 로딩: API 설정 → 앱 초기화 (병렬 컴포넌트 로딩) -->
|
||||
<script src="/js/api-base.js"></script>
|
||||
@@ -668,5 +669,19 @@
|
||||
|
||||
<!-- 기존 UI 로직 (점진적 마이그레이션) -->
|
||||
<script type="module" src="/js/tbm.js?v=4"></script>
|
||||
|
||||
<!-- 모바일 하단 네비게이션 -->
|
||||
<div id="mobile-nav-container"></div>
|
||||
<script>
|
||||
if (window.innerWidth <= 768) {
|
||||
fetch('/components/mobile-nav.html')
|
||||
.then(r => r.text())
|
||||
.then(html => {
|
||||
document.getElementById('mobile-nav-container').innerHTML = html;
|
||||
const scripts = document.getElementById('mobile-nav-container').querySelectorAll('script');
|
||||
scripts.forEach(s => eval(s.textContent));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user