refactor: 코드 관리 페이지 삭제 및 프론트엔드 모듈화
- codes.html, code-management.js 삭제 (tasks.html에서 동일 기능 제공) - 사이드바에서 코드 관리 링크 제거 - daily-work-report, tbm, workplace-management JS 모듈 분리 - common/security.js 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,7 @@ TK-FB-Project/
|
|||||||
| 1 | [CODING_GUIDE.md](../CODING_GUIDE.md) | 프로젝트 실행, 코딩 규칙, API 개발 | 모든 개발자 |
|
| 1 | [CODING_GUIDE.md](../CODING_GUIDE.md) | 프로젝트 실행, 코딩 규칙, API 개발 | 모든 개발자 |
|
||||||
| 2 | [DEV_LOG.md](../DEV_LOG.md) | 최근 개발 현황 및 변경사항 | 모든 개발자 |
|
| 2 | [DEV_LOG.md](../DEV_LOG.md) | 최근 개발 현황 및 변경사항 | 모든 개발자 |
|
||||||
| 3 | [guides/SETUP.md](guides/SETUP.md) | 개발 환경 상세 설정 | 신규 개발자 |
|
| 3 | [guides/SETUP.md](guides/SETUP.md) | 개발 환경 상세 설정 | 신규 개발자 |
|
||||||
|
| 4 | [SECURITY_GUIDE.md](SECURITY_GUIDE.md) | 보안 취약점 및 개발 가이드 | 모든 개발자 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -55,6 +56,7 @@ TK-FB-Project/
|
|||||||
| [guides/SETUP.md](guides/SETUP.md) | 개발 환경 설정 |
|
| [guides/SETUP.md](guides/SETUP.md) | 개발 환경 설정 |
|
||||||
| [guides/work-report-time-input-guide.md](guides/work-report-time-input-guide.md) | 작업보고서 시간 입력 UX |
|
| [guides/work-report-time-input-guide.md](guides/work-report-time-input-guide.md) | 작업보고서 시간 입력 UX |
|
||||||
| [TBM_DEPLOYMENT_GUIDE.md](TBM_DEPLOYMENT_GUIDE.md) | TBM 시스템 배포/사용 가이드 |
|
| [TBM_DEPLOYMENT_GUIDE.md](TBM_DEPLOYMENT_GUIDE.md) | TBM 시스템 배포/사용 가이드 |
|
||||||
|
| [SECURITY_GUIDE.md](SECURITY_GUIDE.md) | **보안 가이드 (필독)** - 취약점 분석 및 보안 개발 가이드 |
|
||||||
|
|
||||||
### 3. 아키텍처 문서 (Architecture)
|
### 3. 아키텍처 문서 (Architecture)
|
||||||
|
|
||||||
|
|||||||
@@ -136,9 +136,6 @@
|
|||||||
<a href="/pages/admin/equipments.html" class="nav-item" data-page-key="admin.equipments">
|
<a href="/pages/admin/equipments.html" class="nav-item" data-page-key="admin.equipments">
|
||||||
<span class="nav-text">설비 관리</span>
|
<span class="nav-text">설비 관리</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/pages/admin/codes.html" class="nav-item" data-page-key="admin.codes">
|
|
||||||
<span class="nav-text">코드 관리</span>
|
|
||||||
</a>
|
|
||||||
<a href="/pages/admin/issue-categories.html" class="nav-item" data-page-key="admin.issue_categories">
|
<a href="/pages/admin/issue-categories.html" class="nav-item" data-page-key="admin.issue_categories">
|
||||||
<span class="nav-text">신고 카테고리 관리</span>
|
<span class="nav-text">신고 카테고리 관리</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,79 +1,141 @@
|
|||||||
/* daily-patrol.css - 일일순회점검 페이지 스타일 */
|
/* daily-patrol.css - 일일순회점검 페이지 스타일 */
|
||||||
|
|
||||||
/* 세션 선택 영역 */
|
/* 점검 시작 영역 */
|
||||||
.patrol-session-selector {
|
.patrol-start-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-direction: column;
|
||||||
gap: 1.5rem;
|
align-items: center;
|
||||||
padding: 1.5rem;
|
justify-content: center;
|
||||||
|
gap: 2rem;
|
||||||
|
padding: 3rem 1.5rem;
|
||||||
background: var(--surface-color, #fff);
|
background: var(--surface-color, #fff);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.patrol-date-time {
|
.patrol-start-section .btn-lg {
|
||||||
|
padding: 1.25rem 3rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
border-radius: 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
align-items: center;
|
||||||
align-items: flex-end;
|
gap: 0.75rem;
|
||||||
gap: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.patrol-date-time .form-group {
|
.patrol-start-section .btn-icon {
|
||||||
min-width: 140px;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.patrol-time-buttons {
|
/* 공장 선택 영역 */
|
||||||
display: flex;
|
.factory-selection-area {
|
||||||
gap: 0.5rem;
|
padding: 2rem;
|
||||||
}
|
|
||||||
|
|
||||||
.patrol-time-btn {
|
|
||||||
padding: 0.5rem 1.25rem;
|
|
||||||
border: 2px solid var(--border-color, #e2e8f0);
|
|
||||||
background: var(--surface-color, #fff);
|
background: var(--surface-color, #fff);
|
||||||
border-radius: 8px;
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.factory-selection-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.factory-selection-header h3 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.factory-selection-subtitle {
|
||||||
|
color: var(--text-secondary, #64748b);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.factory-cards-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.factory-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
|
background: var(--bg-color, #f8fafc);
|
||||||
|
border: 2px solid var(--border-color, #e2e8f0);
|
||||||
|
border-radius: 16px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 500;
|
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.patrol-time-btn:hover {
|
.factory-card:hover {
|
||||||
border-color: var(--primary-color, #3b82f6);
|
border-color: var(--primary-color, #3b82f6);
|
||||||
|
background: #eff6ff;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 8px 20px rgba(59, 130, 246, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.patrol-time-btn.active {
|
.factory-card:active {
|
||||||
background: var(--primary-color, #3b82f6);
|
transform: translateY(-2px);
|
||||||
border-color: var(--primary-color, #3b82f6);
|
}
|
||||||
color: #fff;
|
|
||||||
|
.factory-card-icon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--surface-color, #fff);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.factory-card-icon img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.factory-card-name {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 오늘 점검 현황 요약 */
|
/* 오늘 점검 현황 요약 */
|
||||||
.today-status-summary {
|
.today-status-summary {
|
||||||
flex: 1;
|
|
||||||
min-width: 300px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1.5rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 1rem;
|
justify-content: center;
|
||||||
|
padding: 1rem 2rem;
|
||||||
background: var(--bg-color, #f8fafc);
|
background: var(--bg-color, #f8fafc);
|
||||||
border-radius: 8px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-card {
|
.status-card {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 0.75rem 1rem;
|
padding: 1rem 1.5rem;
|
||||||
|
background: var(--surface-color, #fff);
|
||||||
|
border-radius: 8px;
|
||||||
|
min-width: 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-label {
|
.status-label {
|
||||||
font-size: 0.8rem;
|
font-size: 0.85rem;
|
||||||
color: var(--text-secondary, #64748b);
|
color: var(--text-secondary, #64748b);
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-value {
|
.status-value {
|
||||||
font-size: 1.25rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-value.completed {
|
.status-value.completed {
|
||||||
@@ -84,6 +146,12 @@
|
|||||||
color: var(--warning-color, #f59e0b);
|
color: var(--warning-color, #f59e0b);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-sub {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary, #64748b);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* 점검 영역 */
|
/* 점검 영역 */
|
||||||
.patrol-area {
|
.patrol-area {
|
||||||
background: var(--surface-color, #fff);
|
background: var(--surface-color, #fff);
|
||||||
@@ -265,12 +333,15 @@
|
|||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 작업장 목록 (지도 대체) */
|
/* 작업장 목록 */
|
||||||
.workplace-list-container {
|
.workplace-list-container {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: var(--bg-color, #f8fafc);
|
||||||
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workplace-card {
|
.workplace-card {
|
||||||
@@ -292,11 +363,21 @@
|
|||||||
background: #f0fdf4;
|
background: #f0fdf4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workplace-card.selected {
|
.workplace-card.in-progress {
|
||||||
border-color: var(--primary-color, #3b82f6);
|
border-color: var(--primary-color, #3b82f6);
|
||||||
background: #eff6ff;
|
background: #eff6ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workplace-card.selected {
|
||||||
|
border-color: var(--primary-color, #3b82f6);
|
||||||
|
background: var(--primary-color, #3b82f6);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workplace-card.selected .workplace-card-status {
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
.workplace-card-name {
|
.workplace-card-name {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
@@ -586,17 +667,47 @@
|
|||||||
|
|
||||||
/* 반응형 */
|
/* 반응형 */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.patrol-session-selector {
|
.patrol-start-section {
|
||||||
flex-direction: column;
|
padding: 2rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.patrol-date-time {
|
.patrol-start-section .btn-lg {
|
||||||
flex-direction: column;
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-status-summary {
|
||||||
|
flex-direction: row;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.patrol-date-time .form-group {
|
.status-card {
|
||||||
width: 100%;
|
flex: 1;
|
||||||
|
min-width: auto;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.factory-selection-area {
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.factory-cards-container {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.factory-card {
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.factory-card-icon {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.factory-card-name {
|
||||||
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.patrol-content {
|
.patrol-content {
|
||||||
|
|||||||
@@ -1,766 +0,0 @@
|
|||||||
// 코드 관리 페이지 JavaScript
|
|
||||||
|
|
||||||
// 전역 변수
|
|
||||||
let workStatusTypes = [];
|
|
||||||
let errorTypes = [];
|
|
||||||
let workTypes = [];
|
|
||||||
let currentCodeType = 'work-status';
|
|
||||||
let currentEditingCode = null;
|
|
||||||
|
|
||||||
// 페이지 초기화
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
console.log('🏷️ 코드 관리 페이지 초기화 시작');
|
|
||||||
|
|
||||||
initializePage();
|
|
||||||
loadAllCodes();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 페이지 초기화
|
|
||||||
function initializePage() {
|
|
||||||
// 시간 업데이트 시작
|
|
||||||
updateCurrentTime();
|
|
||||||
setInterval(updateCurrentTime, 1000);
|
|
||||||
|
|
||||||
// 사용자 정보 업데이트
|
|
||||||
updateUserInfo();
|
|
||||||
|
|
||||||
// 프로필 메뉴 토글
|
|
||||||
setupProfileMenu();
|
|
||||||
|
|
||||||
// 로그아웃 버튼
|
|
||||||
setupLogoutButton();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 현재 시간 업데이트
|
|
||||||
function updateCurrentTime() {
|
|
||||||
const now = new Date();
|
|
||||||
const timeString = now.toLocaleTimeString('ko-KR', {
|
|
||||||
hour12: false,
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
second: '2-digit'
|
|
||||||
});
|
|
||||||
|
|
||||||
const timeElement = document.getElementById('timeValue');
|
|
||||||
if (timeElement) {
|
|
||||||
timeElement.textContent = timeString;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 사용자 정보 업데이트
|
|
||||||
// navbar/sidebar는 app-init.js에서 공통 처리
|
|
||||||
function updateUserInfo() {
|
|
||||||
// app-init.js가 navbar 사용자 정보를 처리
|
|
||||||
}
|
|
||||||
|
|
||||||
// 프로필 메뉴 설정
|
|
||||||
function setupProfileMenu() {
|
|
||||||
const userProfile = document.getElementById('userProfile');
|
|
||||||
const profileMenu = document.getElementById('profileMenu');
|
|
||||||
|
|
||||||
if (userProfile && profileMenu) {
|
|
||||||
userProfile.addEventListener('click', function(e) {
|
|
||||||
e.stopPropagation();
|
|
||||||
const isVisible = profileMenu.style.display === 'block';
|
|
||||||
profileMenu.style.display = isVisible ? 'none' : 'block';
|
|
||||||
});
|
|
||||||
|
|
||||||
// 외부 클릭 시 메뉴 닫기
|
|
||||||
document.addEventListener('click', function() {
|
|
||||||
profileMenu.style.display = 'none';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 로그아웃 버튼 설정
|
|
||||||
function setupLogoutButton() {
|
|
||||||
const logoutBtn = document.getElementById('logoutBtn');
|
|
||||||
if (logoutBtn) {
|
|
||||||
logoutBtn.addEventListener('click', function() {
|
|
||||||
if (confirm('로그아웃 하시겠습니까?')) {
|
|
||||||
localStorage.removeItem('token');
|
|
||||||
localStorage.removeItem('user');
|
|
||||||
localStorage.removeItem('userInfo');
|
|
||||||
window.location.href = '/index.html';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 모든 코드 데이터 로드
|
|
||||||
async function loadAllCodes() {
|
|
||||||
try {
|
|
||||||
console.log('📊 모든 코드 데이터 로딩 시작');
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
loadWorkStatusTypes(),
|
|
||||||
// loadErrorTypes(), // 오류 유형은 신고 시스템으로 대체됨
|
|
||||||
loadWorkTypes()
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 현재 활성 탭 렌더링
|
|
||||||
renderCurrentTab();
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('코드 데이터 로딩 오류:', error);
|
|
||||||
showToast('코드 데이터를 불러오는데 실패했습니다.', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 작업 상태 유형 로드
|
|
||||||
async function loadWorkStatusTypes() {
|
|
||||||
try {
|
|
||||||
console.log('📊 작업 상태 유형 로딩...');
|
|
||||||
|
|
||||||
const response = await apiCall('/daily-work-reports/work-status-types', 'GET');
|
|
||||||
|
|
||||||
let statusData = [];
|
|
||||||
if (response && response.success && Array.isArray(response.data)) {
|
|
||||||
statusData = response.data;
|
|
||||||
} else if (Array.isArray(response)) {
|
|
||||||
statusData = response;
|
|
||||||
}
|
|
||||||
|
|
||||||
workStatusTypes = statusData;
|
|
||||||
console.log(`✅ 작업 상태 유형 ${workStatusTypes.length}개 로드 완료`);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('작업 상태 유형 로딩 오류:', error);
|
|
||||||
workStatusTypes = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 오류 유형 로드
|
|
||||||
async function loadErrorTypes() {
|
|
||||||
try {
|
|
||||||
console.log('⚠️ 오류 유형 로딩...');
|
|
||||||
|
|
||||||
const response = await apiCall('/daily-work-reports/error-types', 'GET');
|
|
||||||
|
|
||||||
let errorData = [];
|
|
||||||
if (response && response.success && Array.isArray(response.data)) {
|
|
||||||
errorData = response.data;
|
|
||||||
} else if (Array.isArray(response)) {
|
|
||||||
errorData = response;
|
|
||||||
}
|
|
||||||
|
|
||||||
errorTypes = errorData;
|
|
||||||
console.log(`✅ 오류 유형 ${errorTypes.length}개 로드 완료`);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('오류 유형 로딩 오류:', error);
|
|
||||||
errorTypes = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 작업 유형 로드
|
|
||||||
async function loadWorkTypes() {
|
|
||||||
try {
|
|
||||||
console.log('🔧 작업 유형 로딩...');
|
|
||||||
|
|
||||||
const response = await apiCall('/daily-work-reports/work-types', 'GET');
|
|
||||||
|
|
||||||
let typeData = [];
|
|
||||||
if (response && response.success && Array.isArray(response.data)) {
|
|
||||||
typeData = response.data;
|
|
||||||
} else if (Array.isArray(response)) {
|
|
||||||
typeData = response;
|
|
||||||
}
|
|
||||||
|
|
||||||
workTypes = typeData;
|
|
||||||
console.log(`✅ 작업 유형 ${workTypes.length}개 로드 완료`);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('작업 유형 로딩 오류:', error);
|
|
||||||
workTypes = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 코드 탭 전환
|
|
||||||
function switchCodeTab(tabName) {
|
|
||||||
// 탭 버튼 활성화 상태 변경
|
|
||||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
|
||||||
btn.classList.remove('active');
|
|
||||||
});
|
|
||||||
document.querySelector(`[data-tab="${tabName}"]`).classList.add('active');
|
|
||||||
|
|
||||||
// 탭 콘텐츠 표시/숨김
|
|
||||||
document.querySelectorAll('.code-tab-content').forEach(content => {
|
|
||||||
content.classList.remove('active');
|
|
||||||
});
|
|
||||||
document.getElementById(`${tabName}-tab`).classList.add('active');
|
|
||||||
|
|
||||||
currentCodeType = tabName;
|
|
||||||
renderCurrentTab();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 현재 탭 렌더링
|
|
||||||
function renderCurrentTab() {
|
|
||||||
switch (currentCodeType) {
|
|
||||||
case 'work-status':
|
|
||||||
renderWorkStatusTypes();
|
|
||||||
break;
|
|
||||||
// case 'error-types': // 오류 유형은 신고 시스템으로 대체됨
|
|
||||||
// renderErrorTypes();
|
|
||||||
// break;
|
|
||||||
case 'work-types':
|
|
||||||
renderWorkTypes();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 작업 상태 유형 렌더링
|
|
||||||
function renderWorkStatusTypes() {
|
|
||||||
const grid = document.getElementById('workStatusGrid');
|
|
||||||
if (!grid) return;
|
|
||||||
|
|
||||||
if (workStatusTypes.length === 0) {
|
|
||||||
grid.innerHTML = `
|
|
||||||
<div class="empty-state">
|
|
||||||
<div class="empty-icon">📊</div>
|
|
||||||
<h3>등록된 작업 상태 유형이 없습니다.</h3>
|
|
||||||
<p>"새 상태 추가" 버튼을 눌러 작업 상태를 등록해보세요.</p>
|
|
||||||
<button class="btn btn-primary" onclick="openCodeModal('work-status')">
|
|
||||||
➕ 첫 상태 추가하기
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
updateWorkStatusStats();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let gridHtml = '';
|
|
||||||
|
|
||||||
workStatusTypes.forEach(status => {
|
|
||||||
const isError = status.is_error === 1 || status.is_error === true;
|
|
||||||
const statusClass = isError ? 'error-status' : 'normal-status';
|
|
||||||
const statusIcon = isError ? '❌' : '✅';
|
|
||||||
const statusLabel = isError ? '오류' : '정상';
|
|
||||||
|
|
||||||
gridHtml += `
|
|
||||||
<div class="code-card ${statusClass}" onclick="editCode('work-status', ${status.id})">
|
|
||||||
<div class="code-header">
|
|
||||||
<div class="code-icon">${statusIcon}</div>
|
|
||||||
<div class="code-info">
|
|
||||||
<h3 class="code-name">${status.name}</h3>
|
|
||||||
<span class="code-label">${statusLabel}</span>
|
|
||||||
</div>
|
|
||||||
<div class="code-actions">
|
|
||||||
<button class="btn-small btn-edit" onclick="event.stopPropagation(); editCode('work-status', ${status.id})" title="수정">
|
|
||||||
✏️
|
|
||||||
</button>
|
|
||||||
<button class="btn-small btn-delete" onclick="event.stopPropagation(); confirmDeleteCode('work-status', ${status.id})" title="삭제">
|
|
||||||
🗑️
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
${status.description ? `<p class="code-description">${status.description}</p>` : ''}
|
|
||||||
<div class="code-meta">
|
|
||||||
<span class="code-date">등록: ${formatDate(status.created_at)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
|
|
||||||
grid.innerHTML = gridHtml;
|
|
||||||
updateWorkStatusStats();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 오류 유형 렌더링
|
|
||||||
function renderErrorTypes() {
|
|
||||||
const grid = document.getElementById('errorTypesGrid');
|
|
||||||
if (!grid) return;
|
|
||||||
|
|
||||||
if (errorTypes.length === 0) {
|
|
||||||
grid.innerHTML = `
|
|
||||||
<div class="empty-state">
|
|
||||||
<div class="empty-icon">⚠️</div>
|
|
||||||
<h3>등록된 오류 유형이 없습니다.</h3>
|
|
||||||
<p>"새 오류 유형 추가" 버튼을 눌러 오류 유형을 등록해보세요.</p>
|
|
||||||
<button class="btn btn-primary" onclick="openCodeModal('error-types')">
|
|
||||||
➕ 첫 오류 유형 추가하기
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
updateErrorTypesStats();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let gridHtml = '';
|
|
||||||
|
|
||||||
errorTypes.forEach(error => {
|
|
||||||
const severityMap = {
|
|
||||||
'low': { icon: '🟢', label: '낮음', class: 'severity-low' },
|
|
||||||
'medium': { icon: '🟡', label: '보통', class: 'severity-medium' },
|
|
||||||
'high': { icon: '🟠', label: '높음', class: 'severity-high' },
|
|
||||||
'critical': { icon: '🔴', label: '심각', class: 'severity-critical' }
|
|
||||||
};
|
|
||||||
|
|
||||||
const severity = severityMap[error.severity] || severityMap.medium;
|
|
||||||
|
|
||||||
gridHtml += `
|
|
||||||
<div class="code-card error-type-card ${severity.class}" onclick="editCode('error-types', ${error.id})">
|
|
||||||
<div class="code-header">
|
|
||||||
<div class="code-icon">⚠️</div>
|
|
||||||
<div class="code-info">
|
|
||||||
<h3 class="code-name">${error.name}</h3>
|
|
||||||
<span class="code-label">${severity.icon} ${severity.label}</span>
|
|
||||||
</div>
|
|
||||||
<div class="code-actions">
|
|
||||||
<button class="btn-small btn-edit" onclick="event.stopPropagation(); editCode('error-types', ${error.id})" title="수정">
|
|
||||||
✏️
|
|
||||||
</button>
|
|
||||||
<button class="btn-small btn-delete" onclick="event.stopPropagation(); confirmDeleteCode('error-types', ${error.id})" title="삭제">
|
|
||||||
🗑️
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
${error.description ? `<p class="code-description">${error.description}</p>` : ''}
|
|
||||||
${error.solution_guide ? `<div class="solution-guide"><strong>해결 가이드:</strong><br>${error.solution_guide}</div>` : ''}
|
|
||||||
<div class="code-meta">
|
|
||||||
<span class="code-date">등록: ${formatDate(error.created_at)}</span>
|
|
||||||
${error.updated_at !== error.created_at ? `<span class="code-date">수정: ${formatDate(error.updated_at)}</span>` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
|
|
||||||
grid.innerHTML = gridHtml;
|
|
||||||
updateErrorTypesStats();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 작업 유형 렌더링
|
|
||||||
function renderWorkTypes() {
|
|
||||||
const grid = document.getElementById('workTypesGrid');
|
|
||||||
if (!grid) return;
|
|
||||||
|
|
||||||
if (workTypes.length === 0) {
|
|
||||||
grid.innerHTML = `
|
|
||||||
<div class="empty-state">
|
|
||||||
<div class="empty-icon">🔧</div>
|
|
||||||
<h3>등록된 작업 유형이 없습니다.</h3>
|
|
||||||
<p>"새 작업 유형 추가" 버튼을 눌러 작업 유형을 등록해보세요.</p>
|
|
||||||
<button class="btn btn-primary" onclick="openCodeModal('work-types')">
|
|
||||||
➕ 첫 작업 유형 추가하기
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
updateWorkTypesStats();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let gridHtml = '';
|
|
||||||
|
|
||||||
workTypes.forEach(type => {
|
|
||||||
gridHtml += `
|
|
||||||
<div class="code-card work-type-card" onclick="editCode('work-types', ${type.id})">
|
|
||||||
<div class="code-header">
|
|
||||||
<div class="code-icon">🔧</div>
|
|
||||||
<div class="code-info">
|
|
||||||
<h3 class="code-name">${type.name}</h3>
|
|
||||||
${type.category ? `<span class="code-label">📁 ${type.category}</span>` : ''}
|
|
||||||
</div>
|
|
||||||
<div class="code-actions">
|
|
||||||
<button class="btn-small btn-edit" onclick="event.stopPropagation(); editCode('work-types', ${type.id})" title="수정">
|
|
||||||
✏️
|
|
||||||
</button>
|
|
||||||
<button class="btn-small btn-delete" onclick="event.stopPropagation(); confirmDeleteCode('work-types', ${type.id})" title="삭제">
|
|
||||||
🗑️
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
${type.description ? `<p class="code-description">${type.description}</p>` : ''}
|
|
||||||
<div class="code-meta">
|
|
||||||
<span class="code-date">등록: ${formatDate(type.created_at)}</span>
|
|
||||||
${type.updated_at !== type.created_at ? `<span class="code-date">수정: ${formatDate(type.updated_at)}</span>` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
|
|
||||||
grid.innerHTML = gridHtml;
|
|
||||||
updateWorkTypesStats();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 작업 상태 통계 업데이트
|
|
||||||
function updateWorkStatusStats() {
|
|
||||||
const total = workStatusTypes.length;
|
|
||||||
const normal = workStatusTypes.filter(s => !s.is_error).length;
|
|
||||||
const error = workStatusTypes.filter(s => s.is_error).length;
|
|
||||||
|
|
||||||
document.getElementById('workStatusCount').textContent = total;
|
|
||||||
document.getElementById('normalStatusCount').textContent = normal;
|
|
||||||
document.getElementById('errorStatusCount').textContent = error;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 오류 유형 통계 업데이트
|
|
||||||
function updateErrorTypesStats() {
|
|
||||||
const total = errorTypes.length;
|
|
||||||
const critical = errorTypes.filter(e => e.severity === 'critical').length;
|
|
||||||
const high = errorTypes.filter(e => e.severity === 'high').length;
|
|
||||||
const medium = errorTypes.filter(e => e.severity === 'medium').length;
|
|
||||||
const low = errorTypes.filter(e => e.severity === 'low').length;
|
|
||||||
|
|
||||||
document.getElementById('errorTypesCount').textContent = total;
|
|
||||||
document.getElementById('criticalErrorsCount').textContent = critical;
|
|
||||||
document.getElementById('highErrorsCount').textContent = high;
|
|
||||||
document.getElementById('mediumErrorsCount').textContent = medium;
|
|
||||||
document.getElementById('lowErrorsCount').textContent = low;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 작업 유형 통계 업데이트
|
|
||||||
function updateWorkTypesStats() {
|
|
||||||
const total = workTypes.length;
|
|
||||||
const categories = new Set(workTypes.map(t => t.category).filter(Boolean)).size;
|
|
||||||
|
|
||||||
document.getElementById('workTypesCount').textContent = total;
|
|
||||||
document.getElementById('workCategoriesCount').textContent = categories;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 코드 모달 열기
|
|
||||||
function openCodeModal(codeType, codeData = null) {
|
|
||||||
const modal = document.getElementById('codeModal');
|
|
||||||
const modalTitle = document.getElementById('modalTitle');
|
|
||||||
const deleteBtn = document.getElementById('deleteCodeBtn');
|
|
||||||
|
|
||||||
if (!modal) return;
|
|
||||||
|
|
||||||
currentEditingCode = codeData;
|
|
||||||
|
|
||||||
// 모든 전용 필드 숨기기
|
|
||||||
document.getElementById('isErrorGroup').style.display = 'none';
|
|
||||||
document.getElementById('severityGroup').style.display = 'none';
|
|
||||||
document.getElementById('solutionGuideGroup').style.display = 'none';
|
|
||||||
document.getElementById('categoryGroup').style.display = 'none';
|
|
||||||
|
|
||||||
// 코드 유형별 설정
|
|
||||||
switch (codeType) {
|
|
||||||
case 'work-status':
|
|
||||||
modalTitle.textContent = codeData ? '작업 상태 수정' : '새 작업 상태 추가';
|
|
||||||
document.getElementById('isErrorGroup').style.display = 'block';
|
|
||||||
break;
|
|
||||||
case 'error-types':
|
|
||||||
modalTitle.textContent = codeData ? '오류 유형 수정' : '새 오류 유형 추가';
|
|
||||||
document.getElementById('severityGroup').style.display = 'block';
|
|
||||||
document.getElementById('solutionGuideGroup').style.display = 'block';
|
|
||||||
break;
|
|
||||||
case 'work-types':
|
|
||||||
modalTitle.textContent = codeData ? '작업 유형 수정' : '새 작업 유형 추가';
|
|
||||||
document.getElementById('categoryGroup').style.display = 'block';
|
|
||||||
updateCategoryList();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('codeType').value = codeType;
|
|
||||||
|
|
||||||
if (codeData) {
|
|
||||||
// 수정 모드
|
|
||||||
deleteBtn.style.display = 'inline-flex';
|
|
||||||
|
|
||||||
// 폼에 데이터 채우기
|
|
||||||
document.getElementById('codeId').value = codeData.id;
|
|
||||||
document.getElementById('codeName').value = codeData.name || '';
|
|
||||||
document.getElementById('codeDescription').value = codeData.description || '';
|
|
||||||
|
|
||||||
// 코드 유형별 필드 채우기
|
|
||||||
if (codeType === 'work-status') {
|
|
||||||
document.getElementById('isError').checked = codeData.is_error === 1 || codeData.is_error === true;
|
|
||||||
} else if (codeType === 'error-types') {
|
|
||||||
document.getElementById('severity').value = codeData.severity || 'medium';
|
|
||||||
document.getElementById('solutionGuide').value = codeData.solution_guide || '';
|
|
||||||
} else if (codeType === 'work-types') {
|
|
||||||
document.getElementById('category').value = codeData.category || '';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 신규 등록 모드
|
|
||||||
deleteBtn.style.display = 'none';
|
|
||||||
|
|
||||||
// 폼 초기화
|
|
||||||
document.getElementById('codeForm').reset();
|
|
||||||
document.getElementById('codeId').value = '';
|
|
||||||
document.getElementById('codeType').value = codeType;
|
|
||||||
}
|
|
||||||
|
|
||||||
modal.style.display = 'flex';
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
|
|
||||||
// 첫 번째 입력 필드에 포커스
|
|
||||||
setTimeout(() => {
|
|
||||||
document.getElementById('codeName').focus();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 카테고리 목록 업데이트
|
|
||||||
function updateCategoryList() {
|
|
||||||
const categoryList = document.getElementById('categoryList');
|
|
||||||
if (categoryList) {
|
|
||||||
const categories = [...new Set(workTypes.map(t => t.category).filter(Boolean))].sort();
|
|
||||||
categoryList.innerHTML = categories.map(cat => `<option value="${cat}">`).join('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 코드 모달 닫기
|
|
||||||
function closeCodeModal() {
|
|
||||||
const modal = document.getElementById('codeModal');
|
|
||||||
if (modal) {
|
|
||||||
modal.style.display = 'none';
|
|
||||||
document.body.style.overflow = '';
|
|
||||||
currentEditingCode = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 코드 편집
|
|
||||||
function editCode(codeType, codeId) {
|
|
||||||
let codeData = null;
|
|
||||||
|
|
||||||
switch (codeType) {
|
|
||||||
case 'work-status':
|
|
||||||
codeData = workStatusTypes.find(s => s.id === codeId);
|
|
||||||
break;
|
|
||||||
case 'error-types':
|
|
||||||
codeData = errorTypes.find(e => e.id === codeId);
|
|
||||||
break;
|
|
||||||
case 'work-types':
|
|
||||||
codeData = workTypes.find(t => t.id === codeId);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (codeData) {
|
|
||||||
openCodeModal(codeType, codeData);
|
|
||||||
} else {
|
|
||||||
showToast('코드를 찾을 수 없습니다.', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 코드 저장
|
|
||||||
async function saveCode() {
|
|
||||||
try {
|
|
||||||
const codeType = document.getElementById('codeType').value;
|
|
||||||
const codeId = document.getElementById('codeId').value;
|
|
||||||
|
|
||||||
const codeData = {
|
|
||||||
name: document.getElementById('codeName').value.trim(),
|
|
||||||
description: document.getElementById('codeDescription').value.trim() || null
|
|
||||||
};
|
|
||||||
|
|
||||||
// 필수 필드 검증
|
|
||||||
if (!codeData.name) {
|
|
||||||
showToast('이름은 필수 입력 항목입니다.', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 코드 유형별 추가 필드
|
|
||||||
if (codeType === 'work-status') {
|
|
||||||
codeData.is_error = document.getElementById('isError').checked ? 1 : 0;
|
|
||||||
} else if (codeType === 'error-types') {
|
|
||||||
codeData.severity = document.getElementById('severity').value;
|
|
||||||
codeData.solution_guide = document.getElementById('solutionGuide').value.trim() || null;
|
|
||||||
} else if (codeType === 'work-types') {
|
|
||||||
codeData.category = document.getElementById('category').value.trim() || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('💾 저장할 코드 데이터:', codeData);
|
|
||||||
|
|
||||||
let endpoint = '';
|
|
||||||
switch (codeType) {
|
|
||||||
case 'work-status':
|
|
||||||
endpoint = '/daily-work-reports/work-status-types';
|
|
||||||
break;
|
|
||||||
case 'error-types':
|
|
||||||
endpoint = '/daily-work-reports/error-types';
|
|
||||||
break;
|
|
||||||
case 'work-types':
|
|
||||||
endpoint = '/daily-work-reports/work-types';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let response;
|
|
||||||
if (codeId) {
|
|
||||||
// 수정
|
|
||||||
response = await apiCall(`${endpoint}/${codeId}`, 'PUT', codeData);
|
|
||||||
} else {
|
|
||||||
// 신규 등록
|
|
||||||
response = await apiCall(endpoint, 'POST', codeData);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response && (response.success || response.id)) {
|
|
||||||
const action = codeId ? '수정' : '등록';
|
|
||||||
showToast(`코드가 성공적으로 ${action}되었습니다.`, 'success');
|
|
||||||
|
|
||||||
closeCodeModal();
|
|
||||||
await loadAllCodes();
|
|
||||||
} else {
|
|
||||||
throw new Error(response?.message || '저장에 실패했습니다.');
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('코드 저장 오류:', error);
|
|
||||||
showToast(error.message || '코드 저장 중 오류가 발생했습니다.', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 코드 삭제 확인
|
|
||||||
function confirmDeleteCode(codeType, codeId) {
|
|
||||||
let codeData = null;
|
|
||||||
let typeName = '';
|
|
||||||
|
|
||||||
switch (codeType) {
|
|
||||||
case 'work-status':
|
|
||||||
codeData = workStatusTypes.find(s => s.id === codeId);
|
|
||||||
typeName = '작업 상태';
|
|
||||||
break;
|
|
||||||
case 'error-types':
|
|
||||||
codeData = errorTypes.find(e => e.id === codeId);
|
|
||||||
typeName = '오류 유형';
|
|
||||||
break;
|
|
||||||
case 'work-types':
|
|
||||||
codeData = workTypes.find(t => t.id === codeId);
|
|
||||||
typeName = '작업 유형';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!codeData) {
|
|
||||||
showToast('코드를 찾을 수 없습니다.', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (confirm(`"${codeData.name}" ${typeName}을 정말 삭제하시겠습니까?\n\n⚠️ 삭제된 코드는 복구할 수 없습니다.`)) {
|
|
||||||
deleteCodeById(codeType, codeId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 코드 삭제 (수정 모드에서)
|
|
||||||
function deleteCode() {
|
|
||||||
if (currentEditingCode) {
|
|
||||||
const codeType = document.getElementById('codeType').value;
|
|
||||||
confirmDeleteCode(codeType, currentEditingCode.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 코드 삭제 실행
|
|
||||||
async function deleteCodeById(codeType, codeId) {
|
|
||||||
try {
|
|
||||||
let endpoint = '';
|
|
||||||
switch (codeType) {
|
|
||||||
case 'work-status':
|
|
||||||
endpoint = '/daily-work-reports/work-status-types';
|
|
||||||
break;
|
|
||||||
case 'error-types':
|
|
||||||
endpoint = '/daily-work-reports/error-types';
|
|
||||||
break;
|
|
||||||
case 'work-types':
|
|
||||||
endpoint = '/daily-work-reports/work-types';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await apiCall(`${endpoint}/${codeId}`, 'DELETE');
|
|
||||||
|
|
||||||
if (response && response.success) {
|
|
||||||
showToast('코드가 성공적으로 삭제되었습니다.', 'success');
|
|
||||||
|
|
||||||
closeCodeModal();
|
|
||||||
await loadAllCodes();
|
|
||||||
} else {
|
|
||||||
throw new Error(response?.message || '삭제에 실패했습니다.');
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('코드 삭제 오류:', error);
|
|
||||||
showToast(error.message || '코드 삭제 중 오류가 발생했습니다.', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 전체 새로고침
|
|
||||||
async function refreshAllCodes() {
|
|
||||||
const refreshBtn = document.querySelector('.btn-secondary');
|
|
||||||
if (refreshBtn) {
|
|
||||||
const originalText = refreshBtn.innerHTML;
|
|
||||||
refreshBtn.innerHTML = '<span class="btn-icon">⏳</span>새로고침 중...';
|
|
||||||
refreshBtn.disabled = true;
|
|
||||||
|
|
||||||
await loadAllCodes();
|
|
||||||
|
|
||||||
refreshBtn.innerHTML = originalText;
|
|
||||||
refreshBtn.disabled = false;
|
|
||||||
} else {
|
|
||||||
await loadAllCodes();
|
|
||||||
}
|
|
||||||
|
|
||||||
showToast('모든 코드 데이터가 새로고침되었습니다.', 'success');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 날짜 포맷팅
|
|
||||||
function formatDate(dateString) {
|
|
||||||
if (!dateString) return '';
|
|
||||||
|
|
||||||
const date = new Date(dateString);
|
|
||||||
return date.toLocaleDateString('ko-KR', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 토스트 메시지 표시
|
|
||||||
function showToast(message, type = 'info') {
|
|
||||||
// 기존 토스트 제거
|
|
||||||
const existingToast = document.querySelector('.toast');
|
|
||||||
if (existingToast) {
|
|
||||||
existingToast.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 새 토스트 생성
|
|
||||||
const toast = document.createElement('div');
|
|
||||||
toast.className = `toast toast-${type}`;
|
|
||||||
toast.textContent = message;
|
|
||||||
|
|
||||||
// 스타일 적용
|
|
||||||
Object.assign(toast.style, {
|
|
||||||
position: 'fixed',
|
|
||||||
top: '20px',
|
|
||||||
right: '20px',
|
|
||||||
padding: '12px 24px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
color: 'white',
|
|
||||||
fontWeight: '500',
|
|
||||||
zIndex: '1000',
|
|
||||||
transform: 'translateX(100%)',
|
|
||||||
transition: 'transform 0.3s ease'
|
|
||||||
});
|
|
||||||
|
|
||||||
// 타입별 배경색
|
|
||||||
const colors = {
|
|
||||||
success: '#10b981',
|
|
||||||
error: '#ef4444',
|
|
||||||
warning: '#f59e0b',
|
|
||||||
info: '#3b82f6'
|
|
||||||
};
|
|
||||||
toast.style.backgroundColor = colors[type] || colors.info;
|
|
||||||
|
|
||||||
document.body.appendChild(toast);
|
|
||||||
|
|
||||||
// 애니메이션
|
|
||||||
setTimeout(() => {
|
|
||||||
toast.style.transform = 'translateX(0)';
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
// 자동 제거
|
|
||||||
setTimeout(() => {
|
|
||||||
toast.style.transform = 'translateX(100%)';
|
|
||||||
setTimeout(() => {
|
|
||||||
if (toast.parentNode) {
|
|
||||||
toast.remove();
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 전역 함수로 노출
|
|
||||||
window.switchCodeTab = switchCodeTab;
|
|
||||||
window.openCodeModal = openCodeModal;
|
|
||||||
window.closeCodeModal = closeCodeModal;
|
|
||||||
window.editCode = editCode;
|
|
||||||
window.saveCode = saveCode;
|
|
||||||
window.deleteCode = deleteCode;
|
|
||||||
window.confirmDeleteCode = confirmDeleteCode;
|
|
||||||
window.refreshAllCodes = refreshAllCodes;
|
|
||||||
259
web-ui/js/common/security.js
Normal file
259
web-ui/js/common/security.js
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
/**
|
||||||
|
* Security Utilities - 보안 관련 유틸리티 함수
|
||||||
|
*
|
||||||
|
* XSS 방지, 입력값 검증, 안전한 DOM 조작을 위한 함수 모음
|
||||||
|
*
|
||||||
|
* @author TK-FB-Project
|
||||||
|
* @since 2026-02-04
|
||||||
|
*/
|
||||||
|
|
||||||
|
(function(global) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const SecurityUtils = {
|
||||||
|
/**
|
||||||
|
* HTML 특수문자 이스케이프 (XSS 방지)
|
||||||
|
* innerHTML에 사용자 입력을 삽입할 때 반드시 사용
|
||||||
|
*
|
||||||
|
* @param {string} str - 이스케이프할 문자열
|
||||||
|
* @returns {string} 이스케이프된 문자열
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* element.innerHTML = `<span>${SecurityUtils.escapeHtml(userInput)}</span>`;
|
||||||
|
*/
|
||||||
|
escapeHtml: function(str) {
|
||||||
|
if (str === null || str === undefined) return '';
|
||||||
|
if (typeof str !== 'string') str = String(str);
|
||||||
|
|
||||||
|
const htmlEntities = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": ''',
|
||||||
|
'/': '/',
|
||||||
|
'`': '`',
|
||||||
|
'=': '='
|
||||||
|
};
|
||||||
|
|
||||||
|
return str.replace(/[&<>"'`=\/]/g, function(char) {
|
||||||
|
return htmlEntities[char];
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL 파라미터 이스케이프
|
||||||
|
* URL에 사용자 입력을 포함할 때 사용
|
||||||
|
*
|
||||||
|
* @param {string} str - 이스케이프할 문자열
|
||||||
|
* @returns {string} URL 인코딩된 문자열
|
||||||
|
*/
|
||||||
|
escapeUrl: function(str) {
|
||||||
|
if (str === null || str === undefined) return '';
|
||||||
|
return encodeURIComponent(String(str));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JavaScript 문자열 이스케이프
|
||||||
|
* 동적 JavaScript 생성 시 사용 (권장하지 않음)
|
||||||
|
*
|
||||||
|
* @param {string} str - 이스케이프할 문자열
|
||||||
|
* @returns {string} 이스케이프된 문자열
|
||||||
|
*/
|
||||||
|
escapeJs: function(str) {
|
||||||
|
if (str === null || str === undefined) return '';
|
||||||
|
return String(str)
|
||||||
|
.replace(/\\/g, '\\\\')
|
||||||
|
.replace(/'/g, "\\'")
|
||||||
|
.replace(/"/g, '\\"')
|
||||||
|
.replace(/\n/g, '\\n')
|
||||||
|
.replace(/\r/g, '\\r')
|
||||||
|
.replace(/\t/g, '\\t');
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 안전한 텍스트 설정
|
||||||
|
* innerHTML 대신 textContent 사용 권장
|
||||||
|
*
|
||||||
|
* @param {Element} element - DOM 요소
|
||||||
|
* @param {string} text - 설정할 텍스트
|
||||||
|
*/
|
||||||
|
setTextSafe: function(element, text) {
|
||||||
|
if (element && element.nodeType === 1) {
|
||||||
|
element.textContent = text;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 안전한 HTML 삽입
|
||||||
|
* 사용자 입력이 포함된 HTML을 삽입할 때 사용
|
||||||
|
*
|
||||||
|
* @param {Element} element - DOM 요소
|
||||||
|
* @param {string} template - HTML 템플릿 ({{변수}} 형식)
|
||||||
|
* @param {Object} data - 삽입할 데이터 (자동 이스케이프됨)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* SecurityUtils.setHtmlSafe(div, '<span>{{name}}</span>', { name: userInput });
|
||||||
|
*/
|
||||||
|
setHtmlSafe: function(element, template, data) {
|
||||||
|
if (!element || element.nodeType !== 1) return;
|
||||||
|
|
||||||
|
const self = this;
|
||||||
|
const safeHtml = template.replace(/\{\{(\w+)\}\}/g, function(match, key) {
|
||||||
|
return data.hasOwnProperty(key) ? self.escapeHtml(data[key]) : '';
|
||||||
|
});
|
||||||
|
|
||||||
|
element.innerHTML = safeHtml;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 입력값 검증 - 숫자
|
||||||
|
*
|
||||||
|
* @param {any} value - 검증할 값
|
||||||
|
* @param {Object} options - 옵션 { min, max, allowFloat }
|
||||||
|
* @returns {number|null} 유효한 숫자 또는 null
|
||||||
|
*/
|
||||||
|
validateNumber: function(value, options) {
|
||||||
|
options = options || {};
|
||||||
|
const num = options.allowFloat ? parseFloat(value) : parseInt(value, 10);
|
||||||
|
|
||||||
|
if (isNaN(num)) return null;
|
||||||
|
if (options.min !== undefined && num < options.min) return null;
|
||||||
|
if (options.max !== undefined && num > options.max) return null;
|
||||||
|
|
||||||
|
return num;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 입력값 검증 - 이메일
|
||||||
|
*
|
||||||
|
* @param {string} email - 검증할 이메일
|
||||||
|
* @returns {boolean} 유효 여부
|
||||||
|
*/
|
||||||
|
validateEmail: function(email) {
|
||||||
|
if (!email || typeof email !== 'string') return false;
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
return emailRegex.test(email);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 입력값 검증 - 길이
|
||||||
|
*
|
||||||
|
* @param {string} str - 검증할 문자열
|
||||||
|
* @param {Object} options - 옵션 { min, max }
|
||||||
|
* @returns {boolean} 유효 여부
|
||||||
|
*/
|
||||||
|
validateLength: function(str, options) {
|
||||||
|
options = options || {};
|
||||||
|
if (!str || typeof str !== 'string') return false;
|
||||||
|
|
||||||
|
const len = str.length;
|
||||||
|
if (options.min !== undefined && len < options.min) return false;
|
||||||
|
if (options.max !== undefined && len > options.max) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 안전한 JSON 파싱
|
||||||
|
*
|
||||||
|
* @param {string} jsonString - 파싱할 JSON 문자열
|
||||||
|
* @param {any} defaultValue - 파싱 실패 시 기본값
|
||||||
|
* @returns {any} 파싱된 객체 또는 기본값
|
||||||
|
*/
|
||||||
|
parseJsonSafe: function(jsonString, defaultValue) {
|
||||||
|
defaultValue = defaultValue === undefined ? null : defaultValue;
|
||||||
|
try {
|
||||||
|
return JSON.parse(jsonString);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[SecurityUtils] JSON 파싱 실패:', e.message);
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* localStorage에서 안전하게 데이터 가져오기
|
||||||
|
*
|
||||||
|
* @param {string} key - 키
|
||||||
|
* @param {any} defaultValue - 기본값
|
||||||
|
* @returns {any} 저장된 값 또는 기본값
|
||||||
|
*/
|
||||||
|
getStorageSafe: function(key, defaultValue) {
|
||||||
|
try {
|
||||||
|
const item = localStorage.getItem(key);
|
||||||
|
if (item === null) return defaultValue;
|
||||||
|
return this.parseJsonSafe(item, defaultValue);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[SecurityUtils] localStorage 접근 실패:', e.message);
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL 파라미터 안전하게 가져오기
|
||||||
|
*
|
||||||
|
* @param {string} name - 파라미터 이름
|
||||||
|
* @param {string} defaultValue - 기본값
|
||||||
|
* @returns {string} 파라미터 값 (이스케이프됨)
|
||||||
|
*/
|
||||||
|
getUrlParamSafe: function(name, defaultValue) {
|
||||||
|
defaultValue = defaultValue === undefined ? '' : defaultValue;
|
||||||
|
try {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const value = urlParams.get(name);
|
||||||
|
return value !== null ? value : defaultValue;
|
||||||
|
} catch (e) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ID 파라미터 안전하게 가져오기 (숫자 검증)
|
||||||
|
*
|
||||||
|
* @param {string} name - 파라미터 이름
|
||||||
|
* @returns {number|null} 유효한 ID 또는 null
|
||||||
|
*/
|
||||||
|
getIdParamSafe: function(name) {
|
||||||
|
const value = this.getUrlParamSafe(name);
|
||||||
|
return this.validateNumber(value, { min: 1 });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content Security Policy 위반 리포터
|
||||||
|
*
|
||||||
|
* @param {string} reportUri - 리포트 전송 URL
|
||||||
|
*/
|
||||||
|
enableCspReporting: function(reportUri) {
|
||||||
|
document.addEventListener('securitypolicyviolation', function(e) {
|
||||||
|
console.error('[CSP Violation]', {
|
||||||
|
blockedUri: e.blockedURI,
|
||||||
|
violatedDirective: e.violatedDirective,
|
||||||
|
originalPolicy: e.originalPolicy
|
||||||
|
});
|
||||||
|
|
||||||
|
if (reportUri) {
|
||||||
|
fetch(reportUri, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
blocked_uri: e.blockedURI,
|
||||||
|
violated_directive: e.violatedDirective,
|
||||||
|
document_uri: e.documentURI,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}),
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}).catch(function() {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 전역 노출
|
||||||
|
global.SecurityUtils = SecurityUtils;
|
||||||
|
|
||||||
|
// 편의를 위한 단축 함수
|
||||||
|
global.escapeHtml = SecurityUtils.escapeHtml.bind(SecurityUtils);
|
||||||
|
global.escapeUrl = SecurityUtils.escapeUrl.bind(SecurityUtils);
|
||||||
|
|
||||||
|
console.log('[Module] common/security.js 로드 완료');
|
||||||
|
|
||||||
|
})(typeof window !== 'undefined' ? window : this);
|
||||||
386
web-ui/js/daily-work-report/api.js
Normal file
386
web-ui/js/daily-work-report/api.js
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
/**
|
||||||
|
* Daily Work Report - API Client
|
||||||
|
* 작업보고서 관련 모든 API 호출을 관리
|
||||||
|
*/
|
||||||
|
|
||||||
|
class DailyWorkReportAPI {
|
||||||
|
constructor() {
|
||||||
|
this.state = window.DailyWorkReportState;
|
||||||
|
console.log('[API] DailyWorkReportAPI 초기화');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업자 로드 (생산팀 소속)
|
||||||
|
*/
|
||||||
|
async loadWorkers() {
|
||||||
|
try {
|
||||||
|
console.log('[API] Workers 로딩 중...');
|
||||||
|
const data = await window.apiCall('/workers?limit=1000&department_id=1');
|
||||||
|
const allWorkers = Array.isArray(data) ? data : (data.data || data.workers || []);
|
||||||
|
|
||||||
|
// 퇴사자만 제외
|
||||||
|
const filtered = allWorkers.filter(worker => worker.employment_status !== 'resigned');
|
||||||
|
|
||||||
|
this.state.workers = filtered;
|
||||||
|
console.log(`[API] Workers 로드 완료: ${filtered.length}명`);
|
||||||
|
return filtered;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[API] 작업자 로딩 오류:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로젝트 로드 (활성 프로젝트만)
|
||||||
|
*/
|
||||||
|
async loadProjects() {
|
||||||
|
try {
|
||||||
|
console.log('[API] Projects 로딩 중...');
|
||||||
|
const data = await window.apiCall('/projects/active/list');
|
||||||
|
const projects = Array.isArray(data) ? data : (data.data || data.projects || []);
|
||||||
|
|
||||||
|
this.state.projects = projects;
|
||||||
|
console.log(`[API] Projects 로드 완료: ${projects.length}개`);
|
||||||
|
return projects;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[API] 프로젝트 로딩 오류:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업 유형 로드
|
||||||
|
*/
|
||||||
|
async loadWorkTypes() {
|
||||||
|
try {
|
||||||
|
const data = await window.apiCall('/daily-work-reports/work-types');
|
||||||
|
if (Array.isArray(data) && data.length > 0) {
|
||||||
|
this.state.workTypes = data;
|
||||||
|
console.log('[API] 작업 유형 로드 완료:', data.length);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
throw new Error('API 실패');
|
||||||
|
} catch (error) {
|
||||||
|
console.log('[API] 작업 유형 API 사용 불가, 기본값 사용');
|
||||||
|
this.state.workTypes = [
|
||||||
|
{ id: 1, name: 'Base' },
|
||||||
|
{ id: 2, name: 'Vessel' },
|
||||||
|
{ id: 3, name: 'Piping' }
|
||||||
|
];
|
||||||
|
return this.state.workTypes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 업무 상태 유형 로드
|
||||||
|
*/
|
||||||
|
async loadWorkStatusTypes() {
|
||||||
|
try {
|
||||||
|
const data = await window.apiCall('/daily-work-reports/work-status-types');
|
||||||
|
if (Array.isArray(data) && data.length > 0) {
|
||||||
|
this.state.workStatusTypes = data;
|
||||||
|
console.log('[API] 업무 상태 유형 로드 완료:', data.length);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
throw new Error('API 실패');
|
||||||
|
} catch (error) {
|
||||||
|
console.log('[API] 업무 상태 유형 API 사용 불가, 기본값 사용');
|
||||||
|
this.state.workStatusTypes = [
|
||||||
|
{ id: 1, name: '정상', is_error: false },
|
||||||
|
{ id: 2, name: '부적합', is_error: true }
|
||||||
|
];
|
||||||
|
return this.state.workStatusTypes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 오류 유형 로드 (신고 카테고리/아이템)
|
||||||
|
*/
|
||||||
|
async loadErrorTypes() {
|
||||||
|
try {
|
||||||
|
// 1. 신고 카테고리 (nonconformity만)
|
||||||
|
const categoriesResponse = await window.apiCall('/work-issues/categories');
|
||||||
|
if (categoriesResponse.success && categoriesResponse.data) {
|
||||||
|
this.state.issueCategories = categoriesResponse.data.filter(
|
||||||
|
c => c.category_type === 'nonconformity'
|
||||||
|
);
|
||||||
|
console.log('[API] 신고 카테고리 로드:', this.state.issueCategories.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 신고 아이템 전체
|
||||||
|
const itemsResponse = await window.apiCall('/work-issues/items');
|
||||||
|
if (itemsResponse.success && itemsResponse.data) {
|
||||||
|
// nonconformity 카테고리의 아이템만 필터링
|
||||||
|
const nonconfCatIds = this.state.issueCategories.map(c => c.category_id);
|
||||||
|
this.state.issueItems = itemsResponse.data.filter(
|
||||||
|
item => nonconfCatIds.includes(item.category_id)
|
||||||
|
);
|
||||||
|
console.log('[API] 신고 아이템 로드:', this.state.issueItems.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 레거시 호환: errorTypes에 카테고리 매핑
|
||||||
|
this.state.errorTypes = this.state.issueCategories.map(cat => ({
|
||||||
|
id: cat.category_id,
|
||||||
|
name: cat.category_name
|
||||||
|
}));
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[API] 오류 유형 로딩 오류:', error);
|
||||||
|
// 기본값 설정
|
||||||
|
this.state.errorTypes = [
|
||||||
|
{ id: 1, name: '자재 부적합' },
|
||||||
|
{ id: 2, name: '도면 오류' },
|
||||||
|
{ id: 3, name: '장비 고장' }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 미완료 TBM 세션 로드
|
||||||
|
*/
|
||||||
|
async loadIncompleteTbms() {
|
||||||
|
try {
|
||||||
|
const response = await window.apiCall('/tbm/sessions/incomplete-reports');
|
||||||
|
|
||||||
|
if (!response.success) {
|
||||||
|
throw new Error(response.message || '미완료 TBM 조회 실패');
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = response.data || [];
|
||||||
|
|
||||||
|
// 사용자 권한 확인 및 필터링
|
||||||
|
const user = this.state.getUser();
|
||||||
|
if (user && user.role !== 'Admin' && user.access_level !== 'system') {
|
||||||
|
const userId = user.user_id;
|
||||||
|
data = data.filter(tbm => tbm.created_by === userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state.incompleteTbms = data;
|
||||||
|
console.log('[API] 미완료 TBM 로드 완료:', data.length);
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[API] 미완료 TBM 로드 오류:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TBM 세션별 당일 신고 로드
|
||||||
|
*/
|
||||||
|
async loadDailyIssuesForTbms() {
|
||||||
|
const tbms = this.state.incompleteTbms;
|
||||||
|
if (!tbms || tbms.length === 0) {
|
||||||
|
console.log('[API] 미완료 TBM 없음, 신고 조회 건너뜀');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 고유한 날짜 수집
|
||||||
|
const uniqueDates = [...new Set(tbms.map(tbm => {
|
||||||
|
return window.DailyWorkReportUtils?.formatDateForApi(tbm.session_date) ||
|
||||||
|
this.formatDateForApi(tbm.session_date);
|
||||||
|
}).filter(Boolean))];
|
||||||
|
|
||||||
|
console.log('[API] 조회할 날짜들:', uniqueDates);
|
||||||
|
|
||||||
|
for (const dateStr of uniqueDates) {
|
||||||
|
if (this.state.dailyIssuesCache[dateStr]) {
|
||||||
|
console.log(`[API] 캐시 사용 (${dateStr})`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await window.apiCall(`/work-issues?start_date=${dateStr}&end_date=${dateStr}`);
|
||||||
|
if (response.success) {
|
||||||
|
this.state.setDailyIssuesCache(dateStr, response.data || []);
|
||||||
|
console.log(`[API] 신고 로드 완료 (${dateStr}):`, this.state.dailyIssuesCache[dateStr].length);
|
||||||
|
} else {
|
||||||
|
this.state.setDailyIssuesCache(dateStr, []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[API] 신고 조회 오류 (${dateStr}):`, error);
|
||||||
|
this.state.setDailyIssuesCache(dateStr, []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 완료된 작업보고서 조회
|
||||||
|
*/
|
||||||
|
async loadCompletedReports(date) {
|
||||||
|
try {
|
||||||
|
const response = await window.apiCall(`/daily-work-reports/v2/reports?date=${date}`);
|
||||||
|
if (response.success) {
|
||||||
|
console.log(`[API] 완료 보고서 로드 (${date}):`, response.data?.length || 0);
|
||||||
|
return response.data || [];
|
||||||
|
}
|
||||||
|
throw new Error(response.message || '조회 실패');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[API] 완료 보고서 로드 오류:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TBM 작업보고서 제출
|
||||||
|
*/
|
||||||
|
async submitTbmWorkReport(reportData) {
|
||||||
|
try {
|
||||||
|
const response = await window.apiCall('/daily-work-reports/from-tbm', 'POST', reportData);
|
||||||
|
if (!response.success) {
|
||||||
|
throw new Error(response.message || '제출 실패');
|
||||||
|
}
|
||||||
|
console.log('[API] TBM 작업보고서 제출 완료:', response);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[API] TBM 작업보고서 제출 오류:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 수동 작업보고서 제출
|
||||||
|
*/
|
||||||
|
async submitManualWorkReport(reportData) {
|
||||||
|
try {
|
||||||
|
const response = await window.apiCall('/daily-work-reports/v2/reports', 'POST', reportData);
|
||||||
|
if (!response.success) {
|
||||||
|
throw new Error(response.message || '제출 실패');
|
||||||
|
}
|
||||||
|
console.log('[API] 수동 작업보고서 제출 완료:', response);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[API] 수동 작업보고서 제출 오류:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업보고서 삭제
|
||||||
|
*/
|
||||||
|
async deleteWorkReport(reportId) {
|
||||||
|
try {
|
||||||
|
const response = await window.apiCall(`/daily-work-reports/v2/reports/${reportId}`, 'DELETE');
|
||||||
|
if (!response.success) {
|
||||||
|
throw new Error(response.message || '삭제 실패');
|
||||||
|
}
|
||||||
|
console.log('[API] 작업보고서 삭제 완료:', reportId);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[API] 작업보고서 삭제 오류:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업보고서 수정
|
||||||
|
*/
|
||||||
|
async updateWorkReport(reportId, updateData) {
|
||||||
|
try {
|
||||||
|
const response = await window.apiCall(`/daily-work-reports/v2/reports/${reportId}`, 'PUT', updateData);
|
||||||
|
if (!response.success) {
|
||||||
|
throw new Error(response.message || '수정 실패');
|
||||||
|
}
|
||||||
|
console.log('[API] 작업보고서 수정 완료:', reportId);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[API] 작업보고서 수정 오류:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 신고 카테고리 추가
|
||||||
|
*/
|
||||||
|
async addIssueCategory(categoryData) {
|
||||||
|
try {
|
||||||
|
const response = await window.apiCall('/work-issues/categories', 'POST', categoryData);
|
||||||
|
if (response.success) {
|
||||||
|
await this.loadErrorTypes(); // 목록 새로고침
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[API] 카테고리 추가 오류:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 신고 아이템 추가
|
||||||
|
*/
|
||||||
|
async addIssueItem(itemData) {
|
||||||
|
try {
|
||||||
|
const response = await window.apiCall('/work-issues/items', 'POST', itemData);
|
||||||
|
if (response.success) {
|
||||||
|
await this.loadErrorTypes(); // 목록 새로고침
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[API] 아이템 추가 오류:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모든 기본 데이터 로드
|
||||||
|
*/
|
||||||
|
async loadAllData() {
|
||||||
|
console.log('[API] 모든 기본 데이터 로딩 시작...');
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
this.loadWorkers(),
|
||||||
|
this.loadProjects(),
|
||||||
|
this.loadWorkTypes(),
|
||||||
|
this.loadWorkStatusTypes(),
|
||||||
|
this.loadErrorTypes()
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log('[API] 모든 기본 데이터 로딩 완료');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 유틸리티: 날짜 형식 변환 (API 형식)
|
||||||
|
formatDateForApi(date) {
|
||||||
|
if (!date) return null;
|
||||||
|
|
||||||
|
let dateObj;
|
||||||
|
if (date instanceof Date) {
|
||||||
|
dateObj = date;
|
||||||
|
} else if (typeof date === 'string') {
|
||||||
|
dateObj = new Date(date);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = dateObj.getFullYear();
|
||||||
|
const month = String(dateObj.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(dateObj.getDate()).padStart(2, '0');
|
||||||
|
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전역 인스턴스 생성
|
||||||
|
window.DailyWorkReportAPI = new DailyWorkReportAPI();
|
||||||
|
|
||||||
|
// 하위 호환성: 기존 함수들
|
||||||
|
window.loadWorkers = () => window.DailyWorkReportAPI.loadWorkers();
|
||||||
|
window.loadProjects = () => window.DailyWorkReportAPI.loadProjects();
|
||||||
|
window.loadWorkTypes = () => window.DailyWorkReportAPI.loadWorkTypes();
|
||||||
|
window.loadWorkStatusTypes = () => window.DailyWorkReportAPI.loadWorkStatusTypes();
|
||||||
|
window.loadErrorTypes = () => window.DailyWorkReportAPI.loadErrorTypes();
|
||||||
|
window.loadIncompleteTbms = () => window.DailyWorkReportAPI.loadIncompleteTbms();
|
||||||
|
window.loadDailyIssuesForTbms = () => window.DailyWorkReportAPI.loadDailyIssuesForTbms();
|
||||||
|
window.loadCompletedReports = () => window.DailyWorkReportAPI.loadCompletedReports(
|
||||||
|
document.getElementById('completedReportDate')?.value
|
||||||
|
);
|
||||||
|
|
||||||
|
// 통합 데이터 로드 함수
|
||||||
|
window.loadData = async () => {
|
||||||
|
try {
|
||||||
|
window.showMessage?.('데이터를 불러오는 중...', 'loading');
|
||||||
|
await window.DailyWorkReportAPI.loadAllData();
|
||||||
|
window.hideMessage?.();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[API] 데이터 로드 실패:', error);
|
||||||
|
window.showMessage?.('데이터 로드 중 오류가 발생했습니다: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
318
web-ui/js/daily-work-report/index.js
Normal file
318
web-ui/js/daily-work-report/index.js
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
/**
|
||||||
|
* Daily Work Report - Module Loader
|
||||||
|
* 작업보고서 모듈을 초기화하고 연결하는 메인 진입점
|
||||||
|
*
|
||||||
|
* 로드 순서:
|
||||||
|
* 1. state.js - 전역 상태 관리
|
||||||
|
* 2. utils.js - 유틸리티 함수
|
||||||
|
* 3. api.js - API 클라이언트
|
||||||
|
* 4. index.js - 이 파일 (메인 컨트롤러)
|
||||||
|
*/
|
||||||
|
|
||||||
|
class DailyWorkReportController {
|
||||||
|
constructor() {
|
||||||
|
this.state = window.DailyWorkReportState;
|
||||||
|
this.api = window.DailyWorkReportAPI;
|
||||||
|
this.utils = window.DailyWorkReportUtils;
|
||||||
|
this.initialized = false;
|
||||||
|
|
||||||
|
console.log('[Controller] DailyWorkReportController 생성');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 초기화
|
||||||
|
*/
|
||||||
|
async init() {
|
||||||
|
if (this.initialized) {
|
||||||
|
console.log('[Controller] 이미 초기화됨');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Controller] 초기화 시작...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 이벤트 리스너 설정
|
||||||
|
this.setupEventListeners();
|
||||||
|
|
||||||
|
// 기본 데이터 로드
|
||||||
|
await this.api.loadAllData();
|
||||||
|
|
||||||
|
// TBM 탭이 기본
|
||||||
|
await this.switchTab('tbm');
|
||||||
|
|
||||||
|
this.initialized = true;
|
||||||
|
console.log('[Controller] 초기화 완료');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Controller] 초기화 실패:', error);
|
||||||
|
window.showMessage?.('초기화 중 오류가 발생했습니다: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트 리스너 설정
|
||||||
|
*/
|
||||||
|
setupEventListeners() {
|
||||||
|
// 탭 버튼
|
||||||
|
const tbmBtn = document.getElementById('tbmReportTab');
|
||||||
|
const completedBtn = document.getElementById('completedReportTab');
|
||||||
|
|
||||||
|
if (tbmBtn) {
|
||||||
|
tbmBtn.addEventListener('click', () => this.switchTab('tbm'));
|
||||||
|
}
|
||||||
|
if (completedBtn) {
|
||||||
|
completedBtn.addEventListener('click', () => this.switchTab('completed'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 완료 보고서 날짜 변경
|
||||||
|
const completedDateInput = document.getElementById('completedReportDate');
|
||||||
|
if (completedDateInput) {
|
||||||
|
completedDateInput.addEventListener('change', () => this.loadCompletedReports());
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Controller] 이벤트 리스너 설정 완료');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 탭 전환
|
||||||
|
*/
|
||||||
|
async switchTab(tab) {
|
||||||
|
this.state.setCurrentTab(tab);
|
||||||
|
|
||||||
|
const tbmBtn = document.getElementById('tbmReportTab');
|
||||||
|
const completedBtn = document.getElementById('completedReportTab');
|
||||||
|
const tbmSection = document.getElementById('tbmReportSection');
|
||||||
|
const completedSection = document.getElementById('completedReportSection');
|
||||||
|
|
||||||
|
// 모든 탭 버튼 비활성화
|
||||||
|
tbmBtn?.classList.remove('active');
|
||||||
|
completedBtn?.classList.remove('active');
|
||||||
|
|
||||||
|
// 모든 섹션 숨기기
|
||||||
|
if (tbmSection) tbmSection.style.display = 'none';
|
||||||
|
if (completedSection) completedSection.style.display = 'none';
|
||||||
|
|
||||||
|
// 선택된 탭 활성화
|
||||||
|
if (tab === 'tbm') {
|
||||||
|
tbmBtn?.classList.add('active');
|
||||||
|
if (tbmSection) tbmSection.style.display = 'block';
|
||||||
|
await this.loadTbmData();
|
||||||
|
} else if (tab === 'completed') {
|
||||||
|
completedBtn?.classList.add('active');
|
||||||
|
if (completedSection) completedSection.style.display = 'block';
|
||||||
|
|
||||||
|
// 오늘 날짜로 초기화
|
||||||
|
const dateInput = document.getElementById('completedReportDate');
|
||||||
|
if (dateInput) {
|
||||||
|
dateInput.value = this.utils.getKoreaToday();
|
||||||
|
}
|
||||||
|
await this.loadCompletedReports();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TBM 데이터 로드
|
||||||
|
*/
|
||||||
|
async loadTbmData() {
|
||||||
|
try {
|
||||||
|
await this.api.loadIncompleteTbms();
|
||||||
|
await this.api.loadDailyIssuesForTbms();
|
||||||
|
|
||||||
|
// 렌더링은 기존 함수 사용 (점진적 마이그레이션)
|
||||||
|
if (typeof window.renderTbmWorkList === 'function') {
|
||||||
|
window.renderTbmWorkList();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Controller] TBM 데이터 로드 오류:', error);
|
||||||
|
window.showMessage?.('TBM 데이터를 불러오는 중 오류가 발생했습니다.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 완료 보고서 로드
|
||||||
|
*/
|
||||||
|
async loadCompletedReports() {
|
||||||
|
try {
|
||||||
|
const dateInput = document.getElementById('completedReportDate');
|
||||||
|
const date = dateInput?.value || this.utils.getKoreaToday();
|
||||||
|
|
||||||
|
const reports = await this.api.loadCompletedReports(date);
|
||||||
|
|
||||||
|
// 렌더링은 기존 함수 사용
|
||||||
|
if (typeof window.renderCompletedReports === 'function') {
|
||||||
|
window.renderCompletedReports(reports);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Controller] 완료 보고서 로드 오류:', error);
|
||||||
|
window.showMessage?.('완료 보고서를 불러오는 중 오류가 발생했습니다.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TBM 작업보고서 제출
|
||||||
|
*/
|
||||||
|
async submitTbmWorkReport(index) {
|
||||||
|
try {
|
||||||
|
const tbm = this.state.incompleteTbms[index];
|
||||||
|
if (!tbm) {
|
||||||
|
throw new Error('TBM 데이터를 찾을 수 없습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 유효성 검사
|
||||||
|
const totalHoursInput = document.getElementById(`totalHours_${index}`);
|
||||||
|
const totalHours = parseFloat(totalHoursInput?.value);
|
||||||
|
|
||||||
|
if (!totalHours || totalHours <= 0) {
|
||||||
|
window.showMessage?.('작업시간을 입력해주세요.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 부적합 시간 계산
|
||||||
|
const defects = this.state.tempDefects[index] || [];
|
||||||
|
const errorHours = defects.reduce((sum, d) => sum + (parseFloat(d.defect_hours) || 0), 0);
|
||||||
|
const regularHours = totalHours - errorHours;
|
||||||
|
|
||||||
|
if (regularHours < 0) {
|
||||||
|
window.showMessage?.('부적합 시간이 총 작업시간을 초과할 수 없습니다.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 데이터 구성
|
||||||
|
const user = this.state.getCurrentUser();
|
||||||
|
const reportData = {
|
||||||
|
tbm_session_id: tbm.session_id,
|
||||||
|
tbm_assignment_id: tbm.assignment_id,
|
||||||
|
worker_id: tbm.worker_id,
|
||||||
|
project_id: tbm.project_id,
|
||||||
|
work_type_id: tbm.work_type_id,
|
||||||
|
report_date: this.utils.formatDateForApi(tbm.session_date),
|
||||||
|
total_hours: totalHours,
|
||||||
|
regular_hours: regularHours,
|
||||||
|
error_hours: errorHours,
|
||||||
|
work_status_id: errorHours > 0 ? 2 : 1,
|
||||||
|
created_by: user?.user_id || user?.id,
|
||||||
|
defects: defects.map(d => ({
|
||||||
|
category_id: d.category_id,
|
||||||
|
item_id: d.item_id,
|
||||||
|
issue_report_id: d.issue_report_id,
|
||||||
|
defect_hours: d.defect_hours,
|
||||||
|
note: d.note
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await this.api.submitTbmWorkReport(reportData);
|
||||||
|
|
||||||
|
window.showSaveResultModal?.(
|
||||||
|
'success',
|
||||||
|
'제출 완료',
|
||||||
|
`${tbm.worker_name}의 작업보고서가 제출되었습니다.`
|
||||||
|
);
|
||||||
|
|
||||||
|
// 목록 새로고침
|
||||||
|
await this.loadTbmData();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Controller] 제출 오류:', error);
|
||||||
|
window.showSaveResultModal?.(
|
||||||
|
'error',
|
||||||
|
'제출 실패',
|
||||||
|
error.message || '작업보고서 제출 중 오류가 발생했습니다.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세션 일괄 제출
|
||||||
|
*/
|
||||||
|
async batchSubmitSession(sessionKey) {
|
||||||
|
const rows = document.querySelectorAll(`tr[data-session-key="${sessionKey}"][data-type="tbm"]`);
|
||||||
|
const indices = [];
|
||||||
|
|
||||||
|
rows.forEach(row => {
|
||||||
|
const index = parseInt(row.dataset.index);
|
||||||
|
const totalHoursInput = document.getElementById(`totalHours_${index}`);
|
||||||
|
if (totalHoursInput?.value && parseFloat(totalHoursInput.value) > 0) {
|
||||||
|
indices.push(index);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (indices.length === 0) {
|
||||||
|
window.showMessage?.('제출할 항목이 없습니다. 작업시간을 입력해주세요.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmed = confirm(`${indices.length}건의 작업보고서를 일괄 제출하시겠습니까?`);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let failCount = 0;
|
||||||
|
|
||||||
|
for (const index of indices) {
|
||||||
|
try {
|
||||||
|
await this.submitTbmWorkReport(index);
|
||||||
|
successCount++;
|
||||||
|
} catch (error) {
|
||||||
|
failCount++;
|
||||||
|
console.error(`[Controller] 일괄 제출 오류 (index: ${index}):`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failCount === 0) {
|
||||||
|
window.showSaveResultModal?.('success', '일괄 제출 완료', `${successCount}건이 성공적으로 제출되었습니다.`);
|
||||||
|
} else {
|
||||||
|
window.showSaveResultModal?.('warning', '일괄 제출 부분 완료', `성공: ${successCount}건, 실패: ${failCount}건`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상태 디버그
|
||||||
|
*/
|
||||||
|
debug() {
|
||||||
|
console.log('[Controller] 상태 디버그:');
|
||||||
|
this.state.debug();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전역 인스턴스 생성
|
||||||
|
window.DailyWorkReportController = new DailyWorkReportController();
|
||||||
|
|
||||||
|
// 하위 호환성: 기존 전역 함수들
|
||||||
|
window.switchTab = (tab) => window.DailyWorkReportController.switchTab(tab);
|
||||||
|
window.submitTbmWorkReport = (index) => window.DailyWorkReportController.submitTbmWorkReport(index);
|
||||||
|
window.batchSubmitTbmSession = (sessionKey) => window.DailyWorkReportController.batchSubmitSession(sessionKey);
|
||||||
|
|
||||||
|
// 사용자 정보 함수
|
||||||
|
window.getUser = () => window.DailyWorkReportState.getUser();
|
||||||
|
window.getCurrentUser = () => window.DailyWorkReportState.getCurrentUser();
|
||||||
|
|
||||||
|
// 날짜 그룹 토글 (UI 함수)
|
||||||
|
window.toggleDateGroup = function(dateStr) {
|
||||||
|
const group = document.querySelector(`.date-group[data-date="${dateStr}"]`);
|
||||||
|
if (!group) return;
|
||||||
|
|
||||||
|
const isExpanded = group.classList.contains('expanded');
|
||||||
|
const content = group.querySelector('.date-group-content');
|
||||||
|
const icon = group.querySelector('.date-toggle-icon');
|
||||||
|
|
||||||
|
if (isExpanded) {
|
||||||
|
group.classList.remove('expanded');
|
||||||
|
group.classList.add('collapsed');
|
||||||
|
if (content) content.style.display = 'none';
|
||||||
|
if (icon) icon.textContent = '▶';
|
||||||
|
} else {
|
||||||
|
group.classList.remove('collapsed');
|
||||||
|
group.classList.add('expanded');
|
||||||
|
if (content) content.style.display = 'block';
|
||||||
|
if (icon) icon.textContent = '▼';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// DOMContentLoaded 이벤트에서 초기화
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// 약간의 지연 후 초기화 (다른 스크립트 로드 대기)
|
||||||
|
setTimeout(() => {
|
||||||
|
window.DailyWorkReportController.init();
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[Module] daily-work-report/index.js 로드 완료');
|
||||||
342
web-ui/js/daily-work-report/state.js
Normal file
342
web-ui/js/daily-work-report/state.js
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
/**
|
||||||
|
* Daily Work Report - State Manager
|
||||||
|
* 작업보고서 페이지의 전역 상태 관리
|
||||||
|
*/
|
||||||
|
|
||||||
|
class DailyWorkReportState {
|
||||||
|
constructor() {
|
||||||
|
// 마스터 데이터
|
||||||
|
this.workTypes = [];
|
||||||
|
this.workStatusTypes = [];
|
||||||
|
this.errorTypes = []; // 레거시 호환용
|
||||||
|
this.issueCategories = []; // 신고 카테고리 (nonconformity)
|
||||||
|
this.issueItems = []; // 신고 아이템
|
||||||
|
this.workers = [];
|
||||||
|
this.projects = [];
|
||||||
|
|
||||||
|
// UI 상태
|
||||||
|
this.selectedWorkers = new Set();
|
||||||
|
this.workEntryCounter = 0;
|
||||||
|
this.currentStep = 1;
|
||||||
|
this.editingWorkId = null;
|
||||||
|
this.currentTab = 'tbm';
|
||||||
|
|
||||||
|
// TBM 관련
|
||||||
|
this.incompleteTbms = [];
|
||||||
|
|
||||||
|
// 부적합 원인 관리
|
||||||
|
this.currentDefectIndex = null;
|
||||||
|
this.tempDefects = {}; // { index: [{ error_type_id, defect_hours, note }] }
|
||||||
|
|
||||||
|
// 작업장소 지도 관련
|
||||||
|
this.mapCanvas = null;
|
||||||
|
this.mapCtx = null;
|
||||||
|
this.mapImage = null;
|
||||||
|
this.mapRegions = [];
|
||||||
|
this.selectedWorkplace = null;
|
||||||
|
this.selectedWorkplaceName = null;
|
||||||
|
this.selectedWorkplaceCategory = null;
|
||||||
|
this.selectedWorkplaceCategoryName = null;
|
||||||
|
|
||||||
|
// 시간 선택 관련
|
||||||
|
this.currentEditingField = null; // { index, type: 'total' | 'error' }
|
||||||
|
this.currentTimeValue = 0;
|
||||||
|
|
||||||
|
// 캐시
|
||||||
|
this.dailyIssuesCache = {}; // { 'YYYY-MM-DD': [issues] }
|
||||||
|
|
||||||
|
// 리스너
|
||||||
|
this.listeners = new Map();
|
||||||
|
|
||||||
|
console.log('[State] DailyWorkReportState 초기화 완료');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상태 업데이트
|
||||||
|
*/
|
||||||
|
update(key, value) {
|
||||||
|
const prevValue = this[key];
|
||||||
|
this[key] = value;
|
||||||
|
this.notifyListeners(key, value, prevValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 리스너 등록
|
||||||
|
*/
|
||||||
|
subscribe(key, callback) {
|
||||||
|
if (!this.listeners.has(key)) {
|
||||||
|
this.listeners.set(key, []);
|
||||||
|
}
|
||||||
|
this.listeners.get(key).push(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 리스너에게 알림
|
||||||
|
*/
|
||||||
|
notifyListeners(key, newValue, prevValue) {
|
||||||
|
const keyListeners = this.listeners.get(key) || [];
|
||||||
|
keyListeners.forEach(callback => {
|
||||||
|
try {
|
||||||
|
callback(newValue, prevValue);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[State] 리스너 오류 (${key}):`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 사용자 정보 가져오기
|
||||||
|
*/
|
||||||
|
getUser() {
|
||||||
|
const user = localStorage.getItem('user');
|
||||||
|
return user ? JSON.parse(user) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 토큰에서 사용자 정보 추출
|
||||||
|
*/
|
||||||
|
getCurrentUser() {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) return null;
|
||||||
|
|
||||||
|
const payloadBase64 = token.split('.')[1];
|
||||||
|
if (payloadBase64) {
|
||||||
|
const payload = JSON.parse(atob(payloadBase64));
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('[State] 토큰에서 사용자 정보 추출 실패:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userInfo = localStorage.getItem('user') || localStorage.getItem('userInfo') || localStorage.getItem('currentUser');
|
||||||
|
if (userInfo) {
|
||||||
|
return JSON.parse(userInfo);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('[State] localStorage에서 사용자 정보 가져오기 실패:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 선택된 작업자 토글
|
||||||
|
*/
|
||||||
|
toggleWorkerSelection(workerId) {
|
||||||
|
if (this.selectedWorkers.has(workerId)) {
|
||||||
|
this.selectedWorkers.delete(workerId);
|
||||||
|
} else {
|
||||||
|
this.selectedWorkers.add(workerId);
|
||||||
|
}
|
||||||
|
this.notifyListeners('selectedWorkers', this.selectedWorkers, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업자 전체 선택/해제
|
||||||
|
*/
|
||||||
|
selectAllWorkers(select = true) {
|
||||||
|
if (select) {
|
||||||
|
this.workers.forEach(w => this.selectedWorkers.add(w.worker_id));
|
||||||
|
} else {
|
||||||
|
this.selectedWorkers.clear();
|
||||||
|
}
|
||||||
|
this.notifyListeners('selectedWorkers', this.selectedWorkers, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업 항목 카운터 증가
|
||||||
|
*/
|
||||||
|
incrementWorkEntryCounter() {
|
||||||
|
this.workEntryCounter++;
|
||||||
|
return this.workEntryCounter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 탭 변경
|
||||||
|
*/
|
||||||
|
setCurrentTab(tab) {
|
||||||
|
const prevTab = this.currentTab;
|
||||||
|
this.currentTab = tab;
|
||||||
|
this.notifyListeners('currentTab', tab, prevTab);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 부적합 임시 저장소 초기화
|
||||||
|
*/
|
||||||
|
initTempDefects(index) {
|
||||||
|
if (!this.tempDefects[index]) {
|
||||||
|
this.tempDefects[index] = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 부적합 추가
|
||||||
|
*/
|
||||||
|
addTempDefect(index, defect) {
|
||||||
|
this.initTempDefects(index);
|
||||||
|
this.tempDefects[index].push(defect);
|
||||||
|
this.notifyListeners('tempDefects', this.tempDefects, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 부적합 업데이트
|
||||||
|
*/
|
||||||
|
updateTempDefect(index, defectIndex, field, value) {
|
||||||
|
if (this.tempDefects[index] && this.tempDefects[index][defectIndex]) {
|
||||||
|
this.tempDefects[index][defectIndex][field] = value;
|
||||||
|
this.notifyListeners('tempDefects', this.tempDefects, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 부적합 삭제
|
||||||
|
*/
|
||||||
|
removeTempDefect(index, defectIndex) {
|
||||||
|
if (this.tempDefects[index]) {
|
||||||
|
this.tempDefects[index].splice(defectIndex, 1);
|
||||||
|
this.notifyListeners('tempDefects', this.tempDefects, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일일 이슈 캐시 설정
|
||||||
|
*/
|
||||||
|
setDailyIssuesCache(dateStr, issues) {
|
||||||
|
this.dailyIssuesCache[dateStr] = issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일일 이슈 캐시 조회
|
||||||
|
*/
|
||||||
|
getDailyIssuesCache(dateStr) {
|
||||||
|
return this.dailyIssuesCache[dateStr] || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상태 초기화
|
||||||
|
*/
|
||||||
|
reset() {
|
||||||
|
this.selectedWorkers.clear();
|
||||||
|
this.workEntryCounter = 0;
|
||||||
|
this.currentStep = 1;
|
||||||
|
this.editingWorkId = null;
|
||||||
|
this.tempDefects = {};
|
||||||
|
this.currentDefectIndex = null;
|
||||||
|
this.dailyIssuesCache = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 디버그 출력
|
||||||
|
*/
|
||||||
|
debug() {
|
||||||
|
console.log('[State] 현재 상태:', {
|
||||||
|
workTypes: this.workTypes.length,
|
||||||
|
workers: this.workers.length,
|
||||||
|
projects: this.projects.length,
|
||||||
|
selectedWorkers: this.selectedWorkers.size,
|
||||||
|
currentTab: this.currentTab,
|
||||||
|
incompleteTbms: this.incompleteTbms.length,
|
||||||
|
tempDefects: Object.keys(this.tempDefects).length
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전역 인스턴스 생성
|
||||||
|
window.DailyWorkReportState = new DailyWorkReportState();
|
||||||
|
|
||||||
|
// 하위 호환성을 위한 전역 변수 프록시
|
||||||
|
const stateProxy = window.DailyWorkReportState;
|
||||||
|
|
||||||
|
// 기존 전역 변수들과 호환
|
||||||
|
Object.defineProperties(window, {
|
||||||
|
workTypes: {
|
||||||
|
get: () => stateProxy.workTypes,
|
||||||
|
set: (v) => { stateProxy.workTypes = v; }
|
||||||
|
},
|
||||||
|
workStatusTypes: {
|
||||||
|
get: () => stateProxy.workStatusTypes,
|
||||||
|
set: (v) => { stateProxy.workStatusTypes = v; }
|
||||||
|
},
|
||||||
|
errorTypes: {
|
||||||
|
get: () => stateProxy.errorTypes,
|
||||||
|
set: (v) => { stateProxy.errorTypes = v; }
|
||||||
|
},
|
||||||
|
issueCategories: {
|
||||||
|
get: () => stateProxy.issueCategories,
|
||||||
|
set: (v) => { stateProxy.issueCategories = v; }
|
||||||
|
},
|
||||||
|
issueItems: {
|
||||||
|
get: () => stateProxy.issueItems,
|
||||||
|
set: (v) => { stateProxy.issueItems = v; }
|
||||||
|
},
|
||||||
|
workers: {
|
||||||
|
get: () => stateProxy.workers,
|
||||||
|
set: (v) => { stateProxy.workers = v; }
|
||||||
|
},
|
||||||
|
projects: {
|
||||||
|
get: () => stateProxy.projects,
|
||||||
|
set: (v) => { stateProxy.projects = v; }
|
||||||
|
},
|
||||||
|
selectedWorkers: {
|
||||||
|
get: () => stateProxy.selectedWorkers,
|
||||||
|
set: (v) => { stateProxy.selectedWorkers = v; }
|
||||||
|
},
|
||||||
|
incompleteTbms: {
|
||||||
|
get: () => stateProxy.incompleteTbms,
|
||||||
|
set: (v) => { stateProxy.incompleteTbms = v; }
|
||||||
|
},
|
||||||
|
tempDefects: {
|
||||||
|
get: () => stateProxy.tempDefects,
|
||||||
|
set: (v) => { stateProxy.tempDefects = v; }
|
||||||
|
},
|
||||||
|
dailyIssuesCache: {
|
||||||
|
get: () => stateProxy.dailyIssuesCache,
|
||||||
|
set: (v) => { stateProxy.dailyIssuesCache = v; }
|
||||||
|
},
|
||||||
|
currentTab: {
|
||||||
|
get: () => stateProxy.currentTab,
|
||||||
|
set: (v) => { stateProxy.currentTab = v; }
|
||||||
|
},
|
||||||
|
currentStep: {
|
||||||
|
get: () => stateProxy.currentStep,
|
||||||
|
set: (v) => { stateProxy.currentStep = v; }
|
||||||
|
},
|
||||||
|
editingWorkId: {
|
||||||
|
get: () => stateProxy.editingWorkId,
|
||||||
|
set: (v) => { stateProxy.editingWorkId = v; }
|
||||||
|
},
|
||||||
|
workEntryCounter: {
|
||||||
|
get: () => stateProxy.workEntryCounter,
|
||||||
|
set: (v) => { stateProxy.workEntryCounter = v; }
|
||||||
|
},
|
||||||
|
currentDefectIndex: {
|
||||||
|
get: () => stateProxy.currentDefectIndex,
|
||||||
|
set: (v) => { stateProxy.currentDefectIndex = v; }
|
||||||
|
},
|
||||||
|
currentEditingField: {
|
||||||
|
get: () => stateProxy.currentEditingField,
|
||||||
|
set: (v) => { stateProxy.currentEditingField = v; }
|
||||||
|
},
|
||||||
|
currentTimeValue: {
|
||||||
|
get: () => stateProxy.currentTimeValue,
|
||||||
|
set: (v) => { stateProxy.currentTimeValue = v; }
|
||||||
|
},
|
||||||
|
selectedWorkplace: {
|
||||||
|
get: () => stateProxy.selectedWorkplace,
|
||||||
|
set: (v) => { stateProxy.selectedWorkplace = v; }
|
||||||
|
},
|
||||||
|
selectedWorkplaceName: {
|
||||||
|
get: () => stateProxy.selectedWorkplaceName,
|
||||||
|
set: (v) => { stateProxy.selectedWorkplaceName = v; }
|
||||||
|
},
|
||||||
|
selectedWorkplaceCategory: {
|
||||||
|
get: () => stateProxy.selectedWorkplaceCategory,
|
||||||
|
set: (v) => { stateProxy.selectedWorkplaceCategory = v; }
|
||||||
|
},
|
||||||
|
selectedWorkplaceCategoryName: {
|
||||||
|
get: () => stateProxy.selectedWorkplaceCategoryName,
|
||||||
|
set: (v) => { stateProxy.selectedWorkplaceCategoryName = v; }
|
||||||
|
}
|
||||||
|
});
|
||||||
470
web-ui/js/daily-work-report/utils.js
Normal file
470
web-ui/js/daily-work-report/utils.js
Normal file
@@ -0,0 +1,470 @@
|
|||||||
|
/**
|
||||||
|
* Daily Work Report - Utilities
|
||||||
|
* 작업보고서 관련 유틸리티 함수들
|
||||||
|
*/
|
||||||
|
|
||||||
|
class DailyWorkReportUtils {
|
||||||
|
constructor() {
|
||||||
|
console.log('[Utils] DailyWorkReportUtils 초기화');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 한국 시간 기준 오늘 날짜 (YYYY-MM-DD)
|
||||||
|
*/
|
||||||
|
getKoreaToday() {
|
||||||
|
const today = new Date();
|
||||||
|
const year = today.getFullYear();
|
||||||
|
const month = String(today.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(today.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날짜를 API 형식(YYYY-MM-DD)으로 변환
|
||||||
|
*/
|
||||||
|
formatDateForApi(date) {
|
||||||
|
if (!date) return null;
|
||||||
|
|
||||||
|
let dateObj;
|
||||||
|
if (date instanceof Date) {
|
||||||
|
dateObj = date;
|
||||||
|
} else if (typeof date === 'string') {
|
||||||
|
dateObj = new Date(date);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = dateObj.getFullYear();
|
||||||
|
const month = String(dateObj.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(dateObj.getDate()).padStart(2, '0');
|
||||||
|
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날짜 포맷팅 (표시용)
|
||||||
|
*/
|
||||||
|
formatDate(date) {
|
||||||
|
if (!date) return '-';
|
||||||
|
|
||||||
|
let dateObj;
|
||||||
|
if (date instanceof Date) {
|
||||||
|
dateObj = date;
|
||||||
|
} else if (typeof date === 'string') {
|
||||||
|
dateObj = new Date(date);
|
||||||
|
} else {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = dateObj.getFullYear();
|
||||||
|
const month = String(dateObj.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(dateObj.getDate()).padStart(2, '0');
|
||||||
|
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 시간 포맷팅 (HH:mm)
|
||||||
|
*/
|
||||||
|
formatTime(time) {
|
||||||
|
if (!time) return '-';
|
||||||
|
if (typeof time === 'string' && time.includes(':')) {
|
||||||
|
return time.substring(0, 5);
|
||||||
|
}
|
||||||
|
return time;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상태 라벨 반환
|
||||||
|
*/
|
||||||
|
getStatusLabel(status) {
|
||||||
|
const labels = {
|
||||||
|
'pending': '접수',
|
||||||
|
'in_progress': '처리중',
|
||||||
|
'resolved': '해결',
|
||||||
|
'completed': '완료',
|
||||||
|
'closed': '종료'
|
||||||
|
};
|
||||||
|
return labels[status] || status || '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 숫자 포맷팅 (천 단위 콤마)
|
||||||
|
*/
|
||||||
|
formatNumber(num) {
|
||||||
|
if (num === null || num === undefined) return '0';
|
||||||
|
return num.toLocaleString('ko-KR');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 소수점 자리수 포맷팅
|
||||||
|
*/
|
||||||
|
formatDecimal(num, decimals = 1) {
|
||||||
|
if (num === null || num === undefined) return '0';
|
||||||
|
return Number(num).toFixed(decimals);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 요일 반환
|
||||||
|
*/
|
||||||
|
getDayOfWeek(date) {
|
||||||
|
const days = ['일', '월', '화', '수', '목', '금', '토'];
|
||||||
|
const dateObj = date instanceof Date ? date : new Date(date);
|
||||||
|
return days[dateObj.getDay()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 오늘인지 확인
|
||||||
|
*/
|
||||||
|
isToday(date) {
|
||||||
|
const today = this.getKoreaToday();
|
||||||
|
const targetDate = this.formatDateForApi(date);
|
||||||
|
return today === targetDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 두 날짜 사이 일수 계산
|
||||||
|
*/
|
||||||
|
daysBetween(date1, date2) {
|
||||||
|
const d1 = new Date(date1);
|
||||||
|
const d2 = new Date(date2);
|
||||||
|
const diffTime = Math.abs(d2 - d1);
|
||||||
|
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 디바운스 함수
|
||||||
|
*/
|
||||||
|
debounce(func, wait) {
|
||||||
|
let timeout;
|
||||||
|
return function executedFunction(...args) {
|
||||||
|
const later = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
func(...args);
|
||||||
|
};
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(later, wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 쓰로틀 함수
|
||||||
|
*/
|
||||||
|
throttle(func, limit) {
|
||||||
|
let inThrottle;
|
||||||
|
return function(...args) {
|
||||||
|
if (!inThrottle) {
|
||||||
|
func.apply(this, args);
|
||||||
|
inThrottle = true;
|
||||||
|
setTimeout(() => inThrottle = false, limit);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTML 이스케이프
|
||||||
|
*/
|
||||||
|
escapeHtml(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 객체 깊은 복사
|
||||||
|
*/
|
||||||
|
deepClone(obj) {
|
||||||
|
return JSON.parse(JSON.stringify(obj));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 빈 값 확인
|
||||||
|
*/
|
||||||
|
isEmpty(value) {
|
||||||
|
if (value === null || value === undefined) return true;
|
||||||
|
if (typeof value === 'string') return value.trim() === '';
|
||||||
|
if (Array.isArray(value)) return value.length === 0;
|
||||||
|
if (typeof value === 'object') return Object.keys(value).length === 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 숫자 유효성 검사
|
||||||
|
*/
|
||||||
|
isValidNumber(value) {
|
||||||
|
return !isNaN(value) && isFinite(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 시간 유효성 검사 (0-24)
|
||||||
|
*/
|
||||||
|
isValidHours(hours) {
|
||||||
|
const num = parseFloat(hours);
|
||||||
|
return this.isValidNumber(num) && num >= 0 && num <= 24;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 쿼리 스트링 파싱
|
||||||
|
*/
|
||||||
|
parseQueryString(queryString) {
|
||||||
|
const params = new URLSearchParams(queryString);
|
||||||
|
const result = {};
|
||||||
|
for (const [key, value] of params) {
|
||||||
|
result[key] = value;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 쿼리 스트링 생성
|
||||||
|
*/
|
||||||
|
buildQueryString(params) {
|
||||||
|
return new URLSearchParams(params).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로컬 스토리지 안전하게 가져오기
|
||||||
|
*/
|
||||||
|
getLocalStorage(key, defaultValue = null) {
|
||||||
|
try {
|
||||||
|
const item = localStorage.getItem(key);
|
||||||
|
return item ? JSON.parse(item) : defaultValue;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Utils] localStorage 읽기 오류:', error);
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로컬 스토리지 안전하게 저장하기
|
||||||
|
*/
|
||||||
|
setLocalStorage(key, value) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, JSON.stringify(value));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Utils] localStorage 저장 오류:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배열 그룹화
|
||||||
|
*/
|
||||||
|
groupBy(array, key) {
|
||||||
|
return array.reduce((result, item) => {
|
||||||
|
const groupKey = typeof key === 'function' ? key(item) : item[key];
|
||||||
|
if (!result[groupKey]) {
|
||||||
|
result[groupKey] = [];
|
||||||
|
}
|
||||||
|
result[groupKey].push(item);
|
||||||
|
return result;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배열 정렬 (다중 키)
|
||||||
|
*/
|
||||||
|
sortBy(array, ...keys) {
|
||||||
|
return [...array].sort((a, b) => {
|
||||||
|
for (const key of keys) {
|
||||||
|
const direction = key.startsWith('-') ? -1 : 1;
|
||||||
|
const actualKey = key.replace(/^-/, '');
|
||||||
|
const aVal = a[actualKey];
|
||||||
|
const bVal = b[actualKey];
|
||||||
|
|
||||||
|
if (aVal < bVal) return -1 * direction;
|
||||||
|
if (aVal > bVal) return 1 * direction;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UUID 생성
|
||||||
|
*/
|
||||||
|
generateUUID() {
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||||
|
const r = Math.random() * 16 | 0;
|
||||||
|
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||||
|
return v.toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전역 인스턴스 생성
|
||||||
|
window.DailyWorkReportUtils = new DailyWorkReportUtils();
|
||||||
|
|
||||||
|
// 하위 호환성: 기존 함수들
|
||||||
|
window.getKoreaToday = () => window.DailyWorkReportUtils.getKoreaToday();
|
||||||
|
window.formatDateForApi = (date) => window.DailyWorkReportUtils.formatDateForApi(date);
|
||||||
|
window.formatDate = (date) => window.DailyWorkReportUtils.formatDate(date);
|
||||||
|
window.getStatusLabel = (status) => window.DailyWorkReportUtils.getStatusLabel(status);
|
||||||
|
|
||||||
|
// 메시지 표시 함수들
|
||||||
|
window.showMessage = function(message, type = 'info') {
|
||||||
|
const container = document.getElementById('message-container');
|
||||||
|
if (!container) {
|
||||||
|
console.log(`[Message] ${type}: ${message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = `<div class="message ${type}">${message}</div>`;
|
||||||
|
|
||||||
|
if (type === 'success') {
|
||||||
|
setTimeout(() => window.hideMessage(), 5000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.hideMessage = function() {
|
||||||
|
const container = document.getElementById('message-container');
|
||||||
|
if (container) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 저장 결과 모달
|
||||||
|
window.showSaveResultModal = function(type, title, message, details = null) {
|
||||||
|
const modal = document.getElementById('saveResultModal');
|
||||||
|
const titleElement = document.getElementById('resultModalTitle');
|
||||||
|
const contentElement = document.getElementById('resultModalContent');
|
||||||
|
|
||||||
|
if (!modal || !contentElement) {
|
||||||
|
alert(`${title}\n\n${message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
success: '✅',
|
||||||
|
error: '❌',
|
||||||
|
warning: '⚠️',
|
||||||
|
info: 'ℹ️'
|
||||||
|
};
|
||||||
|
|
||||||
|
let content = `
|
||||||
|
<div class="result-icon ${type}">${icons[type] || icons.info}</div>
|
||||||
|
<h3 class="result-title ${type}">${title}</h3>
|
||||||
|
<p class="result-message">${message}</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (details) {
|
||||||
|
if (Array.isArray(details) && details.length > 0) {
|
||||||
|
content += `
|
||||||
|
<div class="result-details">
|
||||||
|
<h4>상세 정보:</h4>
|
||||||
|
<ul>${details.map(d => `<li>${d}</li>`).join('')}</ul>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else if (typeof details === 'string') {
|
||||||
|
content += `<div class="result-details"><p>${details}</p></div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (titleElement) titleElement.textContent = '저장 결과';
|
||||||
|
contentElement.innerHTML = content;
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
|
||||||
|
// ESC 키로 닫기
|
||||||
|
const escHandler = (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
window.closeSaveResultModal();
|
||||||
|
document.removeEventListener('keydown', escHandler);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', escHandler);
|
||||||
|
|
||||||
|
// 배경 클릭으로 닫기
|
||||||
|
modal.onclick = (e) => {
|
||||||
|
if (e.target === modal) {
|
||||||
|
window.closeSaveResultModal();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
window.closeSaveResultModal = function() {
|
||||||
|
const modal = document.getElementById('saveResultModal');
|
||||||
|
if (modal) {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 단계 이동 함수
|
||||||
|
window.goToStep = function(stepNumber) {
|
||||||
|
const state = window.DailyWorkReportState;
|
||||||
|
|
||||||
|
for (let i = 1; i <= 3; i++) {
|
||||||
|
const step = document.getElementById(`step${i}`);
|
||||||
|
if (step) {
|
||||||
|
step.classList.remove('active', 'completed');
|
||||||
|
if (i < stepNumber) {
|
||||||
|
step.classList.add('completed');
|
||||||
|
const stepNum = step.querySelector('.step-number');
|
||||||
|
if (stepNum) stepNum.classList.add('completed');
|
||||||
|
} else if (i === stepNumber) {
|
||||||
|
step.classList.add('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.updateProgressSteps(stepNumber);
|
||||||
|
state.currentStep = stepNumber;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.updateProgressSteps = function(currentStepNumber) {
|
||||||
|
for (let i = 1; i <= 3; i++) {
|
||||||
|
const progressStep = document.getElementById(`progressStep${i}`);
|
||||||
|
if (progressStep) {
|
||||||
|
progressStep.classList.remove('active', 'completed');
|
||||||
|
if (i < currentStepNumber) {
|
||||||
|
progressStep.classList.add('completed');
|
||||||
|
} else if (i === currentStepNumber) {
|
||||||
|
progressStep.classList.add('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 토스트 메시지 (간단 버전)
|
||||||
|
window.showToast = function(message, type = 'info', duration = 3000) {
|
||||||
|
console.log(`[Toast] ${type}: ${message}`);
|
||||||
|
|
||||||
|
// 기존 토스트 제거
|
||||||
|
const existingToast = document.querySelector('.toast-message');
|
||||||
|
if (existingToast) {
|
||||||
|
existingToast.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 새 토스트 생성
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = `toast-message toast-${type}`;
|
||||||
|
toast.textContent = message;
|
||||||
|
toast.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: white;
|
||||||
|
font-size: 14px;
|
||||||
|
z-index: 10000;
|
||||||
|
animation: slideIn 0.3s ease;
|
||||||
|
background-color: ${type === 'success' ? '#10b981' : type === 'error' ? '#ef4444' : type === 'warning' ? '#f59e0b' : '#3b82f6'};
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.style.animation = 'slideOut 0.3s ease';
|
||||||
|
setTimeout(() => toast.remove(), 300);
|
||||||
|
}, duration);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 확인 다이얼로그
|
||||||
|
window.showConfirmDialog = function(message, onConfirm, onCancel) {
|
||||||
|
if (confirm(message)) {
|
||||||
|
onConfirm?.();
|
||||||
|
} else {
|
||||||
|
onCancel?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
489
web-ui/js/tbm/api.js
Normal file
489
web-ui/js/tbm/api.js
Normal file
@@ -0,0 +1,489 @@
|
|||||||
|
/**
|
||||||
|
* TBM - API Client
|
||||||
|
* TBM 관련 모든 API 호출을 관리
|
||||||
|
*/
|
||||||
|
|
||||||
|
class TbmAPI {
|
||||||
|
constructor() {
|
||||||
|
this.state = window.TbmState;
|
||||||
|
this.utils = window.TbmUtils;
|
||||||
|
console.log('[TbmAPI] 초기화 완료');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 초기 데이터 로드 (작업자, 프로젝트, 안전 체크리스트, 공정, 작업, 작업장)
|
||||||
|
*/
|
||||||
|
async loadInitialData() {
|
||||||
|
try {
|
||||||
|
// 현재 로그인한 사용자 정보 가져오기
|
||||||
|
const userInfo = JSON.parse(localStorage.getItem('user') || '{}');
|
||||||
|
this.state.currentUser = userInfo;
|
||||||
|
console.log('👤 로그인 사용자:', this.state.currentUser, 'worker_id:', this.state.currentUser?.worker_id);
|
||||||
|
|
||||||
|
// 병렬로 데이터 로드
|
||||||
|
await Promise.all([
|
||||||
|
this.loadWorkers(),
|
||||||
|
this.loadProjects(),
|
||||||
|
this.loadSafetyChecks(),
|
||||||
|
this.loadWorkTypes(),
|
||||||
|
this.loadTasks(),
|
||||||
|
this.loadWorkplaces(),
|
||||||
|
this.loadWorkplaceCategories()
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log('✅ 초기 데이터 로드 완료');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 초기 데이터 로드 오류:', error);
|
||||||
|
window.showToast?.('데이터를 불러오는 중 오류가 발생했습니다.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업자 목록 로드 (생산팀 소속만)
|
||||||
|
*/
|
||||||
|
async loadWorkers() {
|
||||||
|
try {
|
||||||
|
const response = await window.apiCall('/workers?limit=1000&department_id=1');
|
||||||
|
if (response) {
|
||||||
|
let workers = Array.isArray(response) ? response : (response.data || []);
|
||||||
|
// 활성 상태인 작업자만 필터링
|
||||||
|
workers = workers.filter(w => w.status === 'active' && w.employment_status === 'employed');
|
||||||
|
this.state.allWorkers = workers;
|
||||||
|
console.log('✅ 작업자 목록 로드:', workers.length + '명');
|
||||||
|
return workers;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 작업자 로딩 오류:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로젝트 목록 로드 (활성 프로젝트만)
|
||||||
|
*/
|
||||||
|
async loadProjects() {
|
||||||
|
try {
|
||||||
|
const response = await window.apiCall('/projects?is_active=1');
|
||||||
|
if (response) {
|
||||||
|
const projects = Array.isArray(response) ? response : (response.data || []);
|
||||||
|
this.state.allProjects = projects.filter(p =>
|
||||||
|
p.is_active === 1 || p.is_active === true || p.is_active === '1'
|
||||||
|
);
|
||||||
|
console.log('✅ 프로젝트 목록 로드:', this.state.allProjects.length + '개 (활성)');
|
||||||
|
return this.state.allProjects;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 프로젝트 로딩 오류:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 안전 체크리스트 로드
|
||||||
|
*/
|
||||||
|
async loadSafetyChecks() {
|
||||||
|
try {
|
||||||
|
const response = await window.apiCall('/tbm/safety-checks');
|
||||||
|
if (response && response.success) {
|
||||||
|
this.state.allSafetyChecks = response.data;
|
||||||
|
console.log('✅ 안전 체크리스트 로드:', this.state.allSafetyChecks.length + '개');
|
||||||
|
return this.state.allSafetyChecks;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 안전 체크리스트 로딩 오류:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공정(Work Types) 목록 로드
|
||||||
|
*/
|
||||||
|
async loadWorkTypes() {
|
||||||
|
try {
|
||||||
|
const response = await window.apiCall('/daily-work-reports/work-types');
|
||||||
|
if (response && response.success) {
|
||||||
|
this.state.allWorkTypes = response.data || [];
|
||||||
|
console.log('✅ 공정 목록 로드:', this.state.allWorkTypes.length + '개');
|
||||||
|
return this.state.allWorkTypes;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 공정 로딩 오류:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업(Tasks) 목록 로드
|
||||||
|
*/
|
||||||
|
async loadTasks() {
|
||||||
|
try {
|
||||||
|
const response = await window.apiCall('/tasks/active/list');
|
||||||
|
if (response && response.success) {
|
||||||
|
this.state.allTasks = response.data || [];
|
||||||
|
console.log('✅ 작업 목록 로드:', this.state.allTasks.length + '개');
|
||||||
|
return this.state.allTasks;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 작업 로딩 오류:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업장 목록 로드
|
||||||
|
*/
|
||||||
|
async loadWorkplaces() {
|
||||||
|
try {
|
||||||
|
const response = await window.apiCall('/workplaces?is_active=true');
|
||||||
|
if (response && response.success) {
|
||||||
|
this.state.allWorkplaces = response.data || [];
|
||||||
|
console.log('✅ 작업장 목록 로드:', this.state.allWorkplaces.length + '개');
|
||||||
|
return this.state.allWorkplaces;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 작업장 로딩 오류:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업장 카테고리 로드
|
||||||
|
*/
|
||||||
|
async loadWorkplaceCategories() {
|
||||||
|
try {
|
||||||
|
const response = await window.apiCall('/workplaces/categories/active/list');
|
||||||
|
if (response && response.success) {
|
||||||
|
this.state.allWorkplaceCategories = response.data || [];
|
||||||
|
console.log('✅ 작업장 카테고리 로드:', this.state.allWorkplaceCategories.length + '개');
|
||||||
|
return this.state.allWorkplaceCategories;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 작업장 카테고리 로딩 오류:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 오늘의 TBM만 로드 (TBM 입력 탭용)
|
||||||
|
*/
|
||||||
|
async loadTodayOnlyTbm() {
|
||||||
|
const today = this.utils.getTodayKST();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await window.apiCall(`/tbm/sessions/date/${today}`);
|
||||||
|
|
||||||
|
if (response && response.success) {
|
||||||
|
this.state.todaySessions = response.data || [];
|
||||||
|
} else {
|
||||||
|
this.state.todaySessions = [];
|
||||||
|
}
|
||||||
|
console.log('✅ 오늘 TBM 로드:', this.state.todaySessions.length + '건');
|
||||||
|
return this.state.todaySessions;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 오늘 TBM 조회 오류:', error);
|
||||||
|
window.showToast?.('오늘 TBM을 불러오는 중 오류가 발생했습니다.', 'error');
|
||||||
|
this.state.todaySessions = [];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최근 TBM을 날짜별로 그룹화하여 로드
|
||||||
|
*/
|
||||||
|
async loadRecentTbmGroupedByDate() {
|
||||||
|
try {
|
||||||
|
const today = new Date();
|
||||||
|
const dates = [];
|
||||||
|
|
||||||
|
// 최근 N일의 날짜 생성
|
||||||
|
for (let i = 0; i < this.state.loadedDaysCount; i++) {
|
||||||
|
const date = new Date(today);
|
||||||
|
date.setDate(date.getDate() - i);
|
||||||
|
const dateStr = date.toISOString().split('T')[0];
|
||||||
|
dates.push(dateStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 각 날짜의 TBM 로드
|
||||||
|
this.state.dateGroupedSessions = {};
|
||||||
|
this.state.allLoadedSessions = [];
|
||||||
|
|
||||||
|
const promises = dates.map(date => window.apiCall(`/tbm/sessions/date/${date}`));
|
||||||
|
const results = await Promise.all(promises);
|
||||||
|
|
||||||
|
results.forEach((response, index) => {
|
||||||
|
const date = dates[index];
|
||||||
|
if (response && response.success && response.data && response.data.length > 0) {
|
||||||
|
let sessions = response.data;
|
||||||
|
|
||||||
|
// admin이 아니면 본인이 작성한 TBM만 필터링
|
||||||
|
if (!this.state.isAdminUser()) {
|
||||||
|
const userId = this.state.currentUser?.user_id;
|
||||||
|
const workerId = this.state.currentUser?.worker_id;
|
||||||
|
sessions = sessions.filter(s => {
|
||||||
|
return s.created_by === userId ||
|
||||||
|
s.leader_id === workerId ||
|
||||||
|
s.created_by_name === this.state.currentUser?.name;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessions.length > 0) {
|
||||||
|
this.state.dateGroupedSessions[date] = sessions;
|
||||||
|
this.state.allLoadedSessions = this.state.allLoadedSessions.concat(sessions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ 날짜별 TBM 로드 완료:', this.state.allLoadedSessions.length + '건');
|
||||||
|
return this.state.dateGroupedSessions;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ TBM 날짜별 로드 오류:', error);
|
||||||
|
window.showToast?.('TBM을 불러오는 중 오류가 발생했습니다.', 'error');
|
||||||
|
this.state.dateGroupedSessions = {};
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 날짜의 TBM 세션 목록 로드
|
||||||
|
*/
|
||||||
|
async loadTbmSessionsByDate(date) {
|
||||||
|
try {
|
||||||
|
const response = await window.apiCall(`/tbm/sessions/date/${date}`);
|
||||||
|
|
||||||
|
if (response && response.success) {
|
||||||
|
this.state.allSessions = response.data || [];
|
||||||
|
} else {
|
||||||
|
this.state.allSessions = [];
|
||||||
|
}
|
||||||
|
return this.state.allSessions;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ TBM 세션 조회 오류:', error);
|
||||||
|
window.showToast?.('TBM 세션을 불러오는 중 오류가 발생했습니다.', 'error');
|
||||||
|
this.state.allSessions = [];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TBM 세션 생성
|
||||||
|
*/
|
||||||
|
async createTbmSession(sessionData) {
|
||||||
|
try {
|
||||||
|
const response = await window.apiCall('/tbm/sessions', 'POST', sessionData);
|
||||||
|
if (!response || !response.success) {
|
||||||
|
throw new Error(response?.message || '세션 생성 실패');
|
||||||
|
}
|
||||||
|
console.log('✅ TBM 세션 생성 완료:', response.data?.session_id);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ TBM 세션 생성 오류:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TBM 세션 정보 조회
|
||||||
|
*/
|
||||||
|
async getSession(sessionId) {
|
||||||
|
try {
|
||||||
|
const response = await window.apiCall(`/tbm/sessions/${sessionId}`);
|
||||||
|
if (!response || !response.success) {
|
||||||
|
throw new Error(response?.message || '세션 조회 실패');
|
||||||
|
}
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ TBM 세션 조회 오류:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TBM 팀원 조회
|
||||||
|
*/
|
||||||
|
async getTeamMembers(sessionId) {
|
||||||
|
try {
|
||||||
|
const response = await window.apiCall(`/tbm/sessions/${sessionId}/team`);
|
||||||
|
if (!response || !response.success) {
|
||||||
|
throw new Error(response?.message || '팀원 조회 실패');
|
||||||
|
}
|
||||||
|
return response.data || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ TBM 팀원 조회 오류:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TBM 팀원 일괄 추가
|
||||||
|
*/
|
||||||
|
async addTeamMembers(sessionId, members) {
|
||||||
|
try {
|
||||||
|
const response = await window.apiCall(
|
||||||
|
`/tbm/sessions/${sessionId}/team/batch`,
|
||||||
|
'POST',
|
||||||
|
{ members }
|
||||||
|
);
|
||||||
|
if (!response || !response.success) {
|
||||||
|
throw new Error(response?.message || '팀원 추가 실패');
|
||||||
|
}
|
||||||
|
console.log('✅ TBM 팀원 추가 완료:', members.length + '명');
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ TBM 팀원 추가 오류:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TBM 팀원 전체 삭제
|
||||||
|
*/
|
||||||
|
async clearTeamMembers(sessionId) {
|
||||||
|
try {
|
||||||
|
const response = await window.apiCall(`/tbm/sessions/${sessionId}/team/clear`, 'DELETE');
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ TBM 팀원 삭제 오류:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TBM 안전 체크 조회
|
||||||
|
*/
|
||||||
|
async getSafetyChecks(sessionId) {
|
||||||
|
try {
|
||||||
|
const response = await window.apiCall(`/tbm/sessions/${sessionId}/safety`);
|
||||||
|
return response?.data || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 안전 체크 조회 오류:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TBM 안전 체크 (필터링된) 조회
|
||||||
|
*/
|
||||||
|
async getFilteredSafetyChecks(sessionId) {
|
||||||
|
try {
|
||||||
|
const response = await window.apiCall(`/tbm/sessions/${sessionId}/safety-checks/filtered`);
|
||||||
|
if (!response || !response.success) {
|
||||||
|
throw new Error(response?.message || '체크리스트를 불러올 수 없습니다.');
|
||||||
|
}
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 필터링된 안전 체크 조회 오류:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TBM 안전 체크 저장
|
||||||
|
*/
|
||||||
|
async saveSafetyChecks(sessionId, records) {
|
||||||
|
try {
|
||||||
|
const response = await window.apiCall(
|
||||||
|
`/tbm/sessions/${sessionId}/safety`,
|
||||||
|
'POST',
|
||||||
|
{ records }
|
||||||
|
);
|
||||||
|
if (!response || !response.success) {
|
||||||
|
throw new Error(response?.message || '저장 실패');
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 안전 체크 저장 오류:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TBM 세션 완료 처리
|
||||||
|
*/
|
||||||
|
async completeTbmSession(sessionId, endTime) {
|
||||||
|
try {
|
||||||
|
const response = await window.apiCall(
|
||||||
|
`/tbm/sessions/${sessionId}/complete`,
|
||||||
|
'POST',
|
||||||
|
{ end_time: endTime }
|
||||||
|
);
|
||||||
|
if (!response || !response.success) {
|
||||||
|
throw new Error(response?.message || '완료 처리 실패');
|
||||||
|
}
|
||||||
|
console.log('✅ TBM 완료 처리:', sessionId);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ TBM 완료 처리 오류:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업 인계 저장
|
||||||
|
*/
|
||||||
|
async saveHandover(handoverData) {
|
||||||
|
try {
|
||||||
|
const response = await window.apiCall('/tbm/handovers', 'POST', handoverData);
|
||||||
|
if (!response || !response.success) {
|
||||||
|
throw new Error(response?.message || '인계 요청 실패');
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 작업 인계 저장 오류:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리별 작업장 로드
|
||||||
|
*/
|
||||||
|
async loadWorkplacesByCategory(categoryId) {
|
||||||
|
try {
|
||||||
|
const response = await window.apiCall(`/workplaces?category_id=${categoryId}`);
|
||||||
|
if (!response || !response.success || !response.data) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 작업장 로드 오류:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업장 지도 영역 로드
|
||||||
|
*/
|
||||||
|
async loadMapRegions(categoryId) {
|
||||||
|
try {
|
||||||
|
const response = await window.apiCall(`/workplaces/categories/${categoryId}/map-regions`);
|
||||||
|
if (response && response.success) {
|
||||||
|
this.state.mapRegions = response.data || [];
|
||||||
|
return this.state.mapRegions;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 지도 영역 로드 오류:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전역 인스턴스 생성
|
||||||
|
window.TbmAPI = new TbmAPI();
|
||||||
|
|
||||||
|
// 하위 호환성: 기존 함수들
|
||||||
|
window.loadInitialData = () => window.TbmAPI.loadInitialData();
|
||||||
|
window.loadTodayOnlyTbm = () => window.TbmAPI.loadTodayOnlyTbm();
|
||||||
|
window.loadTodayTbm = () => window.TbmAPI.loadRecentTbmGroupedByDate();
|
||||||
|
window.loadAllTbm = () => {
|
||||||
|
window.TbmState.loadedDaysCount = 30;
|
||||||
|
return window.TbmAPI.loadRecentTbmGroupedByDate();
|
||||||
|
};
|
||||||
|
window.loadRecentTbmGroupedByDate = () => window.TbmAPI.loadRecentTbmGroupedByDate();
|
||||||
|
window.loadTbmSessionsByDate = (date) => window.TbmAPI.loadTbmSessionsByDate(date);
|
||||||
|
window.loadWorkplaceCategories = () => window.TbmAPI.loadWorkplaceCategories();
|
||||||
|
window.loadWorkplacesByCategory = (categoryId) => window.TbmAPI.loadWorkplacesByCategory(categoryId);
|
||||||
|
|
||||||
|
// 더 많은 날짜 로드
|
||||||
|
window.loadMoreTbmDays = async function() {
|
||||||
|
window.TbmState.loadedDaysCount += 7;
|
||||||
|
await window.TbmAPI.loadRecentTbmGroupedByDate();
|
||||||
|
window.showToast?.(`최근 ${window.TbmState.loadedDaysCount}일의 TBM을 로드했습니다.`, 'success');
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[Module] tbm/api.js 로드 완료');
|
||||||
325
web-ui/js/tbm/index.js
Normal file
325
web-ui/js/tbm/index.js
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
/**
|
||||||
|
* TBM - Module Loader
|
||||||
|
* TBM 모듈을 초기화하고 연결하는 메인 진입점
|
||||||
|
*
|
||||||
|
* 로드 순서:
|
||||||
|
* 1. state.js - 전역 상태 관리
|
||||||
|
* 2. utils.js - 유틸리티 함수
|
||||||
|
* 3. api.js - API 클라이언트
|
||||||
|
* 4. index.js - 이 파일 (메인 컨트롤러)
|
||||||
|
*/
|
||||||
|
|
||||||
|
class TbmController {
|
||||||
|
constructor() {
|
||||||
|
this.state = window.TbmState;
|
||||||
|
this.api = window.TbmAPI;
|
||||||
|
this.utils = window.TbmUtils;
|
||||||
|
this.initialized = false;
|
||||||
|
|
||||||
|
console.log('[TbmController] 생성');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 초기화
|
||||||
|
*/
|
||||||
|
async init() {
|
||||||
|
if (this.initialized) {
|
||||||
|
console.log('[TbmController] 이미 초기화됨');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🛠️ TBM 관리 페이지 초기화');
|
||||||
|
|
||||||
|
// API 함수가 로드될 때까지 대기
|
||||||
|
let retryCount = 0;
|
||||||
|
while (!window.apiCall && retryCount < 50) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
retryCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.apiCall) {
|
||||||
|
window.showToast?.('시스템을 초기화할 수 없습니다. 페이지를 새로고침해주세요.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 오늘 날짜 설정 (서울 시간대 기준)
|
||||||
|
const today = this.utils.getTodayKST();
|
||||||
|
const tbmDateEl = document.getElementById('tbmDate');
|
||||||
|
const sessionDateEl = document.getElementById('sessionDate');
|
||||||
|
if (tbmDateEl) tbmDateEl.value = today;
|
||||||
|
if (sessionDateEl) sessionDateEl.value = today;
|
||||||
|
|
||||||
|
// 이벤트 리스너 설정
|
||||||
|
this.setupEventListeners();
|
||||||
|
|
||||||
|
// 초기 데이터 로드
|
||||||
|
await this.api.loadInitialData();
|
||||||
|
await this.api.loadTodayOnlyTbm();
|
||||||
|
|
||||||
|
// 렌더링
|
||||||
|
this.displayTodayTbmSessions();
|
||||||
|
|
||||||
|
this.initialized = true;
|
||||||
|
console.log('[TbmController] 초기화 완료');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트 리스너 설정
|
||||||
|
*/
|
||||||
|
setupEventListeners() {
|
||||||
|
// 탭 버튼들
|
||||||
|
document.querySelectorAll('.tbm-tab-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const tabName = btn.dataset.tab;
|
||||||
|
if (tabName) this.switchTbmTab(tabName);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 탭 전환
|
||||||
|
*/
|
||||||
|
async switchTbmTab(tabName) {
|
||||||
|
this.state.setCurrentTab(tabName);
|
||||||
|
|
||||||
|
// 탭 버튼 활성화 상태 변경
|
||||||
|
document.querySelectorAll('.tbm-tab-btn').forEach(btn => {
|
||||||
|
if (btn.dataset.tab === tabName) {
|
||||||
|
btn.classList.add('active');
|
||||||
|
} else {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 탭 컨텐츠 표시 변경
|
||||||
|
document.querySelectorAll('.tbm-tab-content').forEach(content => {
|
||||||
|
content.classList.remove('active');
|
||||||
|
});
|
||||||
|
const tabContent = document.getElementById(`${tabName}-tab`);
|
||||||
|
if (tabContent) tabContent.classList.add('active');
|
||||||
|
|
||||||
|
// 탭에 따라 데이터 로드
|
||||||
|
if (tabName === 'tbm-input') {
|
||||||
|
await this.api.loadTodayOnlyTbm();
|
||||||
|
this.displayTodayTbmSessions();
|
||||||
|
} else if (tabName === 'tbm-manage') {
|
||||||
|
await this.api.loadRecentTbmGroupedByDate();
|
||||||
|
this.displayTbmGroupedByDate();
|
||||||
|
this.updateViewModeIndicator();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 오늘의 TBM 세션 표시
|
||||||
|
*/
|
||||||
|
displayTodayTbmSessions() {
|
||||||
|
const grid = document.getElementById('todayTbmGrid');
|
||||||
|
const emptyState = document.getElementById('todayEmptyState');
|
||||||
|
const todayTotalEl = document.getElementById('todayTotalSessions');
|
||||||
|
const todayCompletedEl = document.getElementById('todayCompletedSessions');
|
||||||
|
const todayActiveEl = document.getElementById('todayActiveSessions');
|
||||||
|
|
||||||
|
const sessions = this.state.todaySessions;
|
||||||
|
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
if (grid) grid.innerHTML = '';
|
||||||
|
if (emptyState) emptyState.style.display = 'flex';
|
||||||
|
if (todayTotalEl) todayTotalEl.textContent = '0';
|
||||||
|
if (todayCompletedEl) todayCompletedEl.textContent = '0';
|
||||||
|
if (todayActiveEl) todayActiveEl.textContent = '0';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emptyState) emptyState.style.display = 'none';
|
||||||
|
|
||||||
|
const completedCount = sessions.filter(s => s.status === 'completed').length;
|
||||||
|
const activeCount = sessions.filter(s => s.status === 'draft').length;
|
||||||
|
|
||||||
|
if (todayTotalEl) todayTotalEl.textContent = sessions.length;
|
||||||
|
if (todayCompletedEl) todayCompletedEl.textContent = completedCount;
|
||||||
|
if (todayActiveEl) todayActiveEl.textContent = activeCount;
|
||||||
|
|
||||||
|
if (grid) {
|
||||||
|
grid.innerHTML = sessions.map(session => this.createSessionCard(session)).join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날짜별 그룹으로 TBM 표시
|
||||||
|
*/
|
||||||
|
displayTbmGroupedByDate() {
|
||||||
|
const container = document.getElementById('tbmDateGroupsContainer');
|
||||||
|
const emptyState = document.getElementById('emptyState');
|
||||||
|
const totalSessionsEl = document.getElementById('totalSessions');
|
||||||
|
const completedSessionsEl = document.getElementById('completedSessions');
|
||||||
|
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const sortedDates = Object.keys(this.state.dateGroupedSessions).sort((a, b) =>
|
||||||
|
new Date(b) - new Date(a)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sortedDates.length === 0 || this.state.allLoadedSessions.length === 0) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
if (emptyState) emptyState.style.display = 'flex';
|
||||||
|
if (totalSessionsEl) totalSessionsEl.textContent = '0';
|
||||||
|
if (completedSessionsEl) completedSessionsEl.textContent = '0';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emptyState) emptyState.style.display = 'none';
|
||||||
|
|
||||||
|
// 통계 업데이트
|
||||||
|
const completedCount = this.state.allLoadedSessions.filter(s => s.status === 'completed').length;
|
||||||
|
if (totalSessionsEl) totalSessionsEl.textContent = this.state.allLoadedSessions.length;
|
||||||
|
if (completedSessionsEl) completedSessionsEl.textContent = completedCount;
|
||||||
|
|
||||||
|
// 날짜별 그룹 HTML 생성
|
||||||
|
const today = this.utils.getTodayKST();
|
||||||
|
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
|
||||||
|
|
||||||
|
container.innerHTML = sortedDates.map(date => {
|
||||||
|
const sessions = this.state.dateGroupedSessions[date];
|
||||||
|
const dateObj = new Date(date + 'T00:00:00');
|
||||||
|
const dayName = dayNames[dateObj.getDay()];
|
||||||
|
const isToday = date === today;
|
||||||
|
|
||||||
|
const [year, month, day] = date.split('-');
|
||||||
|
const displayDate = `${parseInt(month)}월 ${parseInt(day)}일`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="tbm-date-group" data-date="${date}">
|
||||||
|
<div class="tbm-date-header ${isToday ? 'today' : ''}" onclick="toggleDateGroup('${date}')">
|
||||||
|
<span class="tbm-date-toggle">▼</span>
|
||||||
|
<span class="tbm-date-title">${displayDate}</span>
|
||||||
|
<span class="tbm-date-day">${dayName}요일</span>
|
||||||
|
${isToday ? '<span class="tbm-today-badge">오늘</span>' : ''}
|
||||||
|
<span class="tbm-date-count">${sessions.length}건</span>
|
||||||
|
</div>
|
||||||
|
<div class="tbm-date-content">
|
||||||
|
<div class="tbm-date-grid">
|
||||||
|
${sessions.map(session => this.createSessionCard(session)).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 뷰 모드 표시 업데이트
|
||||||
|
*/
|
||||||
|
updateViewModeIndicator() {
|
||||||
|
const indicator = document.getElementById('viewModeIndicator');
|
||||||
|
const text = document.getElementById('viewModeText');
|
||||||
|
|
||||||
|
if (indicator && text) {
|
||||||
|
if (this.state.isAdminUser()) {
|
||||||
|
indicator.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
indicator.style.display = 'inline-flex';
|
||||||
|
text.textContent = '내 TBM';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TBM 세션 카드 생성
|
||||||
|
*/
|
||||||
|
createSessionCard(session) {
|
||||||
|
const statusBadge = this.utils.getStatusBadge(session.status);
|
||||||
|
|
||||||
|
const leaderName = session.leader_name || session.created_by_name || '작업 책임자';
|
||||||
|
const leaderRole = session.leader_name
|
||||||
|
? (session.leader_job_type || '작업자')
|
||||||
|
: '관리자';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="tbm-session-card" onclick="viewTbmSession(${session.session_id})">
|
||||||
|
<div class="tbm-card-header">
|
||||||
|
<div class="tbm-card-header-top">
|
||||||
|
<div>
|
||||||
|
<h3 class="tbm-card-leader">
|
||||||
|
${leaderName}
|
||||||
|
<span class="tbm-card-leader-role">${leaderRole}</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
${statusBadge}
|
||||||
|
</div>
|
||||||
|
<div class="tbm-card-date">
|
||||||
|
<span>📅</span>
|
||||||
|
${this.utils.formatDate(session.session_date)} ${session.start_time ? '| ' + session.start_time : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tbm-card-body">
|
||||||
|
<div class="tbm-card-info-grid">
|
||||||
|
<div class="tbm-card-info-item">
|
||||||
|
<span class="tbm-card-info-label">프로젝트</span>
|
||||||
|
<span class="tbm-card-info-value">${session.project_name || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tbm-card-info-item">
|
||||||
|
<span class="tbm-card-info-label">공정</span>
|
||||||
|
<span class="tbm-card-info-value">${session.work_type_name || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tbm-card-info-item">
|
||||||
|
<span class="tbm-card-info-label">작업장</span>
|
||||||
|
<span class="tbm-card-info-value">${session.work_location || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tbm-card-info-item">
|
||||||
|
<span class="tbm-card-info-label">팀원</span>
|
||||||
|
<span class="tbm-card-info-value">${session.team_member_count || 0}명</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${session.status === 'draft' ? `
|
||||||
|
<div class="tbm-card-footer">
|
||||||
|
<button class="tbm-btn tbm-btn-primary tbm-btn-sm" onclick="event.stopPropagation(); openTeamCompositionModal(${session.session_id})">
|
||||||
|
👥 팀 구성
|
||||||
|
</button>
|
||||||
|
<button class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="event.stopPropagation(); openSafetyCheckModal(${session.session_id})">
|
||||||
|
✓ 안전 체크
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 디버그
|
||||||
|
*/
|
||||||
|
debug() {
|
||||||
|
console.log('[TbmController] 상태 디버그:');
|
||||||
|
this.state.debug();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전역 인스턴스 생성
|
||||||
|
window.TbmController = new TbmController();
|
||||||
|
|
||||||
|
// 하위 호환성: 기존 전역 함수들
|
||||||
|
window.switchTbmTab = (tabName) => window.TbmController.switchTbmTab(tabName);
|
||||||
|
window.displayTodayTbmSessions = () => window.TbmController.displayTodayTbmSessions();
|
||||||
|
window.displayTbmGroupedByDate = () => window.TbmController.displayTbmGroupedByDate();
|
||||||
|
window.displayTbmSessions = () => window.TbmController.displayTbmGroupedByDate();
|
||||||
|
window.createSessionCard = (session) => window.TbmController.createSessionCard(session);
|
||||||
|
window.updateViewModeIndicator = () => window.TbmController.updateViewModeIndicator();
|
||||||
|
|
||||||
|
// 날짜 그룹 토글
|
||||||
|
window.toggleDateGroup = function(date) {
|
||||||
|
const group = document.querySelector(`.tbm-date-group[data-date="${date}"]`);
|
||||||
|
if (group) {
|
||||||
|
group.classList.toggle('collapsed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// DOMContentLoaded 이벤트에서 초기화
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
window.TbmController.init();
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[Module] tbm/index.js 로드 완료');
|
||||||
392
web-ui/js/tbm/state.js
Normal file
392
web-ui/js/tbm/state.js
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
/**
|
||||||
|
* TBM - State Manager
|
||||||
|
* TBM 페이지의 전역 상태 관리
|
||||||
|
*/
|
||||||
|
|
||||||
|
class TbmState {
|
||||||
|
constructor() {
|
||||||
|
// 세션 데이터
|
||||||
|
this.allSessions = [];
|
||||||
|
this.todaySessions = [];
|
||||||
|
this.dateGroupedSessions = {};
|
||||||
|
this.allLoadedSessions = [];
|
||||||
|
this.loadedDaysCount = 7;
|
||||||
|
|
||||||
|
// 마스터 데이터
|
||||||
|
this.allWorkers = [];
|
||||||
|
this.allProjects = [];
|
||||||
|
this.allWorkTypes = [];
|
||||||
|
this.allTasks = [];
|
||||||
|
this.allSafetyChecks = [];
|
||||||
|
this.allWorkplaces = [];
|
||||||
|
this.allWorkplaceCategories = [];
|
||||||
|
|
||||||
|
// 현재 상태
|
||||||
|
this.currentUser = null;
|
||||||
|
this.currentSessionId = null;
|
||||||
|
this.currentTab = 'tbm-input';
|
||||||
|
|
||||||
|
// 작업자 관련
|
||||||
|
this.selectedWorkers = new Set();
|
||||||
|
this.workerTaskList = [];
|
||||||
|
this.selectedWorkersInModal = new Set();
|
||||||
|
this.currentEditingTaskLine = null;
|
||||||
|
|
||||||
|
// 작업장 선택 관련
|
||||||
|
this.selectedCategory = null;
|
||||||
|
this.selectedWorkplace = null;
|
||||||
|
this.selectedCategoryName = '';
|
||||||
|
this.selectedWorkplaceName = '';
|
||||||
|
|
||||||
|
// 일괄 설정 관련
|
||||||
|
this.isBulkMode = false;
|
||||||
|
this.bulkSelectedWorkers = new Set();
|
||||||
|
|
||||||
|
// 지도 관련
|
||||||
|
this.mapCanvas = null;
|
||||||
|
this.mapCtx = null;
|
||||||
|
this.mapImage = null;
|
||||||
|
this.mapRegions = [];
|
||||||
|
|
||||||
|
// 리스너
|
||||||
|
this.listeners = new Map();
|
||||||
|
|
||||||
|
console.log('[TbmState] 초기화 완료');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상태 업데이트
|
||||||
|
*/
|
||||||
|
update(key, value) {
|
||||||
|
const prevValue = this[key];
|
||||||
|
this[key] = value;
|
||||||
|
this.notifyListeners(key, value, prevValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 리스너 등록
|
||||||
|
*/
|
||||||
|
subscribe(key, callback) {
|
||||||
|
if (!this.listeners.has(key)) {
|
||||||
|
this.listeners.set(key, []);
|
||||||
|
}
|
||||||
|
this.listeners.get(key).push(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 리스너 알림
|
||||||
|
*/
|
||||||
|
notifyListeners(key, newValue, prevValue) {
|
||||||
|
const keyListeners = this.listeners.get(key) || [];
|
||||||
|
keyListeners.forEach(callback => {
|
||||||
|
try {
|
||||||
|
callback(newValue, prevValue);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[TbmState] 리스너 오류 (${key}):`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 사용자 정보 가져오기
|
||||||
|
*/
|
||||||
|
getUser() {
|
||||||
|
if (!this.currentUser) {
|
||||||
|
const userInfo = localStorage.getItem('user');
|
||||||
|
this.currentUser = userInfo ? JSON.parse(userInfo) : null;
|
||||||
|
}
|
||||||
|
return this.currentUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin 여부 확인
|
||||||
|
*/
|
||||||
|
isAdminUser() {
|
||||||
|
const user = this.getUser();
|
||||||
|
if (!user) return false;
|
||||||
|
return user.role === 'Admin' || user.role === 'System Admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 탭 변경
|
||||||
|
*/
|
||||||
|
setCurrentTab(tab) {
|
||||||
|
const prevTab = this.currentTab;
|
||||||
|
this.currentTab = tab;
|
||||||
|
this.notifyListeners('currentTab', tab, prevTab);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업자 목록에 추가
|
||||||
|
*/
|
||||||
|
addWorkerToList(worker) {
|
||||||
|
this.workerTaskList.push({
|
||||||
|
worker_id: worker.worker_id,
|
||||||
|
worker_name: worker.worker_name,
|
||||||
|
job_type: worker.job_type,
|
||||||
|
tasks: [this.createEmptyTaskLine()]
|
||||||
|
});
|
||||||
|
this.notifyListeners('workerTaskList', this.workerTaskList, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 빈 작업 라인 생성
|
||||||
|
*/
|
||||||
|
createEmptyTaskLine() {
|
||||||
|
return {
|
||||||
|
task_line_id: this.generateUUID(),
|
||||||
|
project_id: null,
|
||||||
|
work_type_id: null,
|
||||||
|
task_id: null,
|
||||||
|
workplace_category_id: null,
|
||||||
|
workplace_id: null,
|
||||||
|
workplace_category_name: '',
|
||||||
|
workplace_name: '',
|
||||||
|
work_detail: null,
|
||||||
|
is_present: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업자에 작업 라인 추가
|
||||||
|
*/
|
||||||
|
addTaskLineToWorker(workerIndex) {
|
||||||
|
if (this.workerTaskList[workerIndex]) {
|
||||||
|
this.workerTaskList[workerIndex].tasks.push(this.createEmptyTaskLine());
|
||||||
|
this.notifyListeners('workerTaskList', this.workerTaskList, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업 라인 제거
|
||||||
|
*/
|
||||||
|
removeTaskLine(workerIndex, taskIndex) {
|
||||||
|
if (this.workerTaskList[workerIndex]?.tasks) {
|
||||||
|
this.workerTaskList[workerIndex].tasks.splice(taskIndex, 1);
|
||||||
|
this.notifyListeners('workerTaskList', this.workerTaskList, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업자 제거
|
||||||
|
*/
|
||||||
|
removeWorkerFromList(workerIndex) {
|
||||||
|
const removed = this.workerTaskList.splice(workerIndex, 1);
|
||||||
|
this.notifyListeners('workerTaskList', this.workerTaskList, null);
|
||||||
|
return removed[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업장 선택 초기화
|
||||||
|
*/
|
||||||
|
resetWorkplaceSelection() {
|
||||||
|
this.selectedCategory = null;
|
||||||
|
this.selectedWorkplace = null;
|
||||||
|
this.selectedCategoryName = '';
|
||||||
|
this.selectedWorkplaceName = '';
|
||||||
|
this.mapCanvas = null;
|
||||||
|
this.mapCtx = null;
|
||||||
|
this.mapImage = null;
|
||||||
|
this.mapRegions = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일괄 설정 초기화
|
||||||
|
*/
|
||||||
|
resetBulkSettings() {
|
||||||
|
this.isBulkMode = false;
|
||||||
|
this.bulkSelectedWorkers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날짜별 세션 그룹화
|
||||||
|
*/
|
||||||
|
groupSessionsByDate(sessions) {
|
||||||
|
this.dateGroupedSessions = {};
|
||||||
|
this.allLoadedSessions = [];
|
||||||
|
|
||||||
|
sessions.forEach(session => {
|
||||||
|
const date = this.formatDate(session.session_date);
|
||||||
|
if (!this.dateGroupedSessions[date]) {
|
||||||
|
this.dateGroupedSessions[date] = [];
|
||||||
|
}
|
||||||
|
this.dateGroupedSessions[date].push(session);
|
||||||
|
this.allLoadedSessions.push(session);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날짜 포맷팅
|
||||||
|
*/
|
||||||
|
formatDate(dateString) {
|
||||||
|
if (!dateString) return '';
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UUID 생성
|
||||||
|
*/
|
||||||
|
generateUUID() {
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||||
|
const r = Math.random() * 16 | 0;
|
||||||
|
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||||
|
return v.toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상태 초기화
|
||||||
|
*/
|
||||||
|
reset() {
|
||||||
|
this.workerTaskList = [];
|
||||||
|
this.selectedWorkers.clear();
|
||||||
|
this.selectedWorkersInModal.clear();
|
||||||
|
this.currentEditingTaskLine = null;
|
||||||
|
this.resetWorkplaceSelection();
|
||||||
|
this.resetBulkSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 디버그 출력
|
||||||
|
*/
|
||||||
|
debug() {
|
||||||
|
console.log('[TbmState] 현재 상태:', {
|
||||||
|
allSessions: this.allSessions.length,
|
||||||
|
todaySessions: this.todaySessions.length,
|
||||||
|
allWorkers: this.allWorkers.length,
|
||||||
|
allProjects: this.allProjects.length,
|
||||||
|
workerTaskList: this.workerTaskList.length,
|
||||||
|
currentTab: this.currentTab
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전역 인스턴스 생성
|
||||||
|
window.TbmState = new TbmState();
|
||||||
|
|
||||||
|
// 하위 호환성을 위한 전역 변수 프록시
|
||||||
|
const tbmStateProxy = window.TbmState;
|
||||||
|
|
||||||
|
Object.defineProperties(window, {
|
||||||
|
allSessions: {
|
||||||
|
get: () => tbmStateProxy.allSessions,
|
||||||
|
set: (v) => { tbmStateProxy.allSessions = v; }
|
||||||
|
},
|
||||||
|
todaySessions: {
|
||||||
|
get: () => tbmStateProxy.todaySessions,
|
||||||
|
set: (v) => { tbmStateProxy.todaySessions = v; }
|
||||||
|
},
|
||||||
|
allWorkers: {
|
||||||
|
get: () => tbmStateProxy.allWorkers,
|
||||||
|
set: (v) => { tbmStateProxy.allWorkers = v; }
|
||||||
|
},
|
||||||
|
allProjects: {
|
||||||
|
get: () => tbmStateProxy.allProjects,
|
||||||
|
set: (v) => { tbmStateProxy.allProjects = v; }
|
||||||
|
},
|
||||||
|
allWorkTypes: {
|
||||||
|
get: () => tbmStateProxy.allWorkTypes,
|
||||||
|
set: (v) => { tbmStateProxy.allWorkTypes = v; }
|
||||||
|
},
|
||||||
|
allTasks: {
|
||||||
|
get: () => tbmStateProxy.allTasks,
|
||||||
|
set: (v) => { tbmStateProxy.allTasks = v; }
|
||||||
|
},
|
||||||
|
allSafetyChecks: {
|
||||||
|
get: () => tbmStateProxy.allSafetyChecks,
|
||||||
|
set: (v) => { tbmStateProxy.allSafetyChecks = v; }
|
||||||
|
},
|
||||||
|
allWorkplaces: {
|
||||||
|
get: () => tbmStateProxy.allWorkplaces,
|
||||||
|
set: (v) => { tbmStateProxy.allWorkplaces = v; }
|
||||||
|
},
|
||||||
|
allWorkplaceCategories: {
|
||||||
|
get: () => tbmStateProxy.allWorkplaceCategories,
|
||||||
|
set: (v) => { tbmStateProxy.allWorkplaceCategories = v; }
|
||||||
|
},
|
||||||
|
currentUser: {
|
||||||
|
get: () => tbmStateProxy.currentUser,
|
||||||
|
set: (v) => { tbmStateProxy.currentUser = v; }
|
||||||
|
},
|
||||||
|
currentSessionId: {
|
||||||
|
get: () => tbmStateProxy.currentSessionId,
|
||||||
|
set: (v) => { tbmStateProxy.currentSessionId = v; }
|
||||||
|
},
|
||||||
|
selectedWorkers: {
|
||||||
|
get: () => tbmStateProxy.selectedWorkers,
|
||||||
|
set: (v) => { tbmStateProxy.selectedWorkers = v; }
|
||||||
|
},
|
||||||
|
workerTaskList: {
|
||||||
|
get: () => tbmStateProxy.workerTaskList,
|
||||||
|
set: (v) => { tbmStateProxy.workerTaskList = v; }
|
||||||
|
},
|
||||||
|
selectedWorkersInModal: {
|
||||||
|
get: () => tbmStateProxy.selectedWorkersInModal,
|
||||||
|
set: (v) => { tbmStateProxy.selectedWorkersInModal = v; }
|
||||||
|
},
|
||||||
|
currentEditingTaskLine: {
|
||||||
|
get: () => tbmStateProxy.currentEditingTaskLine,
|
||||||
|
set: (v) => { tbmStateProxy.currentEditingTaskLine = v; }
|
||||||
|
},
|
||||||
|
selectedCategory: {
|
||||||
|
get: () => tbmStateProxy.selectedCategory,
|
||||||
|
set: (v) => { tbmStateProxy.selectedCategory = v; }
|
||||||
|
},
|
||||||
|
selectedWorkplace: {
|
||||||
|
get: () => tbmStateProxy.selectedWorkplace,
|
||||||
|
set: (v) => { tbmStateProxy.selectedWorkplace = v; }
|
||||||
|
},
|
||||||
|
selectedCategoryName: {
|
||||||
|
get: () => tbmStateProxy.selectedCategoryName,
|
||||||
|
set: (v) => { tbmStateProxy.selectedCategoryName = v; }
|
||||||
|
},
|
||||||
|
selectedWorkplaceName: {
|
||||||
|
get: () => tbmStateProxy.selectedWorkplaceName,
|
||||||
|
set: (v) => { tbmStateProxy.selectedWorkplaceName = v; }
|
||||||
|
},
|
||||||
|
isBulkMode: {
|
||||||
|
get: () => tbmStateProxy.isBulkMode,
|
||||||
|
set: (v) => { tbmStateProxy.isBulkMode = v; }
|
||||||
|
},
|
||||||
|
bulkSelectedWorkers: {
|
||||||
|
get: () => tbmStateProxy.bulkSelectedWorkers,
|
||||||
|
set: (v) => { tbmStateProxy.bulkSelectedWorkers = v; }
|
||||||
|
},
|
||||||
|
dateGroupedSessions: {
|
||||||
|
get: () => tbmStateProxy.dateGroupedSessions,
|
||||||
|
set: (v) => { tbmStateProxy.dateGroupedSessions = v; }
|
||||||
|
},
|
||||||
|
allLoadedSessions: {
|
||||||
|
get: () => tbmStateProxy.allLoadedSessions,
|
||||||
|
set: (v) => { tbmStateProxy.allLoadedSessions = v; }
|
||||||
|
},
|
||||||
|
loadedDaysCount: {
|
||||||
|
get: () => tbmStateProxy.loadedDaysCount,
|
||||||
|
set: (v) => { tbmStateProxy.loadedDaysCount = v; }
|
||||||
|
},
|
||||||
|
mapRegions: {
|
||||||
|
get: () => tbmStateProxy.mapRegions,
|
||||||
|
set: (v) => { tbmStateProxy.mapRegions = v; }
|
||||||
|
},
|
||||||
|
mapCanvas: {
|
||||||
|
get: () => tbmStateProxy.mapCanvas,
|
||||||
|
set: (v) => { tbmStateProxy.mapCanvas = v; }
|
||||||
|
},
|
||||||
|
mapCtx: {
|
||||||
|
get: () => tbmStateProxy.mapCtx,
|
||||||
|
set: (v) => { tbmStateProxy.mapCtx = v; }
|
||||||
|
},
|
||||||
|
mapImage: {
|
||||||
|
get: () => tbmStateProxy.mapImage,
|
||||||
|
set: (v) => { tbmStateProxy.mapImage = v; }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[Module] tbm/state.js 로드 완료');
|
||||||
253
web-ui/js/tbm/utils.js
Normal file
253
web-ui/js/tbm/utils.js
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
/**
|
||||||
|
* TBM - Utilities
|
||||||
|
* TBM 관련 유틸리티 함수들
|
||||||
|
*/
|
||||||
|
|
||||||
|
class TbmUtils {
|
||||||
|
constructor() {
|
||||||
|
console.log('[TbmUtils] 초기화 완료');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서울 시간대(Asia/Seoul, UTC+9) 기준 오늘 날짜를 YYYY-MM-DD 형식으로 반환
|
||||||
|
*/
|
||||||
|
getTodayKST() {
|
||||||
|
const now = new Date();
|
||||||
|
const kstOffset = 9 * 60;
|
||||||
|
const utc = now.getTime() + (now.getTimezoneOffset() * 60000);
|
||||||
|
const kstTime = new Date(utc + (kstOffset * 60000));
|
||||||
|
|
||||||
|
const year = kstTime.getFullYear();
|
||||||
|
const month = String(kstTime.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(kstTime.getDate()).padStart(2, '0');
|
||||||
|
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ISO 날짜 문자열을 YYYY-MM-DD 형식으로 변환
|
||||||
|
*/
|
||||||
|
formatDate(dateString) {
|
||||||
|
if (!dateString) return '';
|
||||||
|
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날짜 표시용 포맷 (MM월 DD일)
|
||||||
|
*/
|
||||||
|
formatDateDisplay(dateString) {
|
||||||
|
if (!dateString) return '';
|
||||||
|
const [year, month, day] = dateString.split('-');
|
||||||
|
return `${parseInt(month)}월 ${parseInt(day)}일`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날짜를 연/월/일/요일 형식으로 포맷
|
||||||
|
*/
|
||||||
|
formatDateFull(dateString) {
|
||||||
|
if (!dateString) return '';
|
||||||
|
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
|
||||||
|
const [year, month, day] = dateString.split('-');
|
||||||
|
const dateObj = new Date(dateString);
|
||||||
|
const dayName = dayNames[dateObj.getDay()];
|
||||||
|
return `${year}년 ${parseInt(month)}월 ${parseInt(day)}일 (${dayName})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 요일 반환
|
||||||
|
*/
|
||||||
|
getDayOfWeek(dateString) {
|
||||||
|
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
|
||||||
|
const dateObj = new Date(dateString + 'T00:00:00');
|
||||||
|
return dayNames[dateObj.getDay()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 오늘인지 확인
|
||||||
|
*/
|
||||||
|
isToday(dateString) {
|
||||||
|
const today = this.getTodayKST();
|
||||||
|
return this.formatDate(dateString) === today;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 시간을 HH:MM 형식으로 반환
|
||||||
|
*/
|
||||||
|
getCurrentTime() {
|
||||||
|
return new Date().toTimeString().slice(0, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UUID 생성
|
||||||
|
*/
|
||||||
|
generateUUID() {
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||||
|
const r = Math.random() * 16 | 0;
|
||||||
|
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||||
|
return v.toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTML 이스케이프
|
||||||
|
*/
|
||||||
|
escapeHtml(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날씨 조건명 반환
|
||||||
|
*/
|
||||||
|
getWeatherConditionName(code) {
|
||||||
|
const names = {
|
||||||
|
clear: '맑음',
|
||||||
|
rain: '비',
|
||||||
|
snow: '눈',
|
||||||
|
heat: '폭염',
|
||||||
|
cold: '한파',
|
||||||
|
wind: '강풍',
|
||||||
|
fog: '안개',
|
||||||
|
dust: '미세먼지'
|
||||||
|
};
|
||||||
|
return names[code] || code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날씨 아이콘 반환
|
||||||
|
*/
|
||||||
|
getWeatherIcon(code) {
|
||||||
|
const icons = {
|
||||||
|
clear: '☀️',
|
||||||
|
rain: '🌧️',
|
||||||
|
snow: '❄️',
|
||||||
|
heat: '🔥',
|
||||||
|
cold: '🥶',
|
||||||
|
wind: '💨',
|
||||||
|
fog: '🌫️',
|
||||||
|
dust: '😷'
|
||||||
|
};
|
||||||
|
return icons[code] || '🌤️';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리명 반환
|
||||||
|
*/
|
||||||
|
getCategoryName(category) {
|
||||||
|
const names = {
|
||||||
|
'PPE': '개인 보호 장비',
|
||||||
|
'EQUIPMENT': '장비 점검',
|
||||||
|
'ENVIRONMENT': '작업 환경',
|
||||||
|
'EMERGENCY': '비상 대응',
|
||||||
|
'WEATHER': '날씨',
|
||||||
|
'TASK': '작업'
|
||||||
|
};
|
||||||
|
return names[category] || category;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상태 배지 HTML 반환
|
||||||
|
*/
|
||||||
|
getStatusBadge(status) {
|
||||||
|
const badges = {
|
||||||
|
'draft': '<span class="tbm-card-status draft">진행중</span>',
|
||||||
|
'completed': '<span class="tbm-card-status completed">완료</span>',
|
||||||
|
'cancelled': '<span class="tbm-card-status cancelled">취소</span>'
|
||||||
|
};
|
||||||
|
return badges[status] || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전역 인스턴스 생성
|
||||||
|
window.TbmUtils = new TbmUtils();
|
||||||
|
|
||||||
|
// 하위 호환성: 기존 함수들
|
||||||
|
window.getTodayKST = () => window.TbmUtils.getTodayKST();
|
||||||
|
window.formatDate = (dateString) => window.TbmUtils.formatDate(dateString);
|
||||||
|
|
||||||
|
// 토스트 알림
|
||||||
|
window.showToast = function(message, type = 'info', duration = 3000) {
|
||||||
|
const container = document.getElementById('toastContainer');
|
||||||
|
if (!container) {
|
||||||
|
console.log(`[Toast] ${type}: ${message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = `toast ${type}`;
|
||||||
|
|
||||||
|
const iconMap = {
|
||||||
|
success: '✅',
|
||||||
|
error: '❌',
|
||||||
|
warning: '⚠️',
|
||||||
|
info: 'ℹ️'
|
||||||
|
};
|
||||||
|
|
||||||
|
toast.innerHTML = `
|
||||||
|
<div class="toast-icon">${iconMap[type] || 'ℹ️'}</div>
|
||||||
|
<div class="toast-message">${message}</div>
|
||||||
|
<button class="toast-close" onclick="this.parentElement.remove()">×</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
toast.style.cssText = `
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
min-width: 300px;
|
||||||
|
animation: slideIn 0.3s ease-out;
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.appendChild(toast);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (toast.parentElement) {
|
||||||
|
toast.style.animation = 'slideOut 0.3s ease-out';
|
||||||
|
setTimeout(() => toast.remove(), 300);
|
||||||
|
}
|
||||||
|
}, duration);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 카테고리별 그룹화
|
||||||
|
window.groupChecksByCategory = function(checks) {
|
||||||
|
return checks.reduce((acc, check) => {
|
||||||
|
const category = check.check_category || 'OTHER';
|
||||||
|
if (!acc[category]) acc[category] = [];
|
||||||
|
acc[category].push(check);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 작업별 그룹화
|
||||||
|
window.groupChecksByTask = function(checks) {
|
||||||
|
return checks.reduce((acc, check) => {
|
||||||
|
const taskId = check.task_id || 0;
|
||||||
|
const taskName = check.task_name || '기타 작업';
|
||||||
|
if (!acc[taskId]) acc[taskId] = { name: taskName, items: [] };
|
||||||
|
acc[taskId].items.push(check);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Admin 사용자 확인
|
||||||
|
window.isAdminUser = function() {
|
||||||
|
return window.TbmState?.isAdminUser() || false;
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[Module] tbm/utils.js 로드 완료');
|
||||||
329
web-ui/js/workplace-management/api.js
Normal file
329
web-ui/js/workplace-management/api.js
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
/**
|
||||||
|
* Workplace Management - API Client
|
||||||
|
* 작업장 관리 관련 모든 API 호출을 관리
|
||||||
|
*/
|
||||||
|
|
||||||
|
class WorkplaceAPI {
|
||||||
|
constructor() {
|
||||||
|
this.state = window.WorkplaceState;
|
||||||
|
this.utils = window.WorkplaceUtils;
|
||||||
|
console.log('[WorkplaceAPI] 초기화 완료');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모든 데이터 로드
|
||||||
|
*/
|
||||||
|
async loadAllData() {
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
this.loadCategories(),
|
||||||
|
this.loadWorkplaces()
|
||||||
|
]);
|
||||||
|
console.log('✅ 모든 데이터 로드 완료');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('데이터 로딩 오류:', error);
|
||||||
|
window.showToast?.('데이터를 불러오는데 실패했습니다.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리 목록 로드
|
||||||
|
*/
|
||||||
|
async loadCategories() {
|
||||||
|
try {
|
||||||
|
const response = await window.apiCall('/workplaces/categories', 'GET');
|
||||||
|
|
||||||
|
let categoryData = [];
|
||||||
|
if (response && response.success && Array.isArray(response.data)) {
|
||||||
|
categoryData = response.data;
|
||||||
|
} else if (Array.isArray(response)) {
|
||||||
|
categoryData = response;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state.categories = categoryData;
|
||||||
|
console.log(`✅ 카테고리 ${this.state.categories.length}개 로드 완료`);
|
||||||
|
return categoryData;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('카테고리 로딩 오류:', error);
|
||||||
|
this.state.categories = [];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리 저장 (생성/수정)
|
||||||
|
*/
|
||||||
|
async saveCategory(categoryId, categoryData) {
|
||||||
|
try {
|
||||||
|
let response;
|
||||||
|
if (categoryId) {
|
||||||
|
response = await window.apiCall(`/workplaces/categories/${categoryId}`, 'PUT', categoryData);
|
||||||
|
} else {
|
||||||
|
response = await window.apiCall('/workplaces/categories', 'POST', categoryData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response && (response.success || response.category_id)) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
throw new Error(response?.message || '저장에 실패했습니다.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('카테고리 저장 오류:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리 삭제
|
||||||
|
*/
|
||||||
|
async deleteCategory(categoryId) {
|
||||||
|
try {
|
||||||
|
const response = await window.apiCall(`/workplaces/categories/${categoryId}`, 'DELETE');
|
||||||
|
if (response && response.success) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
throw new Error(response?.message || '삭제에 실패했습니다.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('카테고리 삭제 오류:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업장 목록 로드
|
||||||
|
*/
|
||||||
|
async loadWorkplaces() {
|
||||||
|
try {
|
||||||
|
const response = await window.apiCall('/workplaces', 'GET');
|
||||||
|
|
||||||
|
let workplaceData = [];
|
||||||
|
if (response && response.success && Array.isArray(response.data)) {
|
||||||
|
workplaceData = response.data;
|
||||||
|
} else if (Array.isArray(response)) {
|
||||||
|
workplaceData = response;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state.workplaces = workplaceData;
|
||||||
|
console.log(`✅ 작업장 ${this.state.workplaces.length}개 로드 완료`);
|
||||||
|
return workplaceData;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('작업장 로딩 오류:', error);
|
||||||
|
this.state.workplaces = [];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업장 저장 (생성/수정)
|
||||||
|
*/
|
||||||
|
async saveWorkplace(workplaceId, workplaceData) {
|
||||||
|
try {
|
||||||
|
let response;
|
||||||
|
if (workplaceId) {
|
||||||
|
response = await window.apiCall(`/workplaces/${workplaceId}`, 'PUT', workplaceData);
|
||||||
|
} else {
|
||||||
|
response = await window.apiCall('/workplaces', 'POST', workplaceData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response && (response.success || response.workplace_id)) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
throw new Error(response?.message || '저장에 실패했습니다.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('작업장 저장 오류:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업장 삭제
|
||||||
|
*/
|
||||||
|
async deleteWorkplace(workplaceId) {
|
||||||
|
try {
|
||||||
|
const response = await window.apiCall(`/workplaces/${workplaceId}`, 'DELETE');
|
||||||
|
if (response && response.success) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
throw new Error(response?.message || '삭제에 실패했습니다.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('작업장 삭제 오류:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리의 지도 영역 로드
|
||||||
|
*/
|
||||||
|
async loadMapRegions(categoryId) {
|
||||||
|
try {
|
||||||
|
const response = await window.apiCall(`/workplaces/categories/${categoryId}/map-regions`, 'GET');
|
||||||
|
|
||||||
|
let regions = [];
|
||||||
|
if (response && response.success && Array.isArray(response.data)) {
|
||||||
|
regions = response.data;
|
||||||
|
} else if (Array.isArray(response)) {
|
||||||
|
regions = response;
|
||||||
|
}
|
||||||
|
return regions;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('지도 영역 로드 오류:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업장의 지도 영역 로드
|
||||||
|
*/
|
||||||
|
async loadWorkplaceMapRegion(workplaceId) {
|
||||||
|
try {
|
||||||
|
const response = await window.apiCall(`/workplaces/map-regions/workplace/${workplaceId}`, 'GET');
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('작업장 지도 영역 로드 오류:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업장의 설비 목록 로드
|
||||||
|
*/
|
||||||
|
async loadWorkplaceEquipments(workplaceId) {
|
||||||
|
try {
|
||||||
|
const response = await window.apiCall(`/equipments/workplace/${workplaceId}`, 'GET');
|
||||||
|
|
||||||
|
let equipments = [];
|
||||||
|
if (response && response.success && Array.isArray(response.data)) {
|
||||||
|
equipments = response.data;
|
||||||
|
} else if (Array.isArray(response)) {
|
||||||
|
equipments = response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 지도 영역이 있는 설비만 workplaceEquipmentRegions에 추가
|
||||||
|
this.state.workplaceEquipmentRegions = equipments
|
||||||
|
.filter(eq => eq.map_x_percent != null && eq.map_y_percent != null)
|
||||||
|
.map(eq => ({
|
||||||
|
equipment_id: eq.equipment_id,
|
||||||
|
equipment_name: eq.equipment_name,
|
||||||
|
equipment_code: eq.equipment_code,
|
||||||
|
x_percent: parseFloat(eq.map_x_percent),
|
||||||
|
y_percent: parseFloat(eq.map_y_percent),
|
||||||
|
width_percent: parseFloat(eq.map_width_percent) || 10,
|
||||||
|
height_percent: parseFloat(eq.map_height_percent) || 10
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.state.existingEquipments = equipments;
|
||||||
|
|
||||||
|
console.log(`✅ 작업장 ${workplaceId}의 설비 ${equipments.length}개 로드 완료`);
|
||||||
|
return equipments;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('설비 로드 오류:', error);
|
||||||
|
this.state.workplaceEquipmentRegions = [];
|
||||||
|
this.state.existingEquipments = [];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 설비 목록 로드
|
||||||
|
*/
|
||||||
|
async loadAllEquipments() {
|
||||||
|
try {
|
||||||
|
const response = await window.apiCall('/equipments', 'GET');
|
||||||
|
|
||||||
|
let equipments = [];
|
||||||
|
if (response && response.success && Array.isArray(response.data)) {
|
||||||
|
equipments = response.data;
|
||||||
|
} else if (Array.isArray(response)) {
|
||||||
|
equipments = response;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state.allEquipments = equipments;
|
||||||
|
console.log(`✅ 전체 설비 ${this.state.allEquipments.length}개 로드 완료`);
|
||||||
|
return equipments;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('전체 설비 로드 오류:', error);
|
||||||
|
this.state.allEquipments = [];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설비 지도 위치 업데이트
|
||||||
|
*/
|
||||||
|
async updateEquipmentMapPosition(equipmentId, positionData) {
|
||||||
|
try {
|
||||||
|
const response = await window.apiCall(`/equipments/${equipmentId}/map-position`, 'PATCH', positionData);
|
||||||
|
if (!response || !response.success) {
|
||||||
|
throw new Error(response?.message || '위치 저장 실패');
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('설비 위치 업데이트 오류:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 새 설비 생성
|
||||||
|
*/
|
||||||
|
async createEquipment(equipmentData) {
|
||||||
|
try {
|
||||||
|
const response = await window.apiCall('/equipments', 'POST', equipmentData);
|
||||||
|
if (!response || !response.success) {
|
||||||
|
throw new Error(response?.message || '설비 생성 실패');
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('설비 생성 오류:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 다음 관리번호 조회
|
||||||
|
*/
|
||||||
|
async getNextEquipmentCode() {
|
||||||
|
try {
|
||||||
|
const response = await window.apiCall('/equipments/next-code', 'GET');
|
||||||
|
if (response && response.success) {
|
||||||
|
return response.data.next_code;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('다음 관리번호 조회 실패:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업장 레이아웃 이미지 업로드
|
||||||
|
*/
|
||||||
|
async uploadWorkplaceLayout(workplaceId, formData) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${this.utils.getApiBaseUrl()}/workplaces/${workplaceId}/layout-image`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('레이아웃 이미지 업로드 오류:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전역 인스턴스 생성
|
||||||
|
window.WorkplaceAPI = new WorkplaceAPI();
|
||||||
|
|
||||||
|
// 하위 호환성: 기존 함수들
|
||||||
|
window.loadCategories = () => window.WorkplaceAPI.loadCategories();
|
||||||
|
window.loadWorkplaces = () => window.WorkplaceAPI.loadWorkplaces();
|
||||||
|
window.loadWorkplaceEquipments = (id) => window.WorkplaceAPI.loadWorkplaceEquipments(id);
|
||||||
|
window.loadAllEquipments = () => window.WorkplaceAPI.loadAllEquipments();
|
||||||
|
|
||||||
|
console.log('[Module] workplace-management/api.js 로드 완료');
|
||||||
553
web-ui/js/workplace-management/index.js
Normal file
553
web-ui/js/workplace-management/index.js
Normal file
@@ -0,0 +1,553 @@
|
|||||||
|
/**
|
||||||
|
* Workplace Management - Module Loader
|
||||||
|
* 작업장 관리 모듈을 초기화하고 연결하는 메인 진입점
|
||||||
|
*
|
||||||
|
* 로드 순서:
|
||||||
|
* 1. state.js - 전역 상태 관리
|
||||||
|
* 2. utils.js - 유틸리티 함수
|
||||||
|
* 3. api.js - API 클라이언트
|
||||||
|
* 4. index.js - 이 파일 (메인 컨트롤러)
|
||||||
|
*/
|
||||||
|
|
||||||
|
class WorkplaceController {
|
||||||
|
constructor() {
|
||||||
|
this.state = window.WorkplaceState;
|
||||||
|
this.api = window.WorkplaceAPI;
|
||||||
|
this.utils = window.WorkplaceUtils;
|
||||||
|
this.initialized = false;
|
||||||
|
|
||||||
|
console.log('[WorkplaceController] 생성');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 초기화
|
||||||
|
*/
|
||||||
|
async init() {
|
||||||
|
if (this.initialized) {
|
||||||
|
console.log('[WorkplaceController] 이미 초기화됨');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🏗️ 작업장 관리 페이지 초기화 시작');
|
||||||
|
|
||||||
|
// API 함수가 로드될 때까지 대기
|
||||||
|
let retryCount = 0;
|
||||||
|
while (!window.apiCall && retryCount < 50) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
retryCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.apiCall) {
|
||||||
|
window.showToast?.('시스템을 초기화할 수 없습니다. 페이지를 새로고침해주세요.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모든 데이터 로드
|
||||||
|
await this.loadAllData();
|
||||||
|
|
||||||
|
this.initialized = true;
|
||||||
|
console.log('[WorkplaceController] 초기화 완료');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모든 데이터 로드
|
||||||
|
*/
|
||||||
|
async loadAllData() {
|
||||||
|
try {
|
||||||
|
await this.api.loadAllData();
|
||||||
|
this.renderCategoryTabs();
|
||||||
|
this.renderWorkplaces();
|
||||||
|
this.updateStatistics();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('데이터 로딩 오류:', error);
|
||||||
|
window.showToast?.('데이터를 불러오는데 실패했습니다.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리 탭 렌더링
|
||||||
|
*/
|
||||||
|
renderCategoryTabs() {
|
||||||
|
const tabsContainer = document.getElementById('categoryTabs');
|
||||||
|
if (!tabsContainer) return;
|
||||||
|
|
||||||
|
const categories = this.state.categories;
|
||||||
|
const workplaces = this.state.workplaces;
|
||||||
|
const currentCategoryId = this.state.currentCategoryId;
|
||||||
|
|
||||||
|
let tabsHtml = `
|
||||||
|
<button class="wp-tab-btn ${currentCategoryId === '' ? 'active' : ''}"
|
||||||
|
data-category=""
|
||||||
|
onclick="switchCategory('')">
|
||||||
|
<span class="wp-tab-icon">🏗️</span>
|
||||||
|
전체
|
||||||
|
<span class="wp-tab-count">${workplaces.length}</span>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
categories.forEach(category => {
|
||||||
|
const count = workplaces.filter(w => w.category_id === category.category_id).length;
|
||||||
|
const isActive = currentCategoryId === category.category_id;
|
||||||
|
|
||||||
|
tabsHtml += `
|
||||||
|
<button class="wp-tab-btn ${isActive ? 'active' : ''}"
|
||||||
|
data-category="${category.category_id}"
|
||||||
|
onclick="switchCategory(${category.category_id})">
|
||||||
|
<span class="wp-tab-icon">🏭</span>
|
||||||
|
${category.category_name}
|
||||||
|
<span class="wp-tab-count">${count}</span>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
tabsContainer.innerHTML = tabsHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리 전환
|
||||||
|
*/
|
||||||
|
async switchCategory(categoryId) {
|
||||||
|
this.state.setCurrentCategory(categoryId);
|
||||||
|
this.renderCategoryTabs();
|
||||||
|
this.renderWorkplaces();
|
||||||
|
|
||||||
|
const layoutMapSection = document.getElementById('layoutMapSection');
|
||||||
|
const selectedCategoryName = document.getElementById('selectedCategoryName');
|
||||||
|
|
||||||
|
if (categoryId && layoutMapSection) {
|
||||||
|
const category = this.state.getCurrentCategory();
|
||||||
|
if (category) {
|
||||||
|
layoutMapSection.style.display = 'block';
|
||||||
|
if (selectedCategoryName) {
|
||||||
|
selectedCategoryName.textContent = category.category_name;
|
||||||
|
}
|
||||||
|
await this.updateLayoutPreview(category);
|
||||||
|
}
|
||||||
|
} else if (layoutMapSection) {
|
||||||
|
layoutMapSection.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 미리보기 업데이트
|
||||||
|
*/
|
||||||
|
async updateLayoutPreview(category) {
|
||||||
|
const previewDiv = document.getElementById('layoutMapPreview');
|
||||||
|
if (!previewDiv) return;
|
||||||
|
|
||||||
|
if (category.layout_image) {
|
||||||
|
const fullImageUrl = this.utils.getFullImageUrl(category.layout_image);
|
||||||
|
|
||||||
|
previewDiv.innerHTML = `
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<canvas id="previewCanvas" style="max-width: 100%; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); cursor: default;"></canvas>
|
||||||
|
<p style="color: #64748b; margin-top: 12px; font-size: 14px;">
|
||||||
|
클릭하여 작업장 영역을 수정하려면 "지도 설정" 버튼을 누르세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
await this.loadImageWithRegions(fullImageUrl, category.category_id);
|
||||||
|
} else {
|
||||||
|
previewDiv.innerHTML = `
|
||||||
|
<div style="padding: 40px;">
|
||||||
|
<span style="font-size: 48px;">🗺️</span>
|
||||||
|
<p style="color: #94a3b8; margin-top: 16px;">
|
||||||
|
이 공장의 레이아웃 이미지가 아직 등록되지 않았습니다
|
||||||
|
</p>
|
||||||
|
<p style="color: #cbd5e1; font-size: 14px; margin-top: 8px;">
|
||||||
|
"지도 설정" 버튼을 눌러 레이아웃 이미지를 업로드하고 작업장 위치를 지정하세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이미지와 영역을 캔버스에 로드
|
||||||
|
*/
|
||||||
|
async loadImageWithRegions(imageUrl, categoryId) {
|
||||||
|
const canvas = document.getElementById('previewCanvas');
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const img = new Image();
|
||||||
|
const self = this;
|
||||||
|
|
||||||
|
img.onload = async function() {
|
||||||
|
const maxWidth = 800;
|
||||||
|
const scale = img.width > maxWidth ? maxWidth / img.width : 1;
|
||||||
|
|
||||||
|
canvas.width = img.width * scale;
|
||||||
|
canvas.height = img.height * scale;
|
||||||
|
|
||||||
|
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const regions = await self.api.loadMapRegions(categoryId);
|
||||||
|
|
||||||
|
regions.forEach(region => {
|
||||||
|
const x1 = (region.x_start / 100) * canvas.width;
|
||||||
|
const y1 = (region.y_start / 100) * canvas.height;
|
||||||
|
const x2 = (region.x_end / 100) * canvas.width;
|
||||||
|
const y2 = (region.y_end / 100) * canvas.height;
|
||||||
|
|
||||||
|
const width = x2 - x1;
|
||||||
|
const height = y2 - y1;
|
||||||
|
|
||||||
|
ctx.strokeStyle = '#10b981';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.strokeRect(x1, y1, width, height);
|
||||||
|
|
||||||
|
ctx.fillStyle = 'rgba(16, 185, 129, 0.15)';
|
||||||
|
ctx.fillRect(x1, y1, width, height);
|
||||||
|
|
||||||
|
if (region.workplace_name) {
|
||||||
|
ctx.font = 'bold 14px sans-serif';
|
||||||
|
const textMetrics = ctx.measureText(region.workplace_name);
|
||||||
|
const textPadding = 4;
|
||||||
|
|
||||||
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
|
||||||
|
ctx.fillRect(x1 + 5, y1 + 5, textMetrics.width + textPadding * 2, 20);
|
||||||
|
|
||||||
|
ctx.fillStyle = '#10b981';
|
||||||
|
ctx.fillText(region.workplace_name, x1 + 5 + textPadding, y1 + 20);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (regions.length > 0) {
|
||||||
|
console.log(`✅ 레이아웃 미리보기에 ${regions.length}개 영역 표시 완료`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('영역 로드 오류:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onerror = function() {
|
||||||
|
console.error('이미지 로드 실패:', imageUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
img.src = imageUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업장 렌더링
|
||||||
|
*/
|
||||||
|
renderWorkplaces() {
|
||||||
|
const grid = document.getElementById('workplaceGrid');
|
||||||
|
if (!grid) return;
|
||||||
|
|
||||||
|
const filtered = this.state.getFilteredWorkplaces();
|
||||||
|
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
grid.innerHTML = `
|
||||||
|
<div class="wp-empty-state">
|
||||||
|
<div class="wp-empty-icon">🏗️</div>
|
||||||
|
<h3 class="wp-empty-title">등록된 작업장이 없습니다</h3>
|
||||||
|
<p class="wp-empty-description">"작업장 추가" 버튼을 눌러 작업장을 등록해보세요</p>
|
||||||
|
<button class="wp-btn wp-btn-primary" onclick="openWorkplaceModal()">
|
||||||
|
<span class="wp-btn-icon">➕</span>
|
||||||
|
첫 작업장 추가하기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let gridHtml = '';
|
||||||
|
|
||||||
|
filtered.forEach(workplace => {
|
||||||
|
const categoryName = workplace.category_name || '미분류';
|
||||||
|
const isActive = workplace.is_active === 1 || workplace.is_active === true;
|
||||||
|
const purposeIcon = this.utils.getPurposeIcon(workplace.workplace_purpose);
|
||||||
|
|
||||||
|
gridHtml += `
|
||||||
|
<div class="wp-card ${isActive ? '' : 'inactive'}" onclick="editWorkplace(${workplace.workplace_id})">
|
||||||
|
<div class="wp-card-header">
|
||||||
|
<div class="wp-card-icon">${purposeIcon}</div>
|
||||||
|
<div class="wp-card-info">
|
||||||
|
<h3 class="wp-card-title">${workplace.workplace_name}</h3>
|
||||||
|
<div class="wp-card-tags">
|
||||||
|
${workplace.category_id ? `<span class="wp-card-tag factory">🏭 ${categoryName}</span>` : ''}
|
||||||
|
${workplace.workplace_purpose ? `<span class="wp-card-tag purpose">${workplace.workplace_purpose}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="wp-card-actions">
|
||||||
|
<button class="wp-card-btn map" onclick="event.stopPropagation(); openWorkplaceMapModal(${workplace.workplace_id})" title="지도 관리">
|
||||||
|
🗺️
|
||||||
|
</button>
|
||||||
|
<button class="wp-card-btn edit" onclick="event.stopPropagation(); editWorkplace(${workplace.workplace_id})" title="수정">
|
||||||
|
✏️
|
||||||
|
</button>
|
||||||
|
<button class="wp-card-btn delete" onclick="event.stopPropagation(); confirmDeleteWorkplace(${workplace.workplace_id})" title="삭제">
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${workplace.description ? `<p class="wp-card-description">${workplace.description}</p>` : ''}
|
||||||
|
<div class="wp-card-map" id="workplace-map-${workplace.workplace_id}"></div>
|
||||||
|
<div class="wp-card-meta">
|
||||||
|
<span class="wp-card-date">등록: ${this.utils.formatDate(workplace.created_at)}</span>
|
||||||
|
${workplace.updated_at !== workplace.created_at ? `<span class="wp-card-date">수정: ${this.utils.formatDate(workplace.updated_at)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
grid.innerHTML = gridHtml;
|
||||||
|
|
||||||
|
filtered.forEach(workplace => {
|
||||||
|
if (workplace.category_id) {
|
||||||
|
this.loadWorkplaceMapThumbnail(workplace);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업장 카드에 지도 썸네일 로드
|
||||||
|
*/
|
||||||
|
async loadWorkplaceMapThumbnail(workplace) {
|
||||||
|
const thumbnailDiv = document.getElementById(`workplace-map-${workplace.workplace_id}`);
|
||||||
|
if (!thumbnailDiv) return;
|
||||||
|
|
||||||
|
if (workplace.layout_image) {
|
||||||
|
const fullImageUrl = this.utils.getFullImageUrl(workplace.layout_image);
|
||||||
|
|
||||||
|
let equipmentCount = 0;
|
||||||
|
try {
|
||||||
|
const eqResponse = await window.apiCall(`/equipments/workplace/${workplace.workplace_id}`, 'GET');
|
||||||
|
if (eqResponse && eqResponse.success && Array.isArray(eqResponse.data)) {
|
||||||
|
equipmentCount = eqResponse.data.filter(eq => eq.map_x_percent != null).length;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.debug('설비 정보 로드 실패');
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvasId = `layout-canvas-${workplace.workplace_id}`;
|
||||||
|
thumbnailDiv.innerHTML = `
|
||||||
|
<div style="text-align: center; padding: 10px; background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%); border-radius: 8px; border: 1px solid #bae6fd; cursor: pointer;" onclick="openWorkplaceMapModal(${workplace.workplace_id})">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||||
|
<span style="font-size: 12px; color: #0369a1; font-weight: 600;">📍 작업장 지도</span>
|
||||||
|
${equipmentCount > 0 ? `<span style="font-size: 11px; background: #10b981; color: white; padding: 2px 8px; border-radius: 10px;">설비 ${equipmentCount}개</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<canvas id="${canvasId}" style="max-width: 100%; border-radius: 6px; box-shadow: 0 2px 8px rgba(0,0,0,0.12);"></canvas>
|
||||||
|
<div style="font-size: 11px; color: #64748b; margin-top: 8px;">클릭하여 지도 관리</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
await this.loadWorkplaceCanvasWithEquipments(workplace.workplace_id, fullImageUrl, canvasId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.api.loadWorkplaceMapRegion(workplace.workplace_id);
|
||||||
|
|
||||||
|
if (!response || (!response.success && !response.region_id)) {
|
||||||
|
thumbnailDiv.innerHTML = `
|
||||||
|
<div style="text-align: center; padding: 16px; background: #f9fafb; border-radius: 8px; border: 2px dashed #cbd5e1; cursor: pointer;" onclick="openWorkplaceMapModal(${workplace.workplace_id})">
|
||||||
|
<div style="font-size: 24px; margin-bottom: 8px;">🗺️</div>
|
||||||
|
<div style="font-size: 12px; color: #64748b;">클릭하여 지도 설정</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const region = response.success ? response.data : response;
|
||||||
|
|
||||||
|
if (!region || region.x_start === undefined || region.y_start === undefined ||
|
||||||
|
region.x_end === undefined || region.y_end === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = this.state.categories.find(c => c.category_id === workplace.category_id);
|
||||||
|
if (!category || !category.layout_image) return;
|
||||||
|
|
||||||
|
const fullImageUrl = this.utils.getFullImageUrl(category.layout_image);
|
||||||
|
|
||||||
|
const canvasId = `thumbnail-canvas-${workplace.workplace_id}`;
|
||||||
|
thumbnailDiv.innerHTML = `
|
||||||
|
<div style="text-align: center; padding: 10px; background: #f9fafb; border-radius: 8px; border: 1px solid #e5e7eb; cursor: pointer;" onclick="openWorkplaceMapModal(${workplace.workplace_id})">
|
||||||
|
<div style="font-size: 12px; color: #64748b; margin-bottom: 6px; font-weight: 500;">📍 공장 지도 내 위치</div>
|
||||||
|
<canvas id="${canvasId}" style="max-width: 100%; border-radius: 6px; box-shadow: 0 2px 6px rgba(0,0,0,0.1);"></canvas>
|
||||||
|
<div style="font-size: 11px; color: #94a3b8; margin-top: 6px;">클릭하여 상세 지도 설정</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = function() {
|
||||||
|
const canvas = document.getElementById(canvasId);
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
const x1 = (region.x_start / 100) * img.width;
|
||||||
|
const y1 = (region.y_start / 100) * img.height;
|
||||||
|
const x2 = (region.x_end / 100) * img.width;
|
||||||
|
const y2 = (region.y_end / 100) * img.height;
|
||||||
|
|
||||||
|
const regionWidth = x2 - x1;
|
||||||
|
const regionHeight = y2 - y1;
|
||||||
|
|
||||||
|
const maxThumbWidth = 350;
|
||||||
|
const scale = regionWidth > maxThumbWidth ? maxThumbWidth / regionWidth : 1;
|
||||||
|
|
||||||
|
canvas.width = regionWidth * scale;
|
||||||
|
canvas.height = regionHeight * scale;
|
||||||
|
|
||||||
|
ctx.drawImage(
|
||||||
|
img,
|
||||||
|
x1, y1, regionWidth, regionHeight,
|
||||||
|
0, 0, canvas.width, canvas.height
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.strokeStyle = '#10b981';
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.strokeRect(0, 0, canvas.width, canvas.height);
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onerror = function() {
|
||||||
|
thumbnailDiv.innerHTML = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
img.src = fullImageUrl;
|
||||||
|
} catch (error) {
|
||||||
|
console.debug(`작업장 ${workplace.workplace_id}의 지도 영역 없음`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업장 캔버스에 설비 영역 함께 그리기
|
||||||
|
*/
|
||||||
|
async loadWorkplaceCanvasWithEquipments(workplaceId, imageUrl, canvasId) {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = async function() {
|
||||||
|
const canvas = document.getElementById(canvasId);
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
const maxThumbWidth = 400;
|
||||||
|
const scale = img.width > maxThumbWidth ? maxThumbWidth / img.width : 1;
|
||||||
|
|
||||||
|
canvas.width = img.width * scale;
|
||||||
|
canvas.height = img.height * scale;
|
||||||
|
|
||||||
|
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await window.apiCall(`/equipments/workplace/${workplaceId}`, 'GET');
|
||||||
|
let equipments = [];
|
||||||
|
if (response && response.success && Array.isArray(response.data)) {
|
||||||
|
equipments = response.data.filter(eq => eq.map_x_percent != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
equipments.forEach(eq => {
|
||||||
|
const x = (parseFloat(eq.map_x_percent) / 100) * canvas.width;
|
||||||
|
const y = (parseFloat(eq.map_y_percent) / 100) * canvas.height;
|
||||||
|
const width = (parseFloat(eq.map_width_percent || 10) / 100) * canvas.width;
|
||||||
|
const height = (parseFloat(eq.map_height_percent || 10) / 100) * canvas.height;
|
||||||
|
|
||||||
|
ctx.fillStyle = 'rgba(16, 185, 129, 0.2)';
|
||||||
|
ctx.fillRect(x, y, width, height);
|
||||||
|
|
||||||
|
ctx.strokeStyle = '#10b981';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.strokeRect(x, y, width, height);
|
||||||
|
|
||||||
|
if (eq.equipment_code) {
|
||||||
|
ctx.font = 'bold 10px sans-serif';
|
||||||
|
const textMetrics = ctx.measureText(eq.equipment_code);
|
||||||
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
|
||||||
|
ctx.fillRect(x + 2, y + 2, textMetrics.width + 6, 14);
|
||||||
|
ctx.fillStyle = '#047857';
|
||||||
|
ctx.fillText(eq.equipment_code, x + 5, y + 12);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.debug('설비 영역 로드 실패');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
img.src = imageUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 통계 업데이트
|
||||||
|
*/
|
||||||
|
async updateStatistics() {
|
||||||
|
const stats = this.state.getStatistics();
|
||||||
|
|
||||||
|
const factoryCountEl = document.getElementById('factoryCount');
|
||||||
|
const totalCountEl = document.getElementById('totalCount');
|
||||||
|
const activeCountEl = document.getElementById('activeCount');
|
||||||
|
const equipmentCountEl = document.getElementById('equipmentCount');
|
||||||
|
|
||||||
|
if (factoryCountEl) factoryCountEl.textContent = stats.factoryTotal;
|
||||||
|
if (totalCountEl) totalCountEl.textContent = stats.total;
|
||||||
|
if (activeCountEl) activeCountEl.textContent = stats.active;
|
||||||
|
|
||||||
|
if (equipmentCountEl) {
|
||||||
|
try {
|
||||||
|
const equipments = await this.api.loadAllEquipments();
|
||||||
|
equipmentCountEl.textContent = equipments.length;
|
||||||
|
} catch (e) {
|
||||||
|
equipmentCountEl.textContent = '-';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionTotalEl = document.getElementById('sectionTotalCount');
|
||||||
|
const sectionActiveEl = document.getElementById('sectionActiveCount');
|
||||||
|
|
||||||
|
if (sectionTotalEl) sectionTotalEl.textContent = stats.filteredTotal;
|
||||||
|
if (sectionActiveEl) sectionActiveEl.textContent = stats.filteredActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 새로고침
|
||||||
|
*/
|
||||||
|
async refreshWorkplaces() {
|
||||||
|
const refreshBtn = document.querySelector('.btn-secondary');
|
||||||
|
if (refreshBtn) {
|
||||||
|
const originalText = refreshBtn.innerHTML;
|
||||||
|
refreshBtn.innerHTML = '<span class="btn-icon">⏳</span>새로고침 중...';
|
||||||
|
refreshBtn.disabled = true;
|
||||||
|
|
||||||
|
await this.loadAllData();
|
||||||
|
|
||||||
|
refreshBtn.innerHTML = originalText;
|
||||||
|
refreshBtn.disabled = false;
|
||||||
|
} else {
|
||||||
|
await this.loadAllData();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.showToast?.('데이터가 새로고침되었습니다.', 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 디버그
|
||||||
|
*/
|
||||||
|
debug() {
|
||||||
|
console.log('[WorkplaceController] 상태 디버그:');
|
||||||
|
this.state.debug();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전역 인스턴스 생성
|
||||||
|
window.WorkplaceController = new WorkplaceController();
|
||||||
|
|
||||||
|
// 하위 호환성: 기존 전역 함수들
|
||||||
|
window.switchCategory = (categoryId) => window.WorkplaceController.switchCategory(categoryId);
|
||||||
|
window.renderCategoryTabs = () => window.WorkplaceController.renderCategoryTabs();
|
||||||
|
window.renderWorkplaces = () => window.WorkplaceController.renderWorkplaces();
|
||||||
|
window.updateStatistics = () => window.WorkplaceController.updateStatistics();
|
||||||
|
window.refreshWorkplaces = () => window.WorkplaceController.refreshWorkplaces();
|
||||||
|
window.loadAllData = () => window.WorkplaceController.loadAllData();
|
||||||
|
window.updateLayoutPreview = (category) => window.WorkplaceController.updateLayoutPreview(category);
|
||||||
|
|
||||||
|
// DOMContentLoaded 이벤트에서 초기화
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
window.WorkplaceController.init();
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[Module] workplace-management/index.js 로드 완료');
|
||||||
284
web-ui/js/workplace-management/state.js
Normal file
284
web-ui/js/workplace-management/state.js
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
/**
|
||||||
|
* Workplace Management - State Manager
|
||||||
|
* 작업장 관리 페이지의 전역 상태 관리
|
||||||
|
*/
|
||||||
|
|
||||||
|
class WorkplaceState {
|
||||||
|
constructor() {
|
||||||
|
// 마스터 데이터
|
||||||
|
this.categories = [];
|
||||||
|
this.workplaces = [];
|
||||||
|
this.allEquipments = [];
|
||||||
|
this.existingEquipments = [];
|
||||||
|
|
||||||
|
// 현재 상태
|
||||||
|
this.currentCategoryId = '';
|
||||||
|
this.currentEditingCategory = null;
|
||||||
|
this.currentEditingWorkplace = null;
|
||||||
|
this.currentWorkplaceMapId = null;
|
||||||
|
|
||||||
|
// 작업장 지도 관련
|
||||||
|
this.workplaceCanvas = null;
|
||||||
|
this.workplaceCtx = null;
|
||||||
|
this.workplaceImage = null;
|
||||||
|
this.workplaceIsDrawing = false;
|
||||||
|
this.workplaceStartX = 0;
|
||||||
|
this.workplaceStartY = 0;
|
||||||
|
this.workplaceCurrentRect = null;
|
||||||
|
this.workplaceEquipmentRegions = [];
|
||||||
|
|
||||||
|
// 전체화면 편집기 관련
|
||||||
|
this.fsCanvas = null;
|
||||||
|
this.fsCtx = null;
|
||||||
|
this.fsImage = null;
|
||||||
|
this.fsIsDrawing = false;
|
||||||
|
this.fsStartX = 0;
|
||||||
|
this.fsStartY = 0;
|
||||||
|
this.fsCurrentRect = null;
|
||||||
|
this.fsSidebarVisible = true;
|
||||||
|
|
||||||
|
// 리스너
|
||||||
|
this.listeners = new Map();
|
||||||
|
|
||||||
|
console.log('[WorkplaceState] 초기화 완료');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상태 업데이트
|
||||||
|
*/
|
||||||
|
update(key, value) {
|
||||||
|
const prevValue = this[key];
|
||||||
|
this[key] = value;
|
||||||
|
this.notifyListeners(key, value, prevValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 리스너 등록
|
||||||
|
*/
|
||||||
|
subscribe(key, callback) {
|
||||||
|
if (!this.listeners.has(key)) {
|
||||||
|
this.listeners.set(key, []);
|
||||||
|
}
|
||||||
|
this.listeners.get(key).push(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 리스너 알림
|
||||||
|
*/
|
||||||
|
notifyListeners(key, newValue, prevValue) {
|
||||||
|
const keyListeners = this.listeners.get(key) || [];
|
||||||
|
keyListeners.forEach(callback => {
|
||||||
|
try {
|
||||||
|
callback(newValue, prevValue);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[WorkplaceState] 리스너 오류 (${key}):`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 카테고리 변경
|
||||||
|
*/
|
||||||
|
setCurrentCategory(categoryId) {
|
||||||
|
const prevId = this.currentCategoryId;
|
||||||
|
this.currentCategoryId = categoryId === '' ? '' : categoryId;
|
||||||
|
this.notifyListeners('currentCategoryId', this.currentCategoryId, prevId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 카테고리 정보 가져오기
|
||||||
|
*/
|
||||||
|
getCurrentCategory() {
|
||||||
|
if (!this.currentCategoryId) return null;
|
||||||
|
return this.categories.find(c => c.category_id == this.currentCategoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 카테고리별 작업장 가져오기
|
||||||
|
*/
|
||||||
|
getFilteredWorkplaces() {
|
||||||
|
if (this.currentCategoryId === '') {
|
||||||
|
return this.workplaces;
|
||||||
|
}
|
||||||
|
return this.workplaces.filter(w => w.category_id == this.currentCategoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업장 지도 상태 초기화
|
||||||
|
*/
|
||||||
|
resetWorkplaceMapState() {
|
||||||
|
this.workplaceCanvas = null;
|
||||||
|
this.workplaceCtx = null;
|
||||||
|
this.workplaceImage = null;
|
||||||
|
this.workplaceIsDrawing = false;
|
||||||
|
this.workplaceCurrentRect = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체화면 편집기 상태 초기화
|
||||||
|
*/
|
||||||
|
resetFullscreenState() {
|
||||||
|
this.fsCanvas = null;
|
||||||
|
this.fsCtx = null;
|
||||||
|
this.fsImage = null;
|
||||||
|
this.fsIsDrawing = false;
|
||||||
|
this.fsCurrentRect = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 통계 계산
|
||||||
|
*/
|
||||||
|
getStatistics() {
|
||||||
|
const total = this.workplaces.length;
|
||||||
|
const active = this.workplaces.filter(w =>
|
||||||
|
w.is_active === 1 || w.is_active === true
|
||||||
|
).length;
|
||||||
|
const factoryTotal = this.categories.length;
|
||||||
|
|
||||||
|
const filtered = this.getFilteredWorkplaces();
|
||||||
|
const filteredActive = filtered.filter(w =>
|
||||||
|
w.is_active === 1 || w.is_active === true
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
active,
|
||||||
|
factoryTotal,
|
||||||
|
filteredTotal: filtered.length,
|
||||||
|
filteredActive
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상태 초기화
|
||||||
|
*/
|
||||||
|
reset() {
|
||||||
|
this.currentEditingCategory = null;
|
||||||
|
this.currentEditingWorkplace = null;
|
||||||
|
this.currentWorkplaceMapId = null;
|
||||||
|
this.resetWorkplaceMapState();
|
||||||
|
this.resetFullscreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 디버그 출력
|
||||||
|
*/
|
||||||
|
debug() {
|
||||||
|
console.log('[WorkplaceState] 현재 상태:', {
|
||||||
|
categories: this.categories.length,
|
||||||
|
workplaces: this.workplaces.length,
|
||||||
|
currentCategoryId: this.currentCategoryId,
|
||||||
|
allEquipments: this.allEquipments.length,
|
||||||
|
workplaceEquipmentRegions: this.workplaceEquipmentRegions.length
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전역 인스턴스 생성
|
||||||
|
window.WorkplaceState = new WorkplaceState();
|
||||||
|
|
||||||
|
// 하위 호환성을 위한 전역 변수 프록시
|
||||||
|
const wpStateProxy = window.WorkplaceState;
|
||||||
|
|
||||||
|
Object.defineProperties(window, {
|
||||||
|
categories: {
|
||||||
|
get: () => wpStateProxy.categories,
|
||||||
|
set: (v) => { wpStateProxy.categories = v; }
|
||||||
|
},
|
||||||
|
workplaces: {
|
||||||
|
get: () => wpStateProxy.workplaces,
|
||||||
|
set: (v) => { wpStateProxy.workplaces = v; }
|
||||||
|
},
|
||||||
|
currentCategoryId: {
|
||||||
|
get: () => wpStateProxy.currentCategoryId,
|
||||||
|
set: (v) => { wpStateProxy.currentCategoryId = v; }
|
||||||
|
},
|
||||||
|
currentEditingCategory: {
|
||||||
|
get: () => wpStateProxy.currentEditingCategory,
|
||||||
|
set: (v) => { wpStateProxy.currentEditingCategory = v; }
|
||||||
|
},
|
||||||
|
currentEditingWorkplace: {
|
||||||
|
get: () => wpStateProxy.currentEditingWorkplace,
|
||||||
|
set: (v) => { wpStateProxy.currentEditingWorkplace = v; }
|
||||||
|
},
|
||||||
|
workplaceCanvas: {
|
||||||
|
get: () => wpStateProxy.workplaceCanvas,
|
||||||
|
set: (v) => { wpStateProxy.workplaceCanvas = v; }
|
||||||
|
},
|
||||||
|
workplaceCtx: {
|
||||||
|
get: () => wpStateProxy.workplaceCtx,
|
||||||
|
set: (v) => { wpStateProxy.workplaceCtx = v; }
|
||||||
|
},
|
||||||
|
workplaceImage: {
|
||||||
|
get: () => wpStateProxy.workplaceImage,
|
||||||
|
set: (v) => { wpStateProxy.workplaceImage = v; }
|
||||||
|
},
|
||||||
|
workplaceIsDrawing: {
|
||||||
|
get: () => wpStateProxy.workplaceIsDrawing,
|
||||||
|
set: (v) => { wpStateProxy.workplaceIsDrawing = v; }
|
||||||
|
},
|
||||||
|
workplaceStartX: {
|
||||||
|
get: () => wpStateProxy.workplaceStartX,
|
||||||
|
set: (v) => { wpStateProxy.workplaceStartX = v; }
|
||||||
|
},
|
||||||
|
workplaceStartY: {
|
||||||
|
get: () => wpStateProxy.workplaceStartY,
|
||||||
|
set: (v) => { wpStateProxy.workplaceStartY = v; }
|
||||||
|
},
|
||||||
|
workplaceCurrentRect: {
|
||||||
|
get: () => wpStateProxy.workplaceCurrentRect,
|
||||||
|
set: (v) => { wpStateProxy.workplaceCurrentRect = v; }
|
||||||
|
},
|
||||||
|
workplaceEquipmentRegions: {
|
||||||
|
get: () => wpStateProxy.workplaceEquipmentRegions,
|
||||||
|
set: (v) => { wpStateProxy.workplaceEquipmentRegions = v; }
|
||||||
|
},
|
||||||
|
existingEquipments: {
|
||||||
|
get: () => wpStateProxy.existingEquipments,
|
||||||
|
set: (v) => { wpStateProxy.existingEquipments = v; }
|
||||||
|
},
|
||||||
|
allEquipments: {
|
||||||
|
get: () => wpStateProxy.allEquipments,
|
||||||
|
set: (v) => { wpStateProxy.allEquipments = v; }
|
||||||
|
},
|
||||||
|
fsCanvas: {
|
||||||
|
get: () => wpStateProxy.fsCanvas,
|
||||||
|
set: (v) => { wpStateProxy.fsCanvas = v; }
|
||||||
|
},
|
||||||
|
fsCtx: {
|
||||||
|
get: () => wpStateProxy.fsCtx,
|
||||||
|
set: (v) => { wpStateProxy.fsCtx = v; }
|
||||||
|
},
|
||||||
|
fsImage: {
|
||||||
|
get: () => wpStateProxy.fsImage,
|
||||||
|
set: (v) => { wpStateProxy.fsImage = v; }
|
||||||
|
},
|
||||||
|
fsIsDrawing: {
|
||||||
|
get: () => wpStateProxy.fsIsDrawing,
|
||||||
|
set: (v) => { wpStateProxy.fsIsDrawing = v; }
|
||||||
|
},
|
||||||
|
fsStartX: {
|
||||||
|
get: () => wpStateProxy.fsStartX,
|
||||||
|
set: (v) => { wpStateProxy.fsStartX = v; }
|
||||||
|
},
|
||||||
|
fsStartY: {
|
||||||
|
get: () => wpStateProxy.fsStartY,
|
||||||
|
set: (v) => { wpStateProxy.fsStartY = v; }
|
||||||
|
},
|
||||||
|
fsCurrentRect: {
|
||||||
|
get: () => wpStateProxy.fsCurrentRect,
|
||||||
|
set: (v) => { wpStateProxy.fsCurrentRect = v; }
|
||||||
|
},
|
||||||
|
fsSidebarVisible: {
|
||||||
|
get: () => wpStateProxy.fsSidebarVisible,
|
||||||
|
set: (v) => { wpStateProxy.fsSidebarVisible = v; }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// currentWorkplaceMapId를 window에도 설정
|
||||||
|
Object.defineProperty(window, 'currentWorkplaceMapId', {
|
||||||
|
get: () => wpStateProxy.currentWorkplaceMapId,
|
||||||
|
set: (v) => { wpStateProxy.currentWorkplaceMapId = v; }
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[Module] workplace-management/state.js 로드 완료');
|
||||||
154
web-ui/js/workplace-management/utils.js
Normal file
154
web-ui/js/workplace-management/utils.js
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* Workplace Management - Utilities
|
||||||
|
* 작업장 관리 관련 유틸리티 함수들
|
||||||
|
*/
|
||||||
|
|
||||||
|
class WorkplaceUtils {
|
||||||
|
constructor() {
|
||||||
|
console.log('[WorkplaceUtils] 초기화 완료');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날짜 포맷팅
|
||||||
|
*/
|
||||||
|
formatDate(dateString) {
|
||||||
|
if (!dateString) return '';
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('ko-KR', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API URL 생성
|
||||||
|
*/
|
||||||
|
getApiBaseUrl() {
|
||||||
|
return window.API_BASE_URL || 'http://localhost:20005/api';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이미지 URL 생성
|
||||||
|
*/
|
||||||
|
getFullImageUrl(imagePath) {
|
||||||
|
if (!imagePath) return null;
|
||||||
|
if (imagePath.startsWith('http')) return imagePath;
|
||||||
|
return `${this.getApiBaseUrl()}${imagePath}`.replace('/api/', '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업장 용도 아이콘 반환
|
||||||
|
*/
|
||||||
|
getPurposeIcon(purpose) {
|
||||||
|
const icons = {
|
||||||
|
'작업구역': '🔧',
|
||||||
|
'설비': '⚙️',
|
||||||
|
'휴게시설': '☕',
|
||||||
|
'회의실': '💼',
|
||||||
|
'창고': '📦',
|
||||||
|
'기타': '📍'
|
||||||
|
};
|
||||||
|
return purpose ? (icons[purpose] || '📍') : '🏗️';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 퍼센트를 픽셀로 변환
|
||||||
|
*/
|
||||||
|
percentToPixel(percent, canvasSize) {
|
||||||
|
return (percent / 100) * canvasSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 픽셀을 퍼센트로 변환
|
||||||
|
*/
|
||||||
|
pixelToPercent(pixel, canvasSize) {
|
||||||
|
return (pixel / canvasSize) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 영역 좌표 정규화 (음수 처리)
|
||||||
|
*/
|
||||||
|
normalizeRect(rect, canvasWidth, canvasHeight) {
|
||||||
|
const xPercent = this.pixelToPercent(
|
||||||
|
Math.min(rect.x, rect.x + rect.width),
|
||||||
|
canvasWidth
|
||||||
|
);
|
||||||
|
const yPercent = this.pixelToPercent(
|
||||||
|
Math.min(rect.y, rect.y + rect.height),
|
||||||
|
canvasHeight
|
||||||
|
);
|
||||||
|
const widthPercent = this.pixelToPercent(
|
||||||
|
Math.abs(rect.width),
|
||||||
|
canvasWidth
|
||||||
|
);
|
||||||
|
const heightPercent = this.pixelToPercent(
|
||||||
|
Math.abs(rect.height),
|
||||||
|
canvasHeight
|
||||||
|
);
|
||||||
|
|
||||||
|
return { xPercent, yPercent, widthPercent, heightPercent };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전역 인스턴스 생성
|
||||||
|
window.WorkplaceUtils = new WorkplaceUtils();
|
||||||
|
|
||||||
|
// 하위 호환성: 기존 함수들
|
||||||
|
window.formatDate = (dateString) => window.WorkplaceUtils.formatDate(dateString);
|
||||||
|
|
||||||
|
// 토스트 메시지 표시
|
||||||
|
window.showToast = function(message, type = 'info') {
|
||||||
|
// 기존 토스트 제거
|
||||||
|
const existingToast = document.querySelector('.toast');
|
||||||
|
if (existingToast) {
|
||||||
|
existingToast.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 새 토스트 생성
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = `toast toast-${type}`;
|
||||||
|
toast.textContent = message;
|
||||||
|
|
||||||
|
// 스타일 적용
|
||||||
|
Object.assign(toast.style, {
|
||||||
|
position: 'fixed',
|
||||||
|
top: '20px',
|
||||||
|
right: '20px',
|
||||||
|
padding: '12px 24px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: 'white',
|
||||||
|
fontWeight: '500',
|
||||||
|
zIndex: '1000',
|
||||||
|
transform: 'translateX(100%)',
|
||||||
|
transition: 'transform 0.3s ease'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 타입별 배경색
|
||||||
|
const colors = {
|
||||||
|
success: '#10b981',
|
||||||
|
error: '#ef4444',
|
||||||
|
warning: '#f59e0b',
|
||||||
|
info: '#3b82f6'
|
||||||
|
};
|
||||||
|
toast.style.backgroundColor = colors[type] || colors.info;
|
||||||
|
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
|
||||||
|
// 애니메이션
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.style.transform = 'translateX(0)';
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
// 자동 제거
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.style.transform = 'translateX(100%)';
|
||||||
|
setTimeout(() => {
|
||||||
|
if (toast.parentNode) {
|
||||||
|
toast.remove();
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[Module] workplace-management/utils.js 로드 완료');
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
<!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="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>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<!-- 네비게이션 바 -->
|
|
||||||
<div id="navbar-container"></div>
|
|
||||||
|
|
||||||
<!-- 메인 레이아웃 -->
|
|
||||||
<div class="page-container">
|
|
||||||
<!-- 메인 콘텐츠 -->
|
|
||||||
<main class="main-content">
|
|
||||||
<div class="dashboard-main">
|
|
||||||
<div class="page-header">
|
|
||||||
<div class="page-title-section">
|
|
||||||
<h1 class="page-title">코드 관리</h1>
|
|
||||||
<p class="page-description">작업 상태, 작업 유형 등 시스템에서 사용하는 코드를 관리합니다</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="page-actions">
|
|
||||||
<button class="btn btn-secondary" onclick="refreshAllCodes()">전체 새로고침</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 코드 유형 탭 -->
|
|
||||||
<div class="code-tabs">
|
|
||||||
<button class="tab-btn active" data-tab="work-status" onclick="switchCodeTab('work-status')">작업 상태 유형</button>
|
|
||||||
<button class="tab-btn" data-tab="work-types" onclick="switchCodeTab('work-types')">작업 유형</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 작업 상태 유형 관리 -->
|
|
||||||
<div id="work-status-tab" class="code-tab-content active">
|
|
||||||
<div class="code-section">
|
|
||||||
<div class="section-header">
|
|
||||||
<h2 class="section-title">작업 상태 유형 관리</h2>
|
|
||||||
<div class="section-actions">
|
|
||||||
<button class="btn btn-primary" onclick="openCodeModal('work-status')">새 상태 추가</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="code-stats">
|
|
||||||
<span class="stat-item">총 <span id="workStatusCount">0</span>개</span>
|
|
||||||
<span class="stat-item">정상 <span id="normalStatusCount">0</span>개</span>
|
|
||||||
<span class="stat-item">오류 <span id="errorStatusCount">0</span>개</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="code-grid" id="workStatusGrid">
|
|
||||||
<!-- 작업 상태 유형 카드들이 여기에 동적으로 생성됩니다 -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 작업 유형 관리 -->
|
|
||||||
<div id="work-types-tab" class="code-tab-content">
|
|
||||||
<div class="code-section">
|
|
||||||
<div class="section-header">
|
|
||||||
<h2 class="section-title">작업 유형 관리</h2>
|
|
||||||
<div class="section-actions">
|
|
||||||
<button class="btn btn-primary" onclick="openCodeModal('work-types')">새 작업 유형 추가</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="code-stats">
|
|
||||||
<span class="stat-item">총 <span id="workTypesCount">0</span>개</span>
|
|
||||||
<span class="stat-item">카테고리 <span id="workCategoriesCount">0</span>개</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="code-grid" id="workTypesGrid">
|
|
||||||
<!-- 작업 유형 카드들이 여기에 동적으로 생성됩니다 -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<!-- 코드 추가/수정 모달 -->
|
|
||||||
<div id="codeModal" class="modal-overlay" style="display: none;">
|
|
||||||
<div class="modal-container">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h2 id="modalTitle">코드 추가</h2>
|
|
||||||
<button class="modal-close-btn" onclick="closeCodeModal()">×</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-body">
|
|
||||||
<form id="codeForm" onsubmit="event.preventDefault(); saveCode();">
|
|
||||||
<input type="hidden" id="codeId">
|
|
||||||
<input type="hidden" id="codeType">
|
|
||||||
|
|
||||||
<!-- 공통 필드 -->
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">이름 *</label>
|
|
||||||
<input type="text" id="codeName" class="form-control" placeholder="코드명을 입력하세요" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">설명</label>
|
|
||||||
<textarea id="codeDescription" class="form-control" rows="3" placeholder="상세 설명을 입력하세요"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 작업 상태 유형 전용 필드 -->
|
|
||||||
<div class="form-group" id="isErrorGroup" style="display: none;">
|
|
||||||
<label class="form-label">
|
|
||||||
<input type="checkbox" id="isError" class="form-checkbox">
|
|
||||||
오류 상태로 분류
|
|
||||||
</label>
|
|
||||||
<small class="form-help">체크하면 이 상태는 오류로 간주됩니다</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 작업 유형 전용 필드 -->
|
|
||||||
<div class="form-group" id="categoryGroup" style="display: none;">
|
|
||||||
<label class="form-label">카테고리</label>
|
|
||||||
<input type="text" id="category" class="form-control" placeholder="작업 카테고리 (예: PKG, Vessel)" list="categoryList">
|
|
||||||
<datalist id="categoryList">
|
|
||||||
<!-- 기존 카테고리 목록이 여기에 동적으로 생성됩니다 -->
|
|
||||||
</datalist>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" onclick="closeCodeModal()">취소</button>
|
|
||||||
<button type="button" class="btn btn-danger" id="deleteCodeBtn" onclick="deleteCode()" style="display: none;">삭제</button>
|
|
||||||
<button type="button" class="btn btn-primary" onclick="saveCode()">저장</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script type="module" src="/js/code-management.js?v=2"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -7,170 +7,681 @@
|
|||||||
<link rel="stylesheet" href="/css/design-system.css">
|
<link rel="stylesheet" href="/css/design-system.css">
|
||||||
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
|
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
|
||||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||||
|
<style>
|
||||||
|
.page-wrapper {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
max-width: 1600px;
|
||||||
|
}
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.page-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.header-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.search-input {
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
.filter-select {
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.btn-primary { background: #3b82f6; color: white; }
|
||||||
|
.btn-outline { background: white; border: 1px solid #d1d5db; }
|
||||||
|
.btn-danger { background: #ef4444; color: white; }
|
||||||
|
.btn-sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; }
|
||||||
|
|
||||||
|
/* 통계 바 */
|
||||||
|
.stats-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #6b7280;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
.stats-bar .stat {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
.stats-bar .stat:hover { background: #f3f4f6; }
|
||||||
|
.stats-bar .stat.active { background: #dbeafe; color: #1d4ed8; }
|
||||||
|
.stats-bar .stat strong { font-weight: 600; }
|
||||||
|
|
||||||
|
/* 테이블 */
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
.data-table th, .data-table td {
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.data-table th {
|
||||||
|
background: #f9fafb;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
.data-table tr:hover { background: #f9fafb; }
|
||||||
|
.data-table tr.inactive { background: #fef2f2; opacity: 0.7; }
|
||||||
|
.data-table .job-no {
|
||||||
|
font-family: monospace;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.data-table .project-name {
|
||||||
|
font-weight: 500;
|
||||||
|
max-width: 200px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.status-planning { background: #f3f4f6; color: #6b7280; }
|
||||||
|
.status-active { background: #dcfce7; color: #166534; }
|
||||||
|
.status-completed { background: #dbeafe; color: #1e40af; }
|
||||||
|
.status-cancelled { background: #fee2e2; color: #dc2626; }
|
||||||
|
.inactive-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
.action-btns {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.action-btns button {
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.action-btns button:hover { background: #f3f4f6; }
|
||||||
|
.action-btns .btn-edit { color: #3b82f6; }
|
||||||
|
.action-btns .btn-del { color: #ef4444; }
|
||||||
|
|
||||||
|
.empty-row td {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
.table-wrapper {
|
||||||
|
max-height: calc(100vh - 280px);
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 모달 */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0,0,0,0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.modal-container {
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
width: 600px;
|
||||||
|
max-width: 95vw;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
.modal-header h2 { font-size: 1rem; font-weight: 600; margin: 0; }
|
||||||
|
.modal-close { background: none; border: none; font-size: 1.25rem; cursor: pointer; color: #6b7280; }
|
||||||
|
.modal-body { padding: 1rem; }
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.form-group { margin-bottom: 0.75rem; }
|
||||||
|
.form-group:last-child { margin-bottom: 0; }
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
.form-control {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.form-check {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.form-hint {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="has-sidebar">
|
||||||
<!-- 네비게이션 바 -->
|
|
||||||
<div id="navbar-container"></div>
|
<div id="navbar-container"></div>
|
||||||
|
<div id="sidebar-container"></div>
|
||||||
|
|
||||||
<!-- 메인 레이아웃 -->
|
<main class="main-content">
|
||||||
<div class="page-container">
|
<div class="page-wrapper">
|
||||||
<!-- 메인 콘텐츠 -->
|
<div class="page-header">
|
||||||
<main class="main-content">
|
<h1 class="page-title">프로젝트 관리</h1>
|
||||||
<div class="dashboard-main">
|
<div class="header-controls">
|
||||||
<!-- 페이지 헤더: 타이틀 + 액션 버튼 -->
|
<input type="text" id="searchInput" class="search-input" placeholder="검색 (Job No., 프로젝트명, PM)">
|
||||||
<div class="page-header">
|
<select id="statusFilter" class="filter-select" onchange="filterProjects()">
|
||||||
<div class="page-title-section">
|
<option value="">전체 상태</option>
|
||||||
<h1 class="page-title">프로젝트 관리</h1>
|
<option value="planning">계획</option>
|
||||||
<p class="page-description">프로젝트 등록, 수정, 삭제 및 기본 정보를 관리합니다</p>
|
<option value="active">진행중</option>
|
||||||
</div>
|
<option value="completed">완료</option>
|
||||||
<div class="page-actions">
|
<option value="cancelled">취소</option>
|
||||||
<button class="btn btn-primary" onclick="openProjectModal()">새 프로젝트 등록</button>
|
</select>
|
||||||
<button class="btn btn-secondary" onclick="refreshProjectList()">새로고침</button>
|
<select id="sortBy" class="filter-select" onchange="sortProjects()">
|
||||||
</div>
|
<option value="created_at">등록일순</option>
|
||||||
</div>
|
<option value="project_name">이름순</option>
|
||||||
|
<option value="due_date">납기일순</option>
|
||||||
<!-- 검색 및 필터 -->
|
</select>
|
||||||
<div class="search-section">
|
<button class="btn btn-outline" onclick="refreshProjectList()">새로고침</button>
|
||||||
<div class="search-bar">
|
<button class="btn btn-primary" onclick="openProjectModal()">+ 새 프로젝트</button>
|
||||||
<input type="text" id="searchInput" placeholder="프로젝트명 또는 Job No.로 검색..." class="search-input">
|
|
||||||
<button class="search-btn" onclick="searchProjects()">검색</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="filter-options">
|
|
||||||
<select id="statusFilter" class="filter-select" onchange="filterProjects()">
|
|
||||||
<option value="">전체 상태</option>
|
|
||||||
<option value="active">진행중</option>
|
|
||||||
<option value="completed">완료</option>
|
|
||||||
<option value="paused">중단</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<select id="sortBy" class="filter-select" onchange="sortProjects()">
|
|
||||||
<option value="created_at">등록일순</option>
|
|
||||||
<option value="project_name">프로젝트명순</option>
|
|
||||||
<option value="due_date">납기일순</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 프로젝트 목록 -->
|
|
||||||
<div class="projects-section">
|
|
||||||
<div class="section-header">
|
|
||||||
<h2 class="section-title">등록된 프로젝트</h2>
|
|
||||||
<div class="project-stats">
|
|
||||||
<span class="stat-item active-stat" onclick="filterByStatus('active')" title="활성 프로젝트만 보기">활성 <span id="activeProjects">0</span>개</span>
|
|
||||||
<span class="stat-item inactive-stat" onclick="filterByStatus('inactive')" title="비활성 프로젝트만 보기">비활성 <span id="inactiveProjects">0</span>개</span>
|
|
||||||
<span class="stat-item total-stat" onclick="filterByStatus('all')" title="전체 프로젝트 보기">총 <span id="totalProjects">0</span>개</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="projects-grid" id="projectsGrid">
|
|
||||||
<!-- 프로젝트 카드들이 여기에 동적으로 생성됩니다 -->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="empty-state" id="emptyState" style="display: none;">
|
|
||||||
<h3>등록된 프로젝트가 없습니다</h3>
|
|
||||||
<p>새 프로젝트를 등록해보세요.</p>
|
|
||||||
<button class="btn btn-primary" onclick="openProjectModal()">첫 번째 프로젝트 등록</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<!-- 프로젝트 등록/수정 모달 -->
|
|
||||||
<div id="projectModal" class="modal-overlay" style="display: none;">
|
|
||||||
<div class="modal-container">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h2 id="modalTitle">새 프로젝트 등록</h2>
|
|
||||||
<button class="modal-close-btn" onclick="closeProjectModal()">×</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-body">
|
|
||||||
<form id="projectForm">
|
|
||||||
<input type="hidden" id="projectId">
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Job No. *</label>
|
|
||||||
<input type="text" id="jobNo" class="form-control" required placeholder="예: TK-2024-001">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">프로젝트명 *</label>
|
|
||||||
<input type="text" id="projectName" class="form-control" required placeholder="프로젝트명을 입력하세요">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">계약일</label>
|
|
||||||
<input type="date" id="contractDate" class="form-control">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">납기일</label>
|
|
||||||
<input type="date" id="dueDate" class="form-control">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">납품방법</label>
|
|
||||||
<select id="deliveryMethod" class="form-control">
|
|
||||||
<option value="">선택하세요</option>
|
|
||||||
<option value="직접납품">직접납품</option>
|
|
||||||
<option value="택배">택배</option>
|
|
||||||
<option value="화물">화물</option>
|
|
||||||
<option value="현장설치">현장설치</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">현장</label>
|
|
||||||
<input type="text" id="site" class="form-control" placeholder="현장 위치를 입력하세요">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">PM (프로젝트 매니저)</label>
|
|
||||||
<input type="text" id="pm" class="form-control" placeholder="담당 PM을 입력하세요">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">프로젝트 상태</label>
|
|
||||||
<select id="projectStatus" class="form-control">
|
|
||||||
<option value="planning">계획</option>
|
|
||||||
<option value="active" selected>진행중</option>
|
|
||||||
<option value="completed">완료</option>
|
|
||||||
<option value="cancelled">취소</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">완료일 (납품일)</label>
|
|
||||||
<input type="date" id="completedDate" class="form-control">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label" style="display: flex; align-items: center; gap: 0.5rem;">
|
|
||||||
<input type="checkbox" id="isActive" checked style="margin: 0;">
|
|
||||||
<span>프로젝트 활성화</span>
|
|
||||||
</label>
|
|
||||||
<small style="color: #6b7280; font-size: 0.8rem;">체크 해제 시 작업보고서 입력에서 숨겨집니다</small>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" onclick="closeProjectModal()">취소</button>
|
|
||||||
<button type="button" class="btn btn-danger" id="deleteProjectBtn" onclick="deleteProject()" style="display: none;">삭제</button>
|
|
||||||
<button type="button" class="btn btn-primary" onclick="saveProject()">저장</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
|
||||||
|
<div class="stats-bar">
|
||||||
|
<span class="stat active" data-filter="all" onclick="filterByStatus('all')">전체 <strong id="totalProjects">0</strong></span>
|
||||||
|
<span class="stat" data-filter="active" onclick="filterByStatus('active')">활성 <strong id="activeProjects">0</strong></span>
|
||||||
|
<span class="stat" data-filter="inactive" onclick="filterByStatus('inactive')">비활성 <strong id="inactiveProjects">0</strong></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:30px">#</th>
|
||||||
|
<th style="width:120px">Job No.</th>
|
||||||
|
<th>프로젝트명</th>
|
||||||
|
<th style="width:70px">상태</th>
|
||||||
|
<th style="width:90px">계약일</th>
|
||||||
|
<th style="width:90px">납기일</th>
|
||||||
|
<th style="width:80px">PM</th>
|
||||||
|
<th>현장</th>
|
||||||
|
<th style="width:70px">활성</th>
|
||||||
|
<th style="width:80px">관리</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="projectsTableBody">
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- 프로젝트 모달 -->
|
||||||
|
<div id="projectModal" class="modal-overlay" style="display: none;">
|
||||||
|
<div class="modal-container">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="modalTitle">새 프로젝트 등록</h2>
|
||||||
|
<button class="modal-close" onclick="closeProjectModal()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="projectForm">
|
||||||
|
<input type="hidden" id="projectId">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Job No. *</label>
|
||||||
|
<input type="text" id="jobNo" class="form-control" required placeholder="TK-2024-001">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">프로젝트명 *</label>
|
||||||
|
<input type="text" id="projectName" class="form-control" required placeholder="프로젝트명">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">계약일</label>
|
||||||
|
<input type="date" id="contractDate" class="form-control">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">납기일</label>
|
||||||
|
<input type="date" id="dueDate" class="form-control">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">PM</label>
|
||||||
|
<input type="text" id="pm" class="form-control" placeholder="담당 PM">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">현장</label>
|
||||||
|
<input type="text" id="site" class="form-control" placeholder="현장 위치">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">납품방법</label>
|
||||||
|
<select id="deliveryMethod" class="form-control">
|
||||||
|
<option value="">선택</option>
|
||||||
|
<option value="직접납품">직접납품</option>
|
||||||
|
<option value="택배">택배</option>
|
||||||
|
<option value="화물">화물</option>
|
||||||
|
<option value="현장설치">현장설치</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">프로젝트 상태</label>
|
||||||
|
<select id="projectStatus" class="form-control">
|
||||||
|
<option value="planning">계획</option>
|
||||||
|
<option value="active" selected>진행중</option>
|
||||||
|
<option value="completed">완료</option>
|
||||||
|
<option value="cancelled">취소</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">완료일</label>
|
||||||
|
<input type="date" id="completedDate" class="form-control">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="display:flex;align-items:flex-end;">
|
||||||
|
<label class="form-check">
|
||||||
|
<input type="checkbox" id="isActive" checked>
|
||||||
|
<span>프로젝트 활성화</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="form-hint">* 비활성화 시 작업보고서 입력에서 숨겨집니다</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline" onclick="closeProjectModal()">취소</button>
|
||||||
|
<button type="button" class="btn btn-danger" id="deleteProjectBtn" onclick="deleteProject()" style="display:none;">삭제</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="saveProject()">저장</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- JavaScript -->
|
<script src="/js/api-base.js"></script>
|
||||||
<script src="/js/api-base.js"></script>
|
<script src="/js/app-init.js?v=2" defer></script>
|
||||||
<script src="/js/app-init.js?v=2" defer></script>
|
<script>
|
||||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
let allProjects = [];
|
||||||
<script type="module" src="/js/project-management.js?v=3"></script>
|
let filteredProjects = [];
|
||||||
|
let currentEditingProject = null;
|
||||||
|
let currentStatusFilter = 'all';
|
||||||
|
let currentProjectStatusFilter = '';
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
loadProjects();
|
||||||
|
setupSearchInput();
|
||||||
|
});
|
||||||
|
|
||||||
|
function setupSearchInput() {
|
||||||
|
const searchInput = document.getElementById('searchInput');
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.addEventListener('input', () => applyAllFilters());
|
||||||
|
searchInput.addEventListener('keypress', (e) => {
|
||||||
|
if (e.key === 'Enter') applyAllFilters();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProjects() {
|
||||||
|
try {
|
||||||
|
const response = await apiCall('/projects', 'GET');
|
||||||
|
let projectData = [];
|
||||||
|
if (response && response.success && Array.isArray(response.data)) {
|
||||||
|
projectData = response.data;
|
||||||
|
} else if (Array.isArray(response)) {
|
||||||
|
projectData = response;
|
||||||
|
}
|
||||||
|
allProjects = projectData;
|
||||||
|
applyAllFilters();
|
||||||
|
updateStatCardActiveState();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('프로젝트 로딩 오류:', error);
|
||||||
|
allProjects = [];
|
||||||
|
filteredProjects = [];
|
||||||
|
renderProjects();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProjects() {
|
||||||
|
const tbody = document.getElementById('projectsTableBody');
|
||||||
|
if (!tbody) return;
|
||||||
|
|
||||||
|
if (filteredProjects.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr class="empty-row"><td colspan="10">등록된 프로젝트가 없습니다</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusMap = {
|
||||||
|
'planning': { text: '계획', class: 'status-planning' },
|
||||||
|
'active': { text: '진행중', class: 'status-active' },
|
||||||
|
'completed': { text: '완료', class: 'status-completed' },
|
||||||
|
'cancelled': { text: '취소', class: 'status-cancelled' }
|
||||||
|
};
|
||||||
|
|
||||||
|
tbody.innerHTML = filteredProjects.map((p, idx) => {
|
||||||
|
const status = statusMap[p.project_status] || statusMap['active'];
|
||||||
|
const isInactive = p.is_active === 0 || p.is_active === false;
|
||||||
|
const rowClass = isInactive ? 'inactive' : '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr class="${rowClass}">
|
||||||
|
<td>${idx + 1}</td>
|
||||||
|
<td class="job-no">${p.job_no || '-'}</td>
|
||||||
|
<td class="project-name" title="${p.project_name}">
|
||||||
|
${p.project_name}
|
||||||
|
${isInactive ? '<span class="inactive-badge">비활성</span>' : ''}
|
||||||
|
</td>
|
||||||
|
<td><span class="status-badge ${status.class}">${status.text}</span></td>
|
||||||
|
<td>${formatDate(p.contract_date)}</td>
|
||||||
|
<td>${formatDate(p.due_date)}</td>
|
||||||
|
<td>${p.pm || '-'}</td>
|
||||||
|
<td>${p.site || '-'}</td>
|
||||||
|
<td>${isInactive ? '비활성' : '활성'}</td>
|
||||||
|
<td>
|
||||||
|
<div class="action-btns">
|
||||||
|
<button class="btn-edit" onclick="editProject(${p.project_id})">수정</button>
|
||||||
|
<button class="btn-del" onclick="confirmDeleteProject(${p.project_id})">삭제</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateString) {
|
||||||
|
if (!dateString) return '-';
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('ko-KR', { year: '2-digit', month: '2-digit', day: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterByStatus(status) {
|
||||||
|
currentStatusFilter = status;
|
||||||
|
updateStatCardActiveState();
|
||||||
|
applyAllFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatCardActiveState() {
|
||||||
|
document.querySelectorAll('.stats-bar .stat').forEach(item => {
|
||||||
|
item.classList.remove('active');
|
||||||
|
if (item.dataset.filter === currentStatusFilter) {
|
||||||
|
item.classList.add('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterProjects() {
|
||||||
|
currentProjectStatusFilter = document.getElementById('statusFilter').value;
|
||||||
|
applyAllFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAllFilters() {
|
||||||
|
const searchTerm = (document.getElementById('searchInput')?.value || '').toLowerCase().trim();
|
||||||
|
|
||||||
|
// 1. is_active 필터
|
||||||
|
let result = [...allProjects];
|
||||||
|
if (currentStatusFilter === 'active') {
|
||||||
|
result = result.filter(p => p.is_active === 1 || p.is_active === true);
|
||||||
|
} else if (currentStatusFilter === 'inactive') {
|
||||||
|
result = result.filter(p => p.is_active === 0 || p.is_active === false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. project_status 필터
|
||||||
|
if (currentProjectStatusFilter) {
|
||||||
|
result = result.filter(p => p.project_status === currentProjectStatusFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 검색
|
||||||
|
if (searchTerm) {
|
||||||
|
result = result.filter(p =>
|
||||||
|
(p.project_name && p.project_name.toLowerCase().includes(searchTerm)) ||
|
||||||
|
(p.job_no && p.job_no.toLowerCase().includes(searchTerm)) ||
|
||||||
|
(p.pm && p.pm.toLowerCase().includes(searchTerm)) ||
|
||||||
|
(p.site && p.site.toLowerCase().includes(searchTerm))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredProjects = result;
|
||||||
|
renderProjects();
|
||||||
|
updateProjectStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortProjects() {
|
||||||
|
const sortField = document.getElementById('sortBy')?.value || 'created_at';
|
||||||
|
filteredProjects.sort((a, b) => {
|
||||||
|
switch (sortField) {
|
||||||
|
case 'project_name':
|
||||||
|
return (a.project_name || '').localeCompare(b.project_name || '');
|
||||||
|
case 'due_date':
|
||||||
|
if (!a.due_date && !b.due_date) return 0;
|
||||||
|
if (!a.due_date) return 1;
|
||||||
|
if (!b.due_date) return -1;
|
||||||
|
return new Date(a.due_date) - new Date(b.due_date);
|
||||||
|
default:
|
||||||
|
return new Date(b.created_at || 0) - new Date(a.created_at || 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
renderProjects();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProjectStats() {
|
||||||
|
const active = allProjects.filter(p => p.is_active === 1 || p.is_active === true).length;
|
||||||
|
const inactive = allProjects.filter(p => p.is_active === 0 || p.is_active === false).length;
|
||||||
|
document.getElementById('totalProjects').textContent = allProjects.length;
|
||||||
|
document.getElementById('activeProjects').textContent = active;
|
||||||
|
document.getElementById('inactiveProjects').textContent = inactive;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshProjectList() {
|
||||||
|
await loadProjects();
|
||||||
|
showToast('새로고침 완료');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openProjectModal(project = null) {
|
||||||
|
const modal = document.getElementById('projectModal');
|
||||||
|
const modalTitle = document.getElementById('modalTitle');
|
||||||
|
const deleteBtn = document.getElementById('deleteProjectBtn');
|
||||||
|
currentEditingProject = project;
|
||||||
|
|
||||||
|
if (project) {
|
||||||
|
modalTitle.textContent = '프로젝트 수정';
|
||||||
|
deleteBtn.style.display = 'inline-block';
|
||||||
|
document.getElementById('projectId').value = project.project_id;
|
||||||
|
document.getElementById('jobNo').value = project.job_no || '';
|
||||||
|
document.getElementById('projectName').value = project.project_name || '';
|
||||||
|
document.getElementById('contractDate').value = project.contract_date || '';
|
||||||
|
document.getElementById('dueDate').value = project.due_date || '';
|
||||||
|
document.getElementById('deliveryMethod').value = project.delivery_method || '';
|
||||||
|
document.getElementById('site').value = project.site || '';
|
||||||
|
document.getElementById('pm').value = project.pm || '';
|
||||||
|
document.getElementById('projectStatus').value = project.project_status || 'active';
|
||||||
|
document.getElementById('completedDate').value = project.completed_date || '';
|
||||||
|
document.getElementById('isActive').checked = project.is_active === 1 || project.is_active === true;
|
||||||
|
} else {
|
||||||
|
modalTitle.textContent = '새 프로젝트 등록';
|
||||||
|
deleteBtn.style.display = 'none';
|
||||||
|
document.getElementById('projectForm').reset();
|
||||||
|
document.getElementById('projectId').value = '';
|
||||||
|
document.getElementById('isActive').checked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeProjectModal() {
|
||||||
|
document.getElementById('projectModal').style.display = 'none';
|
||||||
|
currentEditingProject = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function editProject(projectId) {
|
||||||
|
const project = allProjects.find(p => p.project_id === projectId);
|
||||||
|
if (project) openProjectModal(project);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveProject() {
|
||||||
|
const projectData = {
|
||||||
|
job_no: document.getElementById('jobNo').value.trim(),
|
||||||
|
project_name: document.getElementById('projectName').value.trim(),
|
||||||
|
contract_date: document.getElementById('contractDate').value || null,
|
||||||
|
due_date: document.getElementById('dueDate').value || null,
|
||||||
|
delivery_method: document.getElementById('deliveryMethod').value || null,
|
||||||
|
site: document.getElementById('site').value.trim() || null,
|
||||||
|
pm: document.getElementById('pm').value.trim() || null,
|
||||||
|
project_status: document.getElementById('projectStatus').value || 'active',
|
||||||
|
completed_date: document.getElementById('completedDate').value || null,
|
||||||
|
is_active: document.getElementById('isActive').checked ? 1 : 0
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!projectData.job_no || !projectData.project_name) {
|
||||||
|
showToast('Job No.와 프로젝트명은 필수입니다.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const projectId = document.getElementById('projectId').value;
|
||||||
|
let response;
|
||||||
|
if (projectId) {
|
||||||
|
response = await apiCall(`/projects/${projectId}`, 'PUT', projectData);
|
||||||
|
} else {
|
||||||
|
response = await apiCall('/projects', 'POST', projectData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response && (response.success || response.project_id)) {
|
||||||
|
showToast(projectId ? '수정 완료' : '등록 완료');
|
||||||
|
closeProjectModal();
|
||||||
|
await loadProjects();
|
||||||
|
} else {
|
||||||
|
throw new Error(response?.message || '저장 실패');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message || '저장 중 오류', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDeleteProject(projectId) {
|
||||||
|
const project = allProjects.find(p => p.project_id === projectId);
|
||||||
|
if (!project) return;
|
||||||
|
if (confirm(`"${project.project_name}" 프로젝트를 삭제하시겠습니까?`)) {
|
||||||
|
deleteProjectById(projectId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteProject() {
|
||||||
|
if (currentEditingProject) {
|
||||||
|
confirmDeleteProject(currentEditingProject.project_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteProjectById(projectId) {
|
||||||
|
try {
|
||||||
|
const response = await apiCall(`/projects/${projectId}`, 'DELETE');
|
||||||
|
if (response && response.success) {
|
||||||
|
showToast('삭제 완료');
|
||||||
|
closeProjectModal();
|
||||||
|
await loadProjects();
|
||||||
|
} else {
|
||||||
|
throw new Error(response?.message || '삭제 실패');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message || '삭제 중 오류', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message, type = 'success') {
|
||||||
|
const existing = document.querySelector('.toast-msg');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = 'toast-msg';
|
||||||
|
toast.textContent = message;
|
||||||
|
toast.style.cssText = `
|
||||||
|
position: fixed; top: 20px; right: 20px;
|
||||||
|
padding: 0.75rem 1.25rem; border-radius: 0.25rem;
|
||||||
|
color: white; font-size: 0.85rem; z-index: 2000;
|
||||||
|
background: ${type === 'error' ? '#ef4444' : '#10b981'};
|
||||||
|
`;
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
setTimeout(() => toast.remove(), 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전역 함수 노출
|
||||||
|
window.openProjectModal = openProjectModal;
|
||||||
|
window.closeProjectModal = closeProjectModal;
|
||||||
|
window.editProject = editProject;
|
||||||
|
window.saveProject = saveProject;
|
||||||
|
window.deleteProject = deleteProject;
|
||||||
|
window.confirmDeleteProject = confirmDeleteProject;
|
||||||
|
window.filterProjects = filterProjects;
|
||||||
|
window.sortProjects = sortProjects;
|
||||||
|
window.refreshProjectList = refreshProjectList;
|
||||||
|
window.filterByStatus = filterByStatus;
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -9,142 +9,586 @@
|
|||||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||||
<script src="/js/api-base.js"></script>
|
<script src="/js/api-base.js"></script>
|
||||||
<script src="/js/app-init.js?v=2" defer></script>
|
<script src="/js/app-init.js?v=2" defer></script>
|
||||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
<style>
|
||||||
|
.page-wrapper { padding: 1rem 1.5rem; max-width: 1400px; }
|
||||||
|
.page-header {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.page-title { font-size: 1.25rem; font-weight: 600; margin: 0; }
|
||||||
|
.header-controls { display: flex; gap: 0.5rem; align-items: center; }
|
||||||
|
.filter-select {
|
||||||
|
padding: 0.4rem 0.5rem; border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.25rem; font-size: 0.8rem; min-width: 120px;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
padding: 0.4rem 0.75rem; border: none; border-radius: 0.25rem;
|
||||||
|
cursor: pointer; font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.btn-primary { background: #3b82f6; color: white; }
|
||||||
|
.btn-outline { background: white; border: 1px solid #d1d5db; }
|
||||||
|
.btn-danger { background: #ef4444; color: white; }
|
||||||
|
|
||||||
|
/* 2열 레이아웃 */
|
||||||
|
.two-col-layout { display: grid; grid-template-columns: 280px 1fr; gap: 1rem; }
|
||||||
|
|
||||||
|
/* 공정 패널 */
|
||||||
|
.work-type-panel {
|
||||||
|
background: white; border: 1px solid #e5e7eb; border-radius: 0.25rem;
|
||||||
|
max-height: calc(100vh - 200px); overflow-y: auto;
|
||||||
|
}
|
||||||
|
.panel-header {
|
||||||
|
padding: 0.75rem; border-bottom: 1px solid #e5e7eb;
|
||||||
|
font-weight: 600; font-size: 0.85rem; background: #f9fafb;
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
}
|
||||||
|
.panel-header .btn { padding: 0.25rem 0.5rem; font-size: 0.7rem; }
|
||||||
|
.work-type-list { padding: 0; margin: 0; list-style: none; }
|
||||||
|
.work-type-item {
|
||||||
|
padding: 0.6rem 0.75rem; border-bottom: 1px solid #f3f4f6;
|
||||||
|
cursor: pointer; font-size: 0.8rem;
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
}
|
||||||
|
.work-type-item:hover { background: #f9fafb; }
|
||||||
|
.work-type-item.active { background: #dbeafe; color: #1d4ed8; }
|
||||||
|
.work-type-item .count {
|
||||||
|
background: #f3f4f6; padding: 0.1rem 0.4rem; border-radius: 0.25rem;
|
||||||
|
font-size: 0.7rem; color: #6b7280;
|
||||||
|
}
|
||||||
|
.work-type-item.active .count { background: #bfdbfe; color: #1d4ed8; }
|
||||||
|
.work-type-item .edit-btn {
|
||||||
|
opacity: 0; font-size: 0.7rem; padding: 0.2rem 0.4rem;
|
||||||
|
background: white; border: 1px solid #d1d5db; border-radius: 0.2rem;
|
||||||
|
cursor: pointer; margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
.work-type-item:hover .edit-btn { opacity: 1; }
|
||||||
|
|
||||||
|
/* 작업 테이블 */
|
||||||
|
.task-panel { background: white; border: 1px solid #e5e7eb; border-radius: 0.25rem; }
|
||||||
|
.task-header {
|
||||||
|
padding: 0.75rem; border-bottom: 1px solid #e5e7eb;
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
.task-header-title { font-weight: 600; font-size: 0.85rem; }
|
||||||
|
.task-stats { font-size: 0.75rem; color: #6b7280; }
|
||||||
|
.task-stats span { margin-left: 1rem; }
|
||||||
|
.table-wrapper { max-height: calc(100vh - 280px); overflow-y: auto; }
|
||||||
|
.data-table {
|
||||||
|
width: 100%; border-collapse: collapse; font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.data-table th, .data-table td {
|
||||||
|
padding: 0.5rem 0.6rem; text-align: left; border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
.data-table th {
|
||||||
|
background: #f9fafb; font-weight: 500; color: #374151;
|
||||||
|
font-size: 0.75rem; position: sticky; top: 0;
|
||||||
|
}
|
||||||
|
.data-table tr:hover { background: #f9fafb; }
|
||||||
|
.data-table tr.inactive { opacity: 0.6; }
|
||||||
|
.task-name { font-weight: 500; }
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block; padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 0.2rem; font-size: 0.7rem; font-weight: 500;
|
||||||
|
}
|
||||||
|
.status-active { background: #dcfce7; color: #166534; }
|
||||||
|
.status-inactive { background: #f3f4f6; color: #6b7280; }
|
||||||
|
.action-btns { display: flex; gap: 0.25rem; }
|
||||||
|
.action-btns button {
|
||||||
|
padding: 0.2rem 0.4rem; font-size: 0.7rem;
|
||||||
|
border: 1px solid #d1d5db; background: white;
|
||||||
|
border-radius: 0.2rem; cursor: pointer;
|
||||||
|
}
|
||||||
|
.action-btns button:hover { background: #f3f4f6; }
|
||||||
|
.action-btns .btn-edit { color: #3b82f6; }
|
||||||
|
.action-btns .btn-del { color: #ef4444; }
|
||||||
|
.empty-row td { text-align: center; padding: 2rem; color: #6b7280; }
|
||||||
|
.desc-cell { max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #6b7280; }
|
||||||
|
|
||||||
|
/* 모달 */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0,0,0,0.5); display: flex;
|
||||||
|
align-items: center; justify-content: center; z-index: 1000;
|
||||||
|
}
|
||||||
|
.modal-container {
|
||||||
|
background: white; border-radius: 0.5rem; width: 500px;
|
||||||
|
max-width: 95vw; max-height: 90vh; overflow-y: auto;
|
||||||
|
}
|
||||||
|
.modal-header {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 1rem; border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
.modal-header h2 { font-size: 1rem; font-weight: 600; margin: 0; }
|
||||||
|
.modal-close { background: none; border: none; font-size: 1.25rem; cursor: pointer; color: #6b7280; }
|
||||||
|
.modal-body { padding: 1rem; }
|
||||||
|
.modal-footer {
|
||||||
|
display: flex; justify-content: flex-end; gap: 0.5rem;
|
||||||
|
padding: 1rem; border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
.form-group { margin-bottom: 0.75rem; }
|
||||||
|
.form-label { display: block; font-size: 0.8rem; font-weight: 500; color: #374151; margin-bottom: 0.25rem; }
|
||||||
|
.form-control {
|
||||||
|
width: 100%; padding: 0.4rem 0.5rem; border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.25rem; font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.form-check { display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; }
|
||||||
|
.form-hint { font-size: 0.7rem; color: #6b7280; margin-top: 0.25rem; }
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="has-sidebar">
|
||||||
<!-- 네비게이션 바 -->
|
|
||||||
<div id="navbar-container"></div>
|
<div id="navbar-container"></div>
|
||||||
|
<div id="sidebar-container"></div>
|
||||||
|
|
||||||
<!-- 메인 레이아웃 -->
|
<main class="main-content">
|
||||||
<div class="page-container">
|
<div class="page-wrapper">
|
||||||
<!-- 메인 콘텐츠 -->
|
<div class="page-header">
|
||||||
<main class="main-content">
|
<h1 class="page-title">작업 관리</h1>
|
||||||
<div class="dashboard-main">
|
<div class="header-controls">
|
||||||
<div class="page-header">
|
<button class="btn btn-outline" onclick="refreshData()">새로고침</button>
|
||||||
<div class="page-title-section">
|
<button class="btn btn-primary" onclick="openWorkTypeModal()">+ 공정</button>
|
||||||
<h1 class="page-title">작업 관리</h1>
|
<button class="btn btn-primary" onclick="openTaskModal()">+ 작업</button>
|
||||||
<p class="page-description">공정별 세부 작업을 등록하고 관리합니다</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="page-actions">
|
|
||||||
<button class="btn btn-primary" onclick="openWorkTypeModal()">공정 추가</button>
|
|
||||||
<button class="btn btn-primary" onclick="openTaskModal()">작업 추가</button>
|
|
||||||
<button class="btn btn-secondary" onclick="refreshTasks()">새로고침</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 공정(work_types) 탭 -->
|
|
||||||
<div class="code-tabs" id="workTypeTabs">
|
|
||||||
<button class="tab-btn active" data-work-type="" onclick="switchWorkType('')">전체</button>
|
|
||||||
<!-- 공정 탭들이 여기에 동적으로 생성됩니다 -->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 작업 목록 -->
|
|
||||||
<div class="code-section">
|
|
||||||
<div class="section-header">
|
|
||||||
<h2 class="section-title">작업 목록</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="code-stats" id="taskStats">
|
|
||||||
<span class="stat-item">전체 <span id="totalCount">0</span>개</span>
|
|
||||||
<span class="stat-item">활성 <span id="activeCount">0</span>개</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="code-grid" id="taskGrid">
|
|
||||||
<!-- 작업 카드들이 여기에 동적으로 생성됩니다 -->
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
|
||||||
|
|
||||||
<!-- 작업 추가/수정 모달 -->
|
<div class="two-col-layout">
|
||||||
<div id="taskModal" class="modal-overlay" style="display: none;">
|
<!-- 공정 목록 -->
|
||||||
<div class="modal-container">
|
<div class="work-type-panel">
|
||||||
<div class="modal-header">
|
<div class="panel-header">
|
||||||
<h2 id="taskModalTitle">작업 추가</h2>
|
<span>공정 목록</span>
|
||||||
<button class="modal-close-btn" onclick="closeTaskModal()">×</button>
|
</div>
|
||||||
</div>
|
<ul class="work-type-list" id="workTypeList">
|
||||||
|
<li class="work-type-item active" data-id="" onclick="filterByWorkType('')">
|
||||||
<div class="modal-body">
|
<span>전체</span>
|
||||||
<form id="taskForm" onsubmit="event.preventDefault(); saveTask();">
|
<span class="count" id="totalCount">0</span>
|
||||||
<input type="hidden" id="taskId">
|
</li>
|
||||||
|
</ul>
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">소속 공정 *</label>
|
|
||||||
<select id="taskWorkTypeId" class="form-control" required>
|
|
||||||
<option value="">공정 선택...</option>
|
|
||||||
<!-- 공정 목록이 동적으로 생성됩니다 -->
|
|
||||||
</select>
|
|
||||||
<small class="form-help">이 작업이 속한 공정을 선택하세요</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">작업명 *</label>
|
|
||||||
<input type="text" id="taskName" class="form-control" placeholder="예: 서스 용접, 프레임 조립" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">설명</label>
|
|
||||||
<textarea id="taskDescription" class="form-control" rows="4" placeholder="작업에 대한 설명을 입력하세요"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label" style="display: flex; align-items: center; gap: 0.5rem;">
|
|
||||||
<input type="checkbox" id="taskIsActive" checked style="margin: 0;">
|
|
||||||
<span>활성화</span>
|
|
||||||
</label>
|
|
||||||
<small class="form-help">비활성화하면 TBM 입력 시 표시되지 않습니다</small>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" onclick="closeTaskModal()">취소</button>
|
|
||||||
<button type="button" class="btn btn-danger" id="deleteTaskBtn" onclick="deleteTask()" style="display: none;">삭제</button>
|
|
||||||
<button type="button" class="btn btn-primary" onclick="saveTask()">저장</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 작업 테이블 -->
|
||||||
|
<div class="task-panel">
|
||||||
|
<div class="task-header">
|
||||||
|
<span class="task-header-title" id="currentWorkTypeName">전체 작업</span>
|
||||||
|
<div class="task-stats">
|
||||||
|
<span>활성 <strong id="activeCount">0</strong></span>
|
||||||
|
<span>비활성 <strong id="inactiveCount">0</strong></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:30px">#</th>
|
||||||
|
<th>작업명</th>
|
||||||
|
<th>소속 공정</th>
|
||||||
|
<th>설명</th>
|
||||||
|
<th style="width:60px">상태</th>
|
||||||
|
<th style="width:80px">관리</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="taskTableBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
<!-- 공정 추가/수정 모달 -->
|
<!-- 작업 모달 -->
|
||||||
<div id="workTypeModal" class="modal-overlay" style="display: none;">
|
<div id="taskModal" class="modal-overlay" style="display:none;">
|
||||||
<div class="modal-container">
|
<div class="modal-container">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2 id="workTypeModalTitle">공정 추가</h2>
|
<h2 id="taskModalTitle">작업 추가</h2>
|
||||||
<button class="modal-close-btn" onclick="closeWorkTypeModal()">×</button>
|
<button class="modal-close" onclick="closeTaskModal()">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
<div class="modal-body">
|
<form id="taskForm">
|
||||||
<form id="workTypeForm" onsubmit="event.preventDefault(); saveWorkType();">
|
<input type="hidden" id="taskId">
|
||||||
<input type="hidden" id="workTypeId">
|
<div class="form-group">
|
||||||
|
<label class="form-label">소속 공정 *</label>
|
||||||
<div class="form-group">
|
<select id="taskWorkTypeId" class="form-control" required>
|
||||||
<label class="form-label">공정명 *</label>
|
<option value="">선택...</option>
|
||||||
<input type="text" id="workTypeName" class="form-control" placeholder="예: Base(구조물), Vessel(용기)" required>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
<div class="form-group">
|
<label class="form-label">작업명 *</label>
|
||||||
<label class="form-label">카테고리</label>
|
<input type="text" id="taskName" class="form-control" required placeholder="예: 서스 용접">
|
||||||
<input type="text" id="workTypeCategory" class="form-control" placeholder="예: 제작, 조립">
|
</div>
|
||||||
<small class="form-help">공정을 그룹화할 카테고리 (선택사항)</small>
|
<div class="form-group">
|
||||||
</div>
|
<label class="form-label">설명</label>
|
||||||
|
<textarea id="taskDescription" class="form-control" rows="3" placeholder="작업 설명"></textarea>
|
||||||
<div class="form-group">
|
</div>
|
||||||
<label class="form-label">설명</label>
|
<div class="form-group">
|
||||||
<textarea id="workTypeDescription" class="form-control" rows="3" placeholder="공정에 대한 설명"></textarea>
|
<label class="form-check">
|
||||||
</div>
|
<input type="checkbox" id="taskIsActive" checked>
|
||||||
</form>
|
<span>활성화</span>
|
||||||
</div>
|
</label>
|
||||||
|
<p class="form-hint">비활성화 시 TBM 입력에서 숨김</p>
|
||||||
<div class="modal-footer">
|
</div>
|
||||||
<button type="button" class="btn btn-secondary" onclick="closeWorkTypeModal()">취소</button>
|
</form>
|
||||||
<button type="button" class="btn btn-danger" id="deleteWorkTypeBtn" onclick="deleteWorkType()" style="display: none;">삭제</button>
|
</div>
|
||||||
<button type="button" class="btn btn-primary" onclick="saveWorkType()">저장</button>
|
<div class="modal-footer">
|
||||||
</div>
|
<button class="btn btn-outline" onclick="closeTaskModal()">취소</button>
|
||||||
</div>
|
<button class="btn btn-danger" id="deleteTaskBtn" onclick="deleteTask()" style="display:none;">삭제</button>
|
||||||
|
<button class="btn btn-primary" onclick="saveTask()">저장</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="module" src="/js/task-management.js?v=1"></script>
|
<!-- 공정 모달 -->
|
||||||
|
<div id="workTypeModal" class="modal-overlay" style="display:none;">
|
||||||
|
<div class="modal-container">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="workTypeModalTitle">공정 추가</h2>
|
||||||
|
<button class="modal-close" onclick="closeWorkTypeModal()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="workTypeForm">
|
||||||
|
<input type="hidden" id="workTypeId">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">공정명 *</label>
|
||||||
|
<input type="text" id="workTypeName" class="form-control" required placeholder="예: Base(구조물)">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">카테고리</label>
|
||||||
|
<input type="text" id="workTypeCategory" class="form-control" placeholder="예: 제작">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">설명</label>
|
||||||
|
<textarea id="workTypeDescription" class="form-control" rows="2" placeholder="공정 설명"></textarea>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-outline" onclick="closeWorkTypeModal()">취소</button>
|
||||||
|
<button class="btn btn-danger" id="deleteWorkTypeBtn" onclick="deleteWorkType()" style="display:none;">삭제</button>
|
||||||
|
<button class="btn btn-primary" onclick="saveWorkType()">저장</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let workTypes = [];
|
||||||
|
let tasks = [];
|
||||||
|
let currentWorkTypeId = '';
|
||||||
|
let currentEditingTask = null;
|
||||||
|
let currentEditingWorkType = null;
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
let retryCount = 0;
|
||||||
|
while (!window.apiCall && retryCount < 50) {
|
||||||
|
await new Promise(r => setTimeout(r, 100));
|
||||||
|
retryCount++;
|
||||||
|
}
|
||||||
|
if (!window.apiCall) {
|
||||||
|
alert('시스템 초기화 실패. 페이지를 새로고침하세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loadAllData();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadAllData() {
|
||||||
|
try {
|
||||||
|
const [wtRes, taskRes] = await Promise.all([
|
||||||
|
window.apiCall('/daily-work-reports/work-types'),
|
||||||
|
window.apiCall('/tasks')
|
||||||
|
]);
|
||||||
|
workTypes = (wtRes && wtRes.success) ? (wtRes.data || []) : [];
|
||||||
|
tasks = (taskRes && taskRes.success) ? (taskRes.data || []) : [];
|
||||||
|
renderWorkTypeList();
|
||||||
|
renderTasks();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('데이터 로드 오류:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWorkTypeList() {
|
||||||
|
const list = document.getElementById('workTypeList');
|
||||||
|
let html = `
|
||||||
|
<li class="work-type-item ${currentWorkTypeId === '' ? 'active' : ''}" data-id="" onclick="filterByWorkType('')">
|
||||||
|
<span>전체</span>
|
||||||
|
<span class="count">${tasks.length}</span>
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
|
workTypes.forEach(wt => {
|
||||||
|
const count = tasks.filter(t => t.work_type_id === wt.id).length;
|
||||||
|
const isActive = currentWorkTypeId === wt.id;
|
||||||
|
html += `
|
||||||
|
<li class="work-type-item ${isActive ? 'active' : ''}" data-id="${wt.id}" onclick="filterByWorkType(${wt.id})">
|
||||||
|
<span>${escapeHtml(wt.name)}</span>
|
||||||
|
<span class="count">${count}</span>
|
||||||
|
<button class="edit-btn" onclick="event.stopPropagation(); editWorkType(${wt.id})">수정</button>
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
list.innerHTML = html;
|
||||||
|
document.getElementById('totalCount').textContent = tasks.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterByWorkType(id) {
|
||||||
|
currentWorkTypeId = id === '' ? '' : parseInt(id);
|
||||||
|
renderWorkTypeList();
|
||||||
|
renderTasks();
|
||||||
|
|
||||||
|
const wt = workTypes.find(w => w.id === currentWorkTypeId);
|
||||||
|
document.getElementById('currentWorkTypeName').textContent = wt ? wt.name + ' 작업' : '전체 작업';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTasks() {
|
||||||
|
const tbody = document.getElementById('taskTableBody');
|
||||||
|
let filtered = tasks;
|
||||||
|
if (currentWorkTypeId !== '') {
|
||||||
|
filtered = tasks.filter(t => t.work_type_id === currentWorkTypeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = filtered.filter(t => t.is_active).length;
|
||||||
|
const inactive = filtered.length - active;
|
||||||
|
document.getElementById('activeCount').textContent = active;
|
||||||
|
document.getElementById('inactiveCount').textContent = inactive;
|
||||||
|
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr class="empty-row"><td colspan="6">등록된 작업이 없습니다</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = filtered.map((t, idx) => {
|
||||||
|
const rowClass = t.is_active ? '' : 'inactive';
|
||||||
|
return `
|
||||||
|
<tr class="${rowClass}">
|
||||||
|
<td>${idx + 1}</td>
|
||||||
|
<td class="task-name">${escapeHtml(t.task_name)}</td>
|
||||||
|
<td>${escapeHtml(t.work_type_name || '-')}</td>
|
||||||
|
<td class="desc-cell" title="${escapeHtml(t.description || '')}">${escapeHtml(t.description || '-')}</td>
|
||||||
|
<td><span class="status-badge ${t.is_active ? 'status-active' : 'status-inactive'}">${t.is_active ? '활성' : '비활성'}</span></td>
|
||||||
|
<td>
|
||||||
|
<div class="action-btns">
|
||||||
|
<button class="btn-edit" onclick="editTask(${t.task_id})">수정</button>
|
||||||
|
<button class="btn-del" onclick="confirmDeleteTask(${t.task_id})">삭제</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return str.replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshData() {
|
||||||
|
await loadAllData();
|
||||||
|
showToast('새로고침 완료');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 작업 모달 ==========
|
||||||
|
function openTaskModal() {
|
||||||
|
currentEditingTask = null;
|
||||||
|
document.getElementById('taskModalTitle').textContent = '작업 추가';
|
||||||
|
document.getElementById('taskForm').reset();
|
||||||
|
document.getElementById('taskId').value = '';
|
||||||
|
document.getElementById('taskIsActive').checked = true;
|
||||||
|
populateWorkTypeSelect();
|
||||||
|
if (currentWorkTypeId !== '') {
|
||||||
|
document.getElementById('taskWorkTypeId').value = currentWorkTypeId;
|
||||||
|
}
|
||||||
|
document.getElementById('deleteTaskBtn').style.display = 'none';
|
||||||
|
document.getElementById('taskModal').style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateWorkTypeSelect() {
|
||||||
|
const select = document.getElementById('taskWorkTypeId');
|
||||||
|
select.innerHTML = '<option value="">선택...</option>' +
|
||||||
|
workTypes.map(wt => `<option value="${wt.id}">${escapeHtml(wt.name)}</option>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editTask(taskId) {
|
||||||
|
try {
|
||||||
|
const res = await window.apiCall(`/tasks/${taskId}`);
|
||||||
|
if (res && res.success) {
|
||||||
|
currentEditingTask = res.data;
|
||||||
|
document.getElementById('taskModalTitle').textContent = '작업 수정';
|
||||||
|
document.getElementById('taskId').value = currentEditingTask.task_id;
|
||||||
|
populateWorkTypeSelect();
|
||||||
|
document.getElementById('taskWorkTypeId').value = currentEditingTask.work_type_id || '';
|
||||||
|
document.getElementById('taskName').value = currentEditingTask.task_name;
|
||||||
|
document.getElementById('taskDescription').value = currentEditingTask.description || '';
|
||||||
|
document.getElementById('taskIsActive').checked = currentEditingTask.is_active;
|
||||||
|
document.getElementById('deleteTaskBtn').style.display = 'inline-block';
|
||||||
|
document.getElementById('taskModal').style.display = 'flex';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showToast('작업 조회 오류', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeTaskModal() {
|
||||||
|
document.getElementById('taskModal').style.display = 'none';
|
||||||
|
currentEditingTask = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTask() {
|
||||||
|
const taskId = document.getElementById('taskId').value;
|
||||||
|
const data = {
|
||||||
|
work_type_id: parseInt(document.getElementById('taskWorkTypeId').value) || null,
|
||||||
|
task_name: document.getElementById('taskName').value.trim(),
|
||||||
|
description: document.getElementById('taskDescription').value.trim() || null,
|
||||||
|
is_active: document.getElementById('taskIsActive').checked ? 1 : 0
|
||||||
|
};
|
||||||
|
if (!data.task_name) { showToast('작업명을 입력하세요', 'error'); return; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = taskId
|
||||||
|
? await window.apiCall(`/tasks/${taskId}`, 'PUT', data)
|
||||||
|
: await window.apiCall('/tasks', 'POST', data);
|
||||||
|
if (res && res.success) {
|
||||||
|
showToast(taskId ? '수정 완료' : '추가 완료');
|
||||||
|
closeTaskModal();
|
||||||
|
await loadAllData();
|
||||||
|
} else {
|
||||||
|
throw new Error(res?.message || '저장 실패');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message || '저장 오류', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDeleteTask(taskId) {
|
||||||
|
const task = tasks.find(t => t.task_id === taskId);
|
||||||
|
if (!task) return;
|
||||||
|
if (confirm(`"${task.task_name}" 작업을 삭제하시겠습니까?`)) {
|
||||||
|
deleteTaskById(taskId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTask() {
|
||||||
|
if (currentEditingTask) {
|
||||||
|
confirmDeleteTask(currentEditingTask.task_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTaskById(taskId) {
|
||||||
|
try {
|
||||||
|
const res = await window.apiCall(`/tasks/${taskId}`, 'DELETE');
|
||||||
|
if (res && res.success) {
|
||||||
|
showToast('삭제 완료');
|
||||||
|
closeTaskModal();
|
||||||
|
await loadAllData();
|
||||||
|
} else {
|
||||||
|
throw new Error(res?.message || '삭제 실패');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message || '삭제 오류', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 공정 모달 ==========
|
||||||
|
function openWorkTypeModal() {
|
||||||
|
currentEditingWorkType = null;
|
||||||
|
document.getElementById('workTypeModalTitle').textContent = '공정 추가';
|
||||||
|
document.getElementById('workTypeForm').reset();
|
||||||
|
document.getElementById('workTypeId').value = '';
|
||||||
|
document.getElementById('deleteWorkTypeBtn').style.display = 'none';
|
||||||
|
document.getElementById('workTypeModal').style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function editWorkType(id) {
|
||||||
|
const wt = workTypes.find(w => w.id === id);
|
||||||
|
if (!wt) return;
|
||||||
|
currentEditingWorkType = wt;
|
||||||
|
document.getElementById('workTypeModalTitle').textContent = '공정 수정';
|
||||||
|
document.getElementById('workTypeId').value = wt.id;
|
||||||
|
document.getElementById('workTypeName').value = wt.name || '';
|
||||||
|
document.getElementById('workTypeCategory').value = wt.category || '';
|
||||||
|
document.getElementById('workTypeDescription').value = wt.description || '';
|
||||||
|
document.getElementById('deleteWorkTypeBtn').style.display = 'inline-block';
|
||||||
|
document.getElementById('workTypeModal').style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeWorkTypeModal() {
|
||||||
|
document.getElementById('workTypeModal').style.display = 'none';
|
||||||
|
currentEditingWorkType = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveWorkType() {
|
||||||
|
const id = document.getElementById('workTypeId').value;
|
||||||
|
const data = {
|
||||||
|
name: document.getElementById('workTypeName').value.trim(),
|
||||||
|
category: document.getElementById('workTypeCategory').value.trim() || null,
|
||||||
|
description: document.getElementById('workTypeDescription').value.trim() || null
|
||||||
|
};
|
||||||
|
if (!data.name) { showToast('공정명을 입력하세요', 'error'); return; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = id
|
||||||
|
? await window.apiCall(`/daily-work-reports/work-types/${id}`, 'PUT', data)
|
||||||
|
: await window.apiCall('/daily-work-reports/work-types', 'POST', data);
|
||||||
|
if (res && res.success) {
|
||||||
|
showToast(id ? '수정 완료' : '추가 완료');
|
||||||
|
closeWorkTypeModal();
|
||||||
|
await loadAllData();
|
||||||
|
} else {
|
||||||
|
throw new Error(res?.message || '저장 실패');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message || '저장 오류', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteWorkType() {
|
||||||
|
if (!currentEditingWorkType) return;
|
||||||
|
const related = tasks.filter(t => t.work_type_id === currentEditingWorkType.id);
|
||||||
|
if (related.length > 0) {
|
||||||
|
showToast(`${related.length}개 작업이 연결되어 삭제 불가`, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!confirm(`"${currentEditingWorkType.name}" 공정을 삭제하시겠습니까?`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await window.apiCall(`/daily-work-reports/work-types/${currentEditingWorkType.id}`, 'DELETE');
|
||||||
|
if (res && res.success) {
|
||||||
|
showToast('삭제 완료');
|
||||||
|
closeWorkTypeModal();
|
||||||
|
currentWorkTypeId = '';
|
||||||
|
await loadAllData();
|
||||||
|
} else {
|
||||||
|
throw new Error(res?.message || '삭제 실패');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message || '삭제 오류', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message, type = 'success') {
|
||||||
|
const existing = document.querySelector('.toast-msg');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = 'toast-msg';
|
||||||
|
toast.textContent = message;
|
||||||
|
toast.style.cssText = `
|
||||||
|
position: fixed; top: 20px; right: 20px;
|
||||||
|
padding: 0.75rem 1.25rem; border-radius: 0.25rem;
|
||||||
|
color: white; font-size: 0.85rem; z-index: 2000;
|
||||||
|
background: ${type === 'error' ? '#ef4444' : '#10b981'};
|
||||||
|
`;
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
setTimeout(() => toast.remove(), 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전역 노출
|
||||||
|
window.filterByWorkType = filterByWorkType;
|
||||||
|
window.openTaskModal = openTaskModal;
|
||||||
|
window.closeTaskModal = closeTaskModal;
|
||||||
|
window.editTask = editTask;
|
||||||
|
window.saveTask = saveTask;
|
||||||
|
window.deleteTask = deleteTask;
|
||||||
|
window.confirmDeleteTask = confirmDeleteTask;
|
||||||
|
window.openWorkTypeModal = openWorkTypeModal;
|
||||||
|
window.closeWorkTypeModal = closeWorkTypeModal;
|
||||||
|
window.editWorkType = editWorkType;
|
||||||
|
window.saveWorkType = saveWorkType;
|
||||||
|
window.deleteWorkType = deleteWorkType;
|
||||||
|
window.refreshData = refreshData;
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -417,7 +417,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="module" src="/js/workplace-management.js?v=8"></script>
|
<!-- 작업장 관리 모듈 (리팩토링된 구조) -->
|
||||||
|
<script src="/js/workplace-management/state.js?v=1"></script>
|
||||||
|
<script src="/js/workplace-management/utils.js?v=1"></script>
|
||||||
|
<script src="/js/workplace-management/api.js?v=1"></script>
|
||||||
|
<script src="/js/workplace-management/index.js?v=1"></script>
|
||||||
|
<!-- 기존 UI 로직 (점진적 마이그레이션) -->
|
||||||
|
<script type="module" src="/js/workplace-management.js?v=9"></script>
|
||||||
<script type="module" src="/js/workplace-layout-map.js?v=1"></script>
|
<script type="module" src="/js/workplace-layout-map.js?v=1"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -9,236 +9,165 @@
|
|||||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||||
<script src="/js/api-base.js"></script>
|
<script src="/js/api-base.js"></script>
|
||||||
<script src="/js/app-init.js?v=2" defer></script>
|
<script src="/js/app-init.js?v=2" defer></script>
|
||||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
|
||||||
<style>
|
<style>
|
||||||
.page-wrapper {
|
.page-wrapper {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
}
|
}
|
||||||
.summary-cards {
|
.page-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
justify-content: space-between;
|
||||||
gap: 1rem;
|
align-items: center;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
.summary-card {
|
.page-title {
|
||||||
flex: 1;
|
font-size: 1.25rem;
|
||||||
min-width: 100px;
|
|
||||||
padding: 1rem;
|
|
||||||
background: white;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
text-align: center;
|
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
.summary-card.normal { border-left: 4px solid #10b981; }
|
|
||||||
.summary-card.annual { border-left: 4px solid #3b82f6; }
|
|
||||||
.summary-card.half { border-left: 4px solid #22c55e; }
|
|
||||||
.summary-card.quarter { border-left: 4px solid #eab308; }
|
|
||||||
.summary-card.early { border-left: 4px solid #ef4444; }
|
|
||||||
.summary-card.overtime { border-left: 4px solid #f97316; }
|
|
||||||
.summary-value { font-size: 1.5rem; font-weight: 700; }
|
|
||||||
.summary-label { font-size: 0.75rem; color: #6b7280; }
|
|
||||||
|
|
||||||
.status-table {
|
|
||||||
width: 100%;
|
|
||||||
background: white;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.status-table th {
|
|
||||||
background: #f8fafc;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
text-align: left;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.875rem;
|
margin: 0;
|
||||||
border-bottom: 2px solid #e5e7eb;
|
|
||||||
}
|
|
||||||
.status-table td {
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border-bottom: 1px solid #e5e7eb;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
.status-table tr:hover { background: #f8fafc; }
|
|
||||||
.status-table tr.absent { background: #fef2f2; }
|
|
||||||
|
|
||||||
.worker-cell {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
.worker-dot {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
.worker-dot.present { background: #10b981; }
|
|
||||||
.worker-dot.absent { background: #ef4444; }
|
|
||||||
|
|
||||||
.type-select {
|
|
||||||
padding: 0.5rem;
|
|
||||||
border: 1px solid #d1d5db;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
min-width: 110px;
|
|
||||||
}
|
|
||||||
.overtime-group {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
.overtime-input {
|
|
||||||
width: 60px;
|
|
||||||
padding: 0.5rem;
|
|
||||||
border: 1px solid #d1d5db;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
}
|
||||||
.controls {
|
.controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
.controls input[type="date"] {
|
.controls input[type="date"] {
|
||||||
padding: 0.5rem;
|
padding: 0.4rem 0.5rem;
|
||||||
border: 1px solid #d1d5db;
|
border: 1px solid #d1d5db;
|
||||||
border-radius: 0.375rem;
|
border-radius: 0.25rem;
|
||||||
}
|
|
||||||
.btn {
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border: none;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
.btn {
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
.btn-primary { background: #3b82f6; color: white; }
|
.btn-primary { background: #3b82f6; color: white; }
|
||||||
|
.btn-outline { background: white; border: 1px solid #d1d5db; }
|
||||||
|
.btn-success { background: #10b981; color: white; }
|
||||||
|
|
||||||
|
/* 요약 */
|
||||||
|
.summary-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #6b7280;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
.summary-row span { display: flex; align-items: center; gap: 0.25rem; }
|
||||||
|
.summary-row .dot {
|
||||||
|
width: 8px; height: 8px; border-radius: 50%;
|
||||||
|
}
|
||||||
|
.dot-normal { background: #10b981; }
|
||||||
|
.dot-annual { background: #3b82f6; }
|
||||||
|
.dot-half { background: #22c55e; }
|
||||||
|
.dot-quarter { background: #eab308; }
|
||||||
|
.dot-early { background: #ef4444; }
|
||||||
|
.dot-overtime { background: #f97316; }
|
||||||
|
|
||||||
|
/* 테이블 */
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
.data-table th, .data-table td {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
.data-table th {
|
||||||
|
background: #f9fafb;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.data-table tr:hover {
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
.data-table tr.saved {
|
||||||
|
background: #f0fdf4;
|
||||||
|
}
|
||||||
|
.data-table tr.absent {
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
.worker-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.saved-tag {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: #10b981;
|
||||||
|
background: #dcfce7;
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
.type-select {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
.overtime-input {
|
||||||
|
width: 50px;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.hours-cell {
|
||||||
|
text-align: center;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
.status-present { color: #10b981; }
|
||||||
|
.status-absent { color: #ef4444; }
|
||||||
|
|
||||||
|
/* 저장 영역 */
|
||||||
|
.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.25rem;
|
||||||
|
}
|
||||||
|
.save-status {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
.save-status.saved { color: #10b981; }
|
||||||
|
.save-status.unsaved { color: #f59e0b; }
|
||||||
.btn-save {
|
.btn-save {
|
||||||
display: block;
|
padding: 0.5rem 1.5rem;
|
||||||
margin: 1.5rem auto 0;
|
font-size: 0.875rem;
|
||||||
padding: 0.75rem 2rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
background: #3b82f6;
|
background: #3b82f6;
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.25rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.btn-save:hover { background: #2563eb; }
|
.btn-save:hover { background: #2563eb; }
|
||||||
.btn-save:disabled { background: #9ca3af; cursor: not-allowed; }
|
.btn-save:disabled { background: #9ca3af; cursor: not-allowed; }
|
||||||
.btn-save.saved { background: #10b981; }
|
|
||||||
.btn-save.saving { background: #6b7280; }
|
.warning-box {
|
||||||
.no-checkin-warning {
|
|
||||||
background: #fef3c7;
|
background: #fef3c7;
|
||||||
border: 1px solid #fcd34d;
|
border: 1px solid #fcd34d;
|
||||||
color: #92400e;
|
color: #92400e;
|
||||||
padding: 1rem;
|
padding: 0.5rem 0.75rem;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.25rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 0.75rem;
|
||||||
text-align: center;
|
font-size: 0.8rem;
|
||||||
}
|
|
||||||
|
|
||||||
/* 저장 상태 섹션 */
|
|
||||||
.save-section {
|
|
||||||
text-align: center;
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
}
|
|
||||||
.status-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
.status-badge.saved {
|
|
||||||
background: #dcfce7;
|
|
||||||
color: #166534;
|
|
||||||
}
|
|
||||||
.status-badge.unsaved {
|
|
||||||
background: #fef3c7;
|
|
||||||
color: #92400e;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 토스트 알림 */
|
|
||||||
.toast {
|
|
||||||
position: fixed;
|
|
||||||
top: 20px;
|
|
||||||
right: 20px;
|
|
||||||
padding: 1rem 1.5rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
color: white;
|
|
||||||
font-weight: 500;
|
|
||||||
z-index: 9999;
|
|
||||||
animation: slideIn 0.3s ease, fadeOut 0.3s ease 2.7s;
|
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
|
||||||
}
|
|
||||||
.toast.success { background: #10b981; }
|
|
||||||
.toast.error { background: #ef4444; }
|
|
||||||
.toast.info { background: #3b82f6; }
|
|
||||||
@keyframes slideIn {
|
|
||||||
from { transform: translateX(100%); opacity: 0; }
|
|
||||||
to { transform: translateX(0); opacity: 1; }
|
|
||||||
}
|
|
||||||
@keyframes fadeOut {
|
|
||||||
from { opacity: 1; }
|
|
||||||
to { opacity: 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 저장 성공 오버레이 */
|
|
||||||
.save-overlay {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: rgba(16, 185, 129, 0.9);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 9998;
|
|
||||||
animation: fadeIn 0.3s ease;
|
|
||||||
}
|
|
||||||
.save-overlay .checkmark {
|
|
||||||
width: 80px;
|
|
||||||
height: 80px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: white;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
animation: scaleIn 0.4s ease;
|
|
||||||
}
|
|
||||||
.save-overlay .checkmark svg {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
stroke: #10b981;
|
|
||||||
stroke-width: 3;
|
|
||||||
}
|
|
||||||
.save-overlay .message {
|
|
||||||
color: white;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.save-overlay .sub-message {
|
|
||||||
color: rgba(255,255,255,0.9);
|
|
||||||
font-size: 1rem;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from { opacity: 0; }
|
|
||||||
to { opacity: 1; }
|
|
||||||
}
|
|
||||||
@keyframes scaleIn {
|
|
||||||
from { transform: scale(0); }
|
|
||||||
to { transform: scale(1); }
|
|
||||||
}
|
}
|
||||||
|
.warning-box a { color: #92400e; font-weight: 500; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="has-sidebar">
|
<body class="has-sidebar">
|
||||||
@@ -247,72 +176,52 @@
|
|||||||
|
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
<div class="page-wrapper">
|
<div class="page-wrapper">
|
||||||
<h1 style="font-size: 1.5rem; font-weight: 700; margin-bottom: 0.5rem;">근무 현황</h1>
|
<div class="page-header">
|
||||||
<p style="color: #64748b; margin-bottom: 1.5rem;">휴가/조퇴 및 연장근무를 입력합니다</p>
|
<h1 class="page-title">근무 현황</h1>
|
||||||
|
<div class="controls">
|
||||||
<div class="controls">
|
<input type="date" id="selectedDate">
|
||||||
<input type="date" id="selectedDate">
|
<button class="btn btn-primary" onclick="loadWorkStatus()">조회</button>
|
||||||
<button class="btn btn-primary" onclick="loadWorkStatus()">새로고침</button>
|
<button class="btn btn-outline" onclick="setAllNormal()">전체 정시근무</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="noCheckinWarning" class="no-checkin-warning" style="display:none;">
|
<div id="noCheckinWarning" class="warning-box" style="display:none;">
|
||||||
출근 체크가 완료되지 않았습니다. 먼저 <a href="/pages/attendance/checkin.html">출근 체크</a>를 진행해주세요.
|
출근 체크가 완료되지 않았습니다. 먼저 <a href="/pages/attendance/checkin.html">출근 체크</a>를 진행해주세요.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="summary-cards">
|
<div class="summary-row">
|
||||||
<div class="summary-card normal">
|
<span><span class="dot dot-normal"></span> 정시 <strong id="normalCount">0</strong></span>
|
||||||
<div class="summary-value" id="normalCount">0</div>
|
<span><span class="dot dot-annual"></span> 연차 <strong id="annualCount">0</strong></span>
|
||||||
<div class="summary-label">정시근무</div>
|
<span><span class="dot dot-half"></span> 반차 <strong id="halfCount">0</strong></span>
|
||||||
</div>
|
<span><span class="dot dot-quarter"></span> 반반차 <strong id="quarterCount">0</strong></span>
|
||||||
<div class="summary-card annual">
|
<span><span class="dot dot-early"></span> 조퇴 <strong id="earlyCount">0</strong></span>
|
||||||
<div class="summary-value" id="annualCount">0</div>
|
<span><span class="dot dot-overtime"></span> 연장 <strong id="overtimeCount">0</strong></span>
|
||||||
<div class="summary-label">연차</div>
|
|
||||||
</div>
|
|
||||||
<div class="summary-card half">
|
|
||||||
<div class="summary-value" id="halfCount">0</div>
|
|
||||||
<div class="summary-label">반차</div>
|
|
||||||
</div>
|
|
||||||
<div class="summary-card quarter">
|
|
||||||
<div class="summary-value" id="quarterCount">0</div>
|
|
||||||
<div class="summary-label">반반차</div>
|
|
||||||
</div>
|
|
||||||
<div class="summary-card early">
|
|
||||||
<div class="summary-value" id="earlyCount">0</div>
|
|
||||||
<div class="summary-label">조퇴</div>
|
|
||||||
</div>
|
|
||||||
<div class="summary-card overtime">
|
|
||||||
<div class="summary-value" id="overtimeCount">0</div>
|
|
||||||
<div class="summary-label">연장근로</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table class="status-table">
|
<table class="data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width: 130px;">작업자</th>
|
<th style="width:30px">#</th>
|
||||||
<th style="width: 80px;">출근</th>
|
<th>이름</th>
|
||||||
<th style="width: 130px;">근태 구분</th>
|
<th>출근</th>
|
||||||
<th style="width: 100px;">근무시간</th>
|
<th>근태구분</th>
|
||||||
<th style="width: 150px;">연장근로</th>
|
<th class="hours-cell">기본</th>
|
||||||
|
<th class="hours-cell">연장</th>
|
||||||
|
<th class="hours-cell">합계</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="statusTableBody">
|
<tbody id="workerTableBody">
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<div class="save-section">
|
<div class="save-bar">
|
||||||
<div id="saveStatus"></div>
|
<span id="saveStatus" class="save-status"></span>
|
||||||
<button id="saveBtn" class="btn-save" onclick="saveWorkStatus()">근무 현황 저장</button>
|
<button id="saveBtn" class="btn-save" onclick="saveWorkStatus()">저장</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- 토스트 컨테이너 -->
|
|
||||||
<div id="toastContainer"></div>
|
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||||
<script type="module">
|
|
||||||
</script>
|
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
const checkApiConfig = setInterval(() => {
|
const checkApiConfig = setInterval(() => {
|
||||||
@@ -333,7 +242,6 @@
|
|||||||
let isAlreadySaved = false;
|
let isAlreadySaved = false;
|
||||||
let isSaving = false;
|
let isSaving = false;
|
||||||
|
|
||||||
// 근태 구분 옵션
|
|
||||||
const attendanceTypes = [
|
const attendanceTypes = [
|
||||||
{ value: 'normal', label: '정시근무', hours: 8 },
|
{ value: 'normal', label: '정시근무', hours: 8 },
|
||||||
{ value: 'annual', label: '연차', hours: 0 },
|
{ value: 'annual', label: '연차', hours: 0 },
|
||||||
@@ -374,9 +282,7 @@
|
|||||||
workers = (workersRes.data.data || []).filter(w => w.status === 'active' && (!w.employment_status || w.employment_status === 'employed'));
|
workers = (workersRes.data.data || []).filter(w => w.status === 'active' && (!w.employment_status || w.employment_status === 'employed'));
|
||||||
const records = recordsRes.data.data || [];
|
const records = recordsRes.data.data || [];
|
||||||
|
|
||||||
// 출근 체크 데이터가 있는지 확인
|
|
||||||
hasCheckinData = records.length > 0;
|
hasCheckinData = records.length > 0;
|
||||||
// 이미 저장된 근무 현황이 있는지 확인 (attendance_type_id가 설정된 경우)
|
|
||||||
isAlreadySaved = records.some(r => r.attendance_type_id || r.total_work_hours > 0);
|
isAlreadySaved = records.some(r => r.attendance_type_id || r.total_work_hours > 0);
|
||||||
document.getElementById('noCheckinWarning').style.display = hasCheckinData ? 'none' : 'block';
|
document.getElementById('noCheckinWarning').style.display = hasCheckinData ? 'none' : 'block';
|
||||||
|
|
||||||
@@ -385,26 +291,23 @@
|
|||||||
const record = records.find(r => r.worker_id === w.worker_id);
|
const record = records.find(r => r.worker_id === w.worker_id);
|
||||||
|
|
||||||
if (record) {
|
if (record) {
|
||||||
// 기존 데이터 기반으로 설정
|
|
||||||
let type = 'normal';
|
let type = 'normal';
|
||||||
let overtimeHours = 0;
|
let overtimeHours = 0;
|
||||||
|
|
||||||
// is_present가 0이면 결근 → 연차로 기본 설정
|
|
||||||
if (record.is_present === 0) {
|
if (record.is_present === 0) {
|
||||||
type = 'annual';
|
type = 'annual';
|
||||||
} else {
|
} else {
|
||||||
// 기존 저장된 타입이 있으면 사용
|
|
||||||
if (record.attendance_type_code) {
|
if (record.attendance_type_code) {
|
||||||
const codeMap = {
|
const codeMap = {
|
||||||
'NORMAL': 'normal',
|
'REGULAR': 'normal',
|
||||||
'VACATION': 'annual',
|
'VACATION': 'annual',
|
||||||
'HALF_LEAVE': 'half',
|
'HALF_LEAVE': 'half',
|
||||||
'QUARTER_LEAVE': 'quarter',
|
'QUARTER_LEAVE': 'quarter',
|
||||||
'EARLY_LEAVE': 'early'
|
'PARTIAL': 'early',
|
||||||
|
'OVERTIME': 'overtime'
|
||||||
};
|
};
|
||||||
type = codeMap[record.attendance_type_code] || 'normal';
|
type = codeMap[record.attendance_type_code] || 'normal';
|
||||||
}
|
}
|
||||||
// 연장근로 시간이 있으면 연장근로 타입으로
|
|
||||||
if (record.total_work_hours > 8) {
|
if (record.total_work_hours > 8) {
|
||||||
type = 'overtime';
|
type = 'overtime';
|
||||||
overtimeHours = record.total_work_hours - 8;
|
overtimeHours = record.total_work_hours - 8;
|
||||||
@@ -419,7 +322,6 @@
|
|||||||
isSaved: record.attendance_type_id != null || record.total_work_hours > 0
|
isSaved: record.attendance_type_id != null || record.total_work_hours > 0
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// 데이터 없으면 기본값 (출근, 정시근무)
|
|
||||||
workStatus[w.worker_id] = {
|
workStatus[w.worker_id] = {
|
||||||
isPresent: true,
|
isPresent: true,
|
||||||
type: 'normal',
|
type: 'normal',
|
||||||
@@ -435,33 +337,35 @@
|
|||||||
updateSaveStatus();
|
updateSaveStatus();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
showToast('데이터 로드 실패', 'error');
|
alert('데이터 로드 실패');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
const tbody = document.getElementById('statusTableBody');
|
const tbody = document.getElementById('workerTableBody');
|
||||||
|
|
||||||
if (workers.length === 0) {
|
if (workers.length === 0) {
|
||||||
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;padding:2rem;color:#6b7280;">작업자가 없습니다</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:#6b7280;padding:2rem;">작업자가 없습니다</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody.innerHTML = workers.map(w => {
|
tbody.innerHTML = workers.map((w, idx) => {
|
||||||
const s = workStatus[w.worker_id];
|
const s = workStatus[w.worker_id];
|
||||||
const isAbsent = !s.isPresent;
|
|
||||||
const showOvertimeInput = s.type === 'overtime';
|
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' : '');
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr class="${isAbsent ? 'absent' : ''}" style="${s.isSaved ? 'background:#f0fdf4;' : ''}">
|
<tr class="${rowClass}">
|
||||||
|
<td>${idx + 1}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="worker-cell">
|
<span class="worker-name">${w.worker_name}</span>
|
||||||
<span class="worker-dot ${s.isPresent ? 'present' : 'absent'}"></span>
|
${s.isSaved ? '<span class="saved-tag">저장됨</span>' : ''}
|
||||||
<span>${w.worker_name}</span>
|
</td>
|
||||||
${s.isSaved ? '<span style="margin-left:0.5rem;font-size:0.7rem;color:#10b981;">✓저장됨</span>' : ''}
|
<td class="${s.isPresent ? 'status-present' : 'status-absent'}">
|
||||||
</div>
|
${s.isPresent ? '출근' : '결근'}
|
||||||
</td>
|
</td>
|
||||||
<td>${s.isPresent ? '<span style="color:#10b981">출근</span>' : '<span style="color:#ef4444">결근</span>'}</td>
|
|
||||||
<td>
|
<td>
|
||||||
<select class="type-select" onchange="updateType(${w.worker_id}, this.value)">
|
<select class="type-select" onchange="updateType(${w.worker_id}, this.value)">
|
||||||
${attendanceTypes.map(t => `
|
${attendanceTypes.map(t => `
|
||||||
@@ -469,16 +373,14 @@
|
|||||||
`).join('')}
|
`).join('')}
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td>${s.type === 'overtime' ? (s.hours + s.overtimeHours) : s.hours}시간</td>
|
<td class="hours-cell">${baseHours}h</td>
|
||||||
<td>
|
<td class="hours-cell">
|
||||||
${showOvertimeInput ? `
|
${showOvertimeInput ? `
|
||||||
<div class="overtime-group">
|
<input type="number" class="overtime-input" value="${s.overtimeHours}" min="0" max="8" step="0.5"
|
||||||
<input type="number" class="overtime-input" value="${s.overtimeHours}" min="0" max="8" step="0.5"
|
onchange="updateOvertime(${w.worker_id}, this.value)">
|
||||||
onchange="updateOvertime(${w.worker_id}, this.value)">
|
` : '-'}
|
||||||
<span style="color:#6b7280;font-size:0.875rem;">시간</span>
|
|
||||||
</div>
|
|
||||||
` : '<span style="color:#9ca3af;">-</span>'}
|
|
||||||
</td>
|
</td>
|
||||||
|
<td class="hours-cell"><strong>${totalHours}h</strong></td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
@@ -489,7 +391,6 @@
|
|||||||
workStatus[workerId].type = value;
|
workStatus[workerId].type = value;
|
||||||
workStatus[workerId].hours = type ? type.hours : 8;
|
workStatus[workerId].hours = type ? type.hours : 8;
|
||||||
|
|
||||||
// 연장근로 선택 시 기본 2시간
|
|
||||||
if (value === 'overtime') {
|
if (value === 'overtime') {
|
||||||
workStatus[workerId].overtimeHours = workStatus[workerId].overtimeHours || 2;
|
workStatus[workerId].overtimeHours = workStatus[workerId].overtimeHours || 2;
|
||||||
} else {
|
} else {
|
||||||
@@ -506,6 +407,16 @@
|
|||||||
updateSummary();
|
updateSummary();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setAllNormal() {
|
||||||
|
workers.forEach(w => {
|
||||||
|
workStatus[w.worker_id].type = 'normal';
|
||||||
|
workStatus[w.worker_id].hours = 8;
|
||||||
|
workStatus[w.worker_id].overtimeHours = 0;
|
||||||
|
});
|
||||||
|
render();
|
||||||
|
updateSummary();
|
||||||
|
}
|
||||||
|
|
||||||
function updateSummary() {
|
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;
|
||||||
|
|
||||||
@@ -528,78 +439,44 @@
|
|||||||
document.getElementById('overtimeCount').textContent = overtime;
|
document.getElementById('overtimeCount').textContent = overtime;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 토스트 알림 표시
|
|
||||||
function showToast(message, type = 'info') {
|
|
||||||
const container = document.getElementById('toastContainer');
|
|
||||||
const toast = document.createElement('div');
|
|
||||||
toast.className = `toast ${type}`;
|
|
||||||
toast.textContent = message;
|
|
||||||
container.appendChild(toast);
|
|
||||||
setTimeout(() => toast.remove(), 3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 저장 성공 오버레이 표시
|
|
||||||
function showSaveOverlay(count) {
|
|
||||||
const overlay = document.createElement('div');
|
|
||||||
overlay.className = 'save-overlay';
|
|
||||||
overlay.id = 'saveOverlay';
|
|
||||||
overlay.innerHTML = `
|
|
||||||
<div class="checkmark">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none">
|
|
||||||
<path d="M5 13l4 4L19 7" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="message">저장 완료!</div>
|
|
||||||
<div class="sub-message">${count}명의 근무 현황이 저장되었습니다</div>
|
|
||||||
`;
|
|
||||||
document.body.appendChild(overlay);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
overlay.style.opacity = '0';
|
|
||||||
overlay.style.transition = 'opacity 0.3s ease';
|
|
||||||
setTimeout(() => overlay.remove(), 300);
|
|
||||||
}, 1500);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 저장 상태 업데이트
|
|
||||||
function updateSaveStatus() {
|
function updateSaveStatus() {
|
||||||
const statusEl = document.getElementById('saveStatus');
|
const statusEl = document.getElementById('saveStatus');
|
||||||
const saveBtn = document.getElementById('saveBtn');
|
const saveBtn = document.getElementById('saveBtn');
|
||||||
|
|
||||||
if (isAlreadySaved) {
|
if (isAlreadySaved) {
|
||||||
statusEl.innerHTML = '<span class="status-badge saved">✓ 이 날짜의 근무 현황이 저장되어 있습니다</span>';
|
statusEl.innerHTML = '이 날짜의 근무 현황이 저장되어 있습니다';
|
||||||
saveBtn.textContent = '수정하여 다시 저장';
|
statusEl.className = 'save-status saved';
|
||||||
saveBtn.classList.add('saved');
|
saveBtn.textContent = '수정 저장';
|
||||||
} else {
|
} else {
|
||||||
statusEl.innerHTML = '<span class="status-badge unsaved">⚠ 아직 저장되지 않았습니다</span>';
|
statusEl.innerHTML = '아직 저장되지 않았습니다';
|
||||||
saveBtn.textContent = '근무 현황 저장';
|
statusEl.className = 'save-status unsaved';
|
||||||
saveBtn.classList.remove('saved');
|
saveBtn.textContent = '저장';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveWorkStatus() {
|
async function saveWorkStatus() {
|
||||||
const date = document.getElementById('selectedDate').value;
|
const date = document.getElementById('selectedDate').value;
|
||||||
if (!date) return showToast('날짜를 선택해주세요.', 'error');
|
if (!date) return alert('날짜를 선택해주세요.');
|
||||||
|
|
||||||
if (isSaving) return;
|
if (isSaving) return;
|
||||||
|
|
||||||
const saveBtn = document.getElementById('saveBtn');
|
const saveBtn = document.getElementById('saveBtn');
|
||||||
|
|
||||||
// attendance_type_id 매핑 (DB의 work_attendance_types 테이블)
|
// work_attendance_types: 1=REGULAR, 2=OVERTIME, 3=PARTIAL, 4=VACATION
|
||||||
const typeIdMap = {
|
const typeIdMap = {
|
||||||
'normal': 1, // NORMAL
|
'normal': 1,
|
||||||
'annual': 5, // VACATION
|
'annual': 4,
|
||||||
'half': 5, // VACATION (반차)
|
'half': 4,
|
||||||
'quarter': 5, // VACATION (반반차)
|
'quarter': 4,
|
||||||
'early': 3, // EARLY_LEAVE
|
'early': 3,
|
||||||
'overtime': 1 // NORMAL (연장근로는 정상출근 + 추가시간)
|
'overtime': 2
|
||||||
};
|
};
|
||||||
|
|
||||||
// vacation_type_id 매핑 (필요한 경우)
|
// vacation_types: 1=ANNUAL_FULL, 2=ANNUAL_HALF, 3=ANNUAL_QUARTER
|
||||||
const vacationTypeIdMap = {
|
const vacationTypeIdMap = {
|
||||||
'annual': 1, // ANNUAL
|
'annual': 1,
|
||||||
'half': 2, // HALF_ANNUAL
|
'half': 2,
|
||||||
'quarter': null, // 반반차는 별도 처리 필요할 수 있음
|
'quarter': 3,
|
||||||
};
|
};
|
||||||
|
|
||||||
const recordsToSave = workers.map(w => {
|
const recordsToSave = workers.map(w => {
|
||||||
@@ -617,10 +494,8 @@
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// 저장 시작 - 버튼 상태 변경
|
|
||||||
isSaving = true;
|
isSaving = true;
|
||||||
saveBtn.disabled = true;
|
saveBtn.disabled = true;
|
||||||
saveBtn.classList.add('saving');
|
|
||||||
saveBtn.textContent = '저장 중...';
|
saveBtn.textContent = '저장 중...';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -636,10 +511,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (fail === 0) {
|
if (fail === 0) {
|
||||||
// 성공 - 오버레이 표시
|
alert(`${ok}명 저장 완료`);
|
||||||
showSaveOverlay(ok);
|
|
||||||
isAlreadySaved = true;
|
isAlreadySaved = true;
|
||||||
// 모든 작업자 저장됨 표시
|
|
||||||
workers.forEach(w => {
|
workers.forEach(w => {
|
||||||
if (workStatus[w.worker_id]) {
|
if (workStatus[w.worker_id]) {
|
||||||
workStatus[w.worker_id].isSaved = true;
|
workStatus[w.worker_id].isSaved = true;
|
||||||
@@ -648,17 +521,16 @@
|
|||||||
render();
|
render();
|
||||||
updateSaveStatus();
|
updateSaveStatus();
|
||||||
} else if (ok > 0) {
|
} else if (ok > 0) {
|
||||||
showToast(`${ok}명 성공, ${fail}명 실패`, 'error');
|
alert(`${ok}명 성공, ${fail}명 실패`);
|
||||||
} else {
|
} else {
|
||||||
showToast('저장에 실패했습니다', 'error');
|
alert('저장에 실패했습니다');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
showToast('저장 중 오류가 발생했습니다', 'error');
|
alert('저장 중 오류가 발생했습니다');
|
||||||
} finally {
|
} finally {
|
||||||
isSaving = false;
|
isSaving = false;
|
||||||
saveBtn.disabled = false;
|
saveBtn.disabled = false;
|
||||||
saveBtn.classList.remove('saving');
|
|
||||||
updateSaveStatus();
|
updateSaveStatus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<title>일일순회점검 | (주)테크니컬코리아</title>
|
<title>일일순회점검 | (주)테크니컬코리아</title>
|
||||||
<link rel="stylesheet" href="/css/design-system.css">
|
<link rel="stylesheet" href="/css/design-system.css">
|
||||||
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
|
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
|
||||||
<link rel="stylesheet" href="/css/daily-patrol.css?v=1">
|
<link rel="stylesheet" href="/css/daily-patrol.css?v=3">
|
||||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||||
<script src="/js/api-base.js"></script>
|
<script src="/js/api-base.js"></script>
|
||||||
<script src="/js/app-init.js?v=2" defer></script>
|
<script src="/js/app-init.js?v=2" defer></script>
|
||||||
@@ -27,34 +27,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 점검 세션 선택 영역 -->
|
<!-- 점검 시작 영역 -->
|
||||||
<div class="patrol-session-selector">
|
<div class="patrol-start-section">
|
||||||
<div class="patrol-date-time">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="patrolDate">점검 일자</label>
|
|
||||||
<input type="date" id="patrolDate" class="form-control">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>점검 시간대</label>
|
|
||||||
<div class="patrol-time-buttons">
|
|
||||||
<button type="button" class="patrol-time-btn active" data-time="morning">오전</button>
|
|
||||||
<button type="button" class="patrol-time-btn" data-time="afternoon">오후</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="categorySelect">공장 선택</label>
|
|
||||||
<select id="categorySelect" class="form-control">
|
|
||||||
<option value="">공장 선택...</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn btn-primary" id="startPatrolBtn" onclick="startPatrol()">
|
|
||||||
순회점검 시작
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<!-- 오늘 점검 현황 요약 -->
|
<!-- 오늘 점검 현황 요약 -->
|
||||||
<div id="todayStatusSummary" class="today-status-summary">
|
<div id="todayStatusSummary" class="today-status-summary">
|
||||||
<!-- JS에서 렌더링 -->
|
<!-- JS에서 렌더링 -->
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" class="btn btn-primary btn-lg" id="startPatrolBtn" onclick="showFactorySelection()">
|
||||||
|
<span class="btn-icon">▶</span> 순회점검 시작
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 공장 선택 영역 (점검 시작 후 표시) -->
|
||||||
|
<div id="factorySelectionArea" class="factory-selection-area" style="display: none;">
|
||||||
|
<div class="factory-selection-header">
|
||||||
|
<h3>공장을 선택하세요</h3>
|
||||||
|
<p class="factory-selection-subtitle" id="patrolSessionInfo"><!-- JS에서 렌더링 --></p>
|
||||||
|
</div>
|
||||||
|
<div id="factoryCardsContainer" class="factory-cards-container">
|
||||||
|
<!-- JS에서 공장 카드 렌더링 -->
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 점검 진행 영역 (세션 시작 후 표시) -->
|
<!-- 점검 진행 영역 (세션 시작 후 표시) -->
|
||||||
@@ -205,6 +197,6 @@
|
|||||||
}, 50);
|
}, 50);
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<script src="/js/daily-patrol.js?v=1"></script>
|
<script src="/js/daily-patrol.js?v=3"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -168,8 +168,15 @@
|
|||||||
|
|
||||||
<!-- 스크립트 -->
|
<!-- 스크립트 -->
|
||||||
<script src="/js/api-base.js"></script>
|
<script src="/js/api-base.js"></script>
|
||||||
<script src="/js/app-init.js?v=2" defer></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://instant.page/5.2.0" type="module"></script>
|
||||||
<script type="module" src="/js/daily-work-report.js?v=28"></script>
|
|
||||||
|
<!-- 작업보고서 모듈 (리팩토링된 구조) -->
|
||||||
|
<script src="/js/daily-work-report/state.js?v=1"></script>
|
||||||
|
<script src="/js/daily-work-report/utils.js?v=1"></script>
|
||||||
|
<script src="/js/daily-work-report/api.js?v=1"></script>
|
||||||
|
|
||||||
|
<!-- 기존 UI 로직 (점진적 마이그레이션) -->
|
||||||
|
<script type="module" src="/js/daily-work-report.js?v=29"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -661,6 +661,12 @@
|
|||||||
<div class="toast-container" id="toastContainer"></div>
|
<div class="toast-container" id="toastContainer"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="module" src="/js/tbm.js?v=3"></script>
|
<!-- TBM 모듈 (리팩토링된 구조) -->
|
||||||
|
<script src="/js/tbm/state.js?v=1"></script>
|
||||||
|
<script src="/js/tbm/utils.js?v=1"></script>
|
||||||
|
<script src="/js/tbm/api.js?v=1"></script>
|
||||||
|
|
||||||
|
<!-- 기존 UI 로직 (점진적 마이그레이션) -->
|
||||||
|
<script type="module" src="/js/tbm.js?v=4"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user