feat: 다수 기능 개선 - 순찰, 출근, 작업분석, 모바일 UI 등

- 순찰/점검 기능 개선 (zone-detail 페이지 추가)
- 출근/근태 시스템 개선 (연차 조회, 근무현황)
- 작업분석 대분류 그룹화 및 마이그레이션 스크립트
- 모바일 네비게이션 UI 추가
- NAS 배포 도구 및 문서 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-02-09 14:41:01 +09:00
parent 1548253f56
commit 2b1c7bfb88
633 changed files with 361224 additions and 1090 deletions

View File

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

View File

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

View File

@@ -89,6 +89,9 @@
<span class="nav-arrow">&#9662;</span>
</button>
<div class="nav-category-items">
<a href="/pages/attendance/my-vacation-info.html" class="nav-item" data-page-key="attendance.my_vacation_info">
<span class="nav-text">내 연차 정보</span>
</a>
<a href="/pages/attendance/monthly.html" class="nav-item" data-page-key="attendance.monthly">
<span class="nav-text">월간 근태</span>
</a>
@@ -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>

View File

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

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

1491
web-ui/css/zone-detail.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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}`;
}
}

View File

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

View File

@@ -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}`;
}

View File

@@ -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) {

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
// 이미지 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()">&times;</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' });
}

View File

@@ -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, "&#39;")})' 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;">&times;</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 {

View File

@@ -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}`;
}
// 날짜 업데이트

View File

@@ -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}`;
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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) {

View File

@@ -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()">&times;</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>

View File

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

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

View File

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

View File

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

View File

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

View 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()">&times;</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>

View File

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

View File

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

View File

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