feat: 다수 기능 개선 - 순찰, 출근, 작업분석, 모바일 UI 등
- 순찰/점검 기능 개선 (zone-detail 페이지 추가) - 출근/근태 시스템 개선 (연차 조회, 근무현황) - 작업분석 대분류 그룹화 및 마이그레이션 스크립트 - 모바일 네비게이션 UI 추가 - NAS 배포 도구 및 문서 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -91,14 +91,11 @@ function getAuthData() {
|
||||
// ========== 시간 업데이트 ========== //
|
||||
function updateCurrentTime() {
|
||||
const now = new Date();
|
||||
const timeString = now.toLocaleTimeString('ko-KR', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(now.getSeconds()).padStart(2, '0');
|
||||
if (elements.timeValue) {
|
||||
elements.timeValue.textContent = timeString;
|
||||
elements.timeValue.textContent = `${hours}시 ${minutes}분 ${seconds}초`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,412 +0,0 @@
|
||||
/**
|
||||
* annual-vacation-overview.js
|
||||
* 연간 연차 현황 페이지 로직 (2-탭 구조)
|
||||
*/
|
||||
|
||||
import { API_BASE_URL } from './api-config.js';
|
||||
|
||||
// 전역 변수
|
||||
let annualUsageChart = null;
|
||||
let currentYear = new Date().getFullYear();
|
||||
let vacationRequests = [];
|
||||
|
||||
/**
|
||||
* 페이지 초기화
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// 관리자 권한 체크
|
||||
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
const isAdmin = user.role === 'Admin' || [1, 2].includes(user.role_id);
|
||||
|
||||
if (!isAdmin) {
|
||||
alert('관리자만 접근할 수 있습니다');
|
||||
window.location.href = '/pages/dashboard.html';
|
||||
return;
|
||||
}
|
||||
|
||||
initializeYearSelector();
|
||||
initializeMonthSelector();
|
||||
initializeEventListeners();
|
||||
await loadAnnualUsageData();
|
||||
});
|
||||
|
||||
/**
|
||||
* 연도 선택 초기화
|
||||
*/
|
||||
function initializeYearSelector() {
|
||||
const yearSelect = document.getElementById('yearSelect');
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
// 최근 5년, 현재 연도, 다음 연도
|
||||
for (let year = currentYear - 5; year <= currentYear + 1; year++) {
|
||||
const option = document.createElement('option');
|
||||
option.value = year;
|
||||
option.textContent = `${year}년`;
|
||||
if (year === currentYear) {
|
||||
option.selected = true;
|
||||
}
|
||||
yearSelect.appendChild(option);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 월 선택 초기화
|
||||
*/
|
||||
function initializeMonthSelector() {
|
||||
const monthSelect = document.getElementById('monthSelect');
|
||||
const currentMonth = new Date().getMonth() + 1;
|
||||
|
||||
// 현재 월을 기본 선택
|
||||
monthSelect.value = currentMonth;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 리스너 초기화
|
||||
*/
|
||||
function initializeEventListeners() {
|
||||
// 탭 전환
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const tabName = e.target.dataset.tab;
|
||||
switchTab(tabName);
|
||||
});
|
||||
});
|
||||
|
||||
// 조회 버튼
|
||||
document.getElementById('refreshBtn').addEventListener('click', async () => {
|
||||
await loadAnnualUsageData();
|
||||
const activeTab = document.querySelector('.tab-btn.active').dataset.tab;
|
||||
if (activeTab === 'monthlyDetails') {
|
||||
await loadMonthlyDetails();
|
||||
}
|
||||
});
|
||||
|
||||
// 연도 변경 시 자동 조회
|
||||
document.getElementById('yearSelect').addEventListener('change', async () => {
|
||||
await loadAnnualUsageData();
|
||||
const activeTab = document.querySelector('.tab-btn.active').dataset.tab;
|
||||
if (activeTab === 'monthlyDetails') {
|
||||
await loadMonthlyDetails();
|
||||
}
|
||||
});
|
||||
|
||||
// 월 선택 변경 시
|
||||
document.getElementById('monthSelect').addEventListener('change', loadMonthlyDetails);
|
||||
|
||||
// 엑셀 다운로드
|
||||
document.getElementById('exportExcelBtn').addEventListener('click', exportToExcel);
|
||||
}
|
||||
|
||||
/**
|
||||
* 탭 전환
|
||||
*/
|
||||
function switchTab(tabName) {
|
||||
// 탭 버튼 활성화
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
if (btn.dataset.tab === tabName) {
|
||||
btn.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
// 탭 콘텐츠 활성화
|
||||
document.querySelectorAll('.tab-content').forEach(content => {
|
||||
content.classList.remove('active');
|
||||
});
|
||||
|
||||
if (tabName === 'annualUsage') {
|
||||
document.getElementById('annualUsageTab').classList.add('active');
|
||||
} else if (tabName === 'monthlyDetails') {
|
||||
document.getElementById('monthlyDetailsTab').classList.add('active');
|
||||
loadMonthlyDetails();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 연간 사용 데이터 로드 (탭 1)
|
||||
*/
|
||||
async function loadAnnualUsageData() {
|
||||
const year = document.getElementById('yearSelect').value;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
// 해당 연도의 모든 승인된 휴가 신청 조회
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/vacation-requests?start_date=${year}-01-01&end_date=${year}-12-31&status=approved`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('휴가 데이터를 불러오는데 실패했습니다');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
vacationRequests = result.data || [];
|
||||
|
||||
// 월별로 집계
|
||||
const monthlyData = aggregateMonthlyUsage(vacationRequests);
|
||||
|
||||
// 잔여 일수 계산 (올해 총 부여 - 사용)
|
||||
const remainingDays = await calculateRemainingDays(year);
|
||||
|
||||
updateAnnualUsageChart(monthlyData, remainingDays);
|
||||
} catch (error) {
|
||||
console.error('연간 사용 데이터 로드 오류:', error);
|
||||
showToast('데이터를 불러오는데 실패했습니다', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별 사용 일수 집계
|
||||
*/
|
||||
function aggregateMonthlyUsage(requests) {
|
||||
const monthlyUsage = Array(12).fill(0); // 1월~12월
|
||||
|
||||
requests.forEach(req => {
|
||||
const startDate = new Date(req.start_date);
|
||||
const endDate = new Date(req.end_date);
|
||||
const daysUsed = req.days_used || 0;
|
||||
|
||||
// 간단한 집계: 시작일의 월에 모든 일수를 할당
|
||||
// (더 정교한 계산이 필요하면 일자별로 쪼개야 함)
|
||||
const month = startDate.getMonth(); // 0-11
|
||||
monthlyUsage[month] += daysUsed;
|
||||
});
|
||||
|
||||
return monthlyUsage;
|
||||
}
|
||||
|
||||
/**
|
||||
* 잔여 일수 계산
|
||||
*/
|
||||
async function calculateRemainingDays(year) {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
// 전체 작업자의 휴가 잔액 조회
|
||||
const response = await fetch(`${API_BASE_URL}/api/vacation-balances/year/${year}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const balances = result.data || [];
|
||||
|
||||
// 전체 잔여 일수 합계
|
||||
const totalRemaining = balances.reduce((sum, item) => sum + (item.remaining_days || 0), 0);
|
||||
return totalRemaining;
|
||||
} catch (error) {
|
||||
console.error('잔여 일수 계산 오류:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 연간 사용 차트 업데이트
|
||||
*/
|
||||
function updateAnnualUsageChart(monthlyData, remainingDays) {
|
||||
const ctx = document.getElementById('annualUsageChart');
|
||||
|
||||
// 기존 차트 삭제
|
||||
if (annualUsageChart) {
|
||||
annualUsageChart.destroy();
|
||||
}
|
||||
|
||||
const labels = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월', '잔여'];
|
||||
const data = [...monthlyData, remainingDays];
|
||||
|
||||
annualUsageChart = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: '일수',
|
||||
data: data,
|
||||
backgroundColor: data.map((_, idx) =>
|
||||
idx === 12 ? 'rgba(16, 185, 129, 0.8)' : 'rgba(59, 130, 246, 0.8)'
|
||||
),
|
||||
borderColor: data.map((_, idx) =>
|
||||
idx === 12 ? 'rgba(16, 185, 129, 1)' : 'rgba(59, 130, 246, 1)'
|
||||
),
|
||||
borderWidth: 1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
return `${context.parsed.y}일`;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
stepSize: 5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별 상세 기록 로드 (탭 2)
|
||||
*/
|
||||
async function loadMonthlyDetails() {
|
||||
const year = document.getElementById('yearSelect').value;
|
||||
const month = document.getElementById('monthSelect').value;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
// 해당 월의 모든 휴가 신청 조회 (승인된 것만)
|
||||
const startDate = `${year}-${String(month).padStart(2, '0')}-01`;
|
||||
const lastDay = new Date(year, month, 0).getDate();
|
||||
const endDate = `${year}-${String(month).padStart(2, '0')}-${lastDay}`;
|
||||
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/vacation-requests?start_date=${startDate}&end_date=${endDate}&status=approved`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('월별 데이터를 불러오는데 실패했습니다');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const monthlyRequests = result.data || [];
|
||||
|
||||
updateMonthlyTable(monthlyRequests);
|
||||
} catch (error) {
|
||||
console.error('월별 상세 기록 로드 오류:', error);
|
||||
showToast('데이터를 불러오는데 실패했습니다', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별 테이블 업데이트
|
||||
*/
|
||||
function updateMonthlyTable(requests) {
|
||||
const tbody = document.getElementById('monthlyTableBody');
|
||||
|
||||
if (requests.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="7" class="loading-state">
|
||||
<p>데이터가 없습니다</p>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = requests.map(req => {
|
||||
const statusText = req.status === 'approved' ? '승인' : req.status === 'pending' ? '대기' : '거부';
|
||||
const statusClass = req.status === 'approved' ? 'success' : req.status === 'pending' ? 'warning' : 'danger';
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${req.worker_name}</td>
|
||||
<td>${req.vacation_type_name}</td>
|
||||
<td>${req.start_date}</td>
|
||||
<td>${req.end_date}</td>
|
||||
<td>${req.days_used}일</td>
|
||||
<td>${req.reason || '-'}</td>
|
||||
<td><span class="badge badge-${statusClass}">${statusText}</span></td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 엑셀 다운로드
|
||||
*/
|
||||
function exportToExcel() {
|
||||
const year = document.getElementById('yearSelect').value;
|
||||
const month = document.getElementById('monthSelect').value;
|
||||
const tbody = document.getElementById('monthlyTableBody');
|
||||
|
||||
// 테이블에 데이터가 없으면 중단
|
||||
if (!tbody.querySelector('tr:not(.loading-state)')) {
|
||||
showToast('다운로드할 데이터가 없습니다', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// CSV 형식으로 데이터 생성
|
||||
const headers = ['작업자명', '휴가유형', '시작일', '종료일', '사용일수', '사유', '상태'];
|
||||
const rows = Array.from(tbody.querySelectorAll('tr:not(.loading-state)')).map(tr => {
|
||||
const cells = tr.querySelectorAll('td');
|
||||
return Array.from(cells).map(cell => {
|
||||
// badge 클래스가 있으면 텍스트만 추출
|
||||
const badge = cell.querySelector('.badge');
|
||||
return badge ? badge.textContent : cell.textContent;
|
||||
});
|
||||
});
|
||||
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...rows.map(row => row.join(','))
|
||||
].join('\n');
|
||||
|
||||
// BOM 추가 (한글 깨짐 방지)
|
||||
const BOM = '\uFEFF';
|
||||
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', `월별_연차_상세_${year}_${month}월.csv`);
|
||||
link.style.visibility = 'hidden';
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
showToast('엑셀 파일이 다운로드되었습니다', 'success');
|
||||
}
|
||||
|
||||
/**
|
||||
* 토스트 메시지 표시
|
||||
*/
|
||||
function showToast(message, type = 'info') {
|
||||
const container = document.getElementById('toastContainer');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.textContent = message;
|
||||
|
||||
container.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.add('show');
|
||||
}, 10);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
setTimeout(() => {
|
||||
container.removeChild(toast);
|
||||
}, 300);
|
||||
}, 3000);
|
||||
}
|
||||
@@ -49,6 +49,13 @@
|
||||
function getApiBaseUrl() {
|
||||
const hostname = window.location.hostname;
|
||||
const protocol = window.location.protocol;
|
||||
|
||||
// 프로덕션 환경 (technicalkorea.net 도메인)
|
||||
if (hostname.includes('technicalkorea.net')) {
|
||||
return `${protocol}//${hostname}${API_PATH}`;
|
||||
}
|
||||
|
||||
// 개발 환경 (localhost 또는 IP)
|
||||
return `${protocol}//${hostname}:${API_PORT}${API_PATH}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
// ===== 캐시 설정 =====
|
||||
const CACHE_DURATION = 10 * 60 * 1000; // 10분
|
||||
const COMPONENT_CACHE_PREFIX = 'component_';
|
||||
const COMPONENT_CACHE_PREFIX = 'component_v3_';
|
||||
|
||||
// ===== 인증 함수 =====
|
||||
function isLoggedIn() {
|
||||
@@ -368,7 +368,12 @@
|
||||
function updateDateTime() {
|
||||
const now = new Date();
|
||||
const timeEl = document.getElementById('timeValue');
|
||||
if (timeEl) timeEl.textContent = now.toLocaleTimeString('ko-KR', { hour12: false });
|
||||
if (timeEl) {
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(now.getSeconds()).padStart(2, '0');
|
||||
timeEl.textContent = `${hours}시 ${minutes}분 ${seconds}초`;
|
||||
}
|
||||
|
||||
const dateEl = document.getElementById('dateValue');
|
||||
if (dateEl) {
|
||||
|
||||
@@ -10,6 +10,18 @@ let selectedWorkplace = null;
|
||||
let itemTypes = []; // 물품 유형
|
||||
let workplaceItems = []; // 현재 작업장 물품
|
||||
let isItemEditMode = false;
|
||||
let workplaceDetail = null; // 작업장 상세 정보
|
||||
|
||||
// XSS 방지를 위한 HTML 이스케이프 함수
|
||||
function escapeHtml(str) {
|
||||
if (str === null || str === undefined) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// 이미지 URL 헬퍼 함수 (정적 파일용 - /api 경로 제외)
|
||||
function getImageUrl(path) {
|
||||
@@ -62,6 +74,77 @@ async function initializePage() {
|
||||
loadItemTypes(),
|
||||
loadTodayStatus()
|
||||
]);
|
||||
|
||||
// 저장된 세션 상태 복원 (구역 상세에서 돌아온 경우)
|
||||
await restoreSessionState();
|
||||
}
|
||||
|
||||
// 세션 상태 저장 (페이지 이동 전)
|
||||
function saveSessionState() {
|
||||
if (currentSession) {
|
||||
const state = {
|
||||
session: currentSession,
|
||||
categoryId: currentSession.category_id,
|
||||
checkRecords: checkRecords,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
sessionStorage.setItem('patrolSessionState', JSON.stringify(state));
|
||||
}
|
||||
}
|
||||
|
||||
// 세션 상태 복원
|
||||
async function restoreSessionState() {
|
||||
const savedState = sessionStorage.getItem('patrolSessionState');
|
||||
if (!savedState) return;
|
||||
|
||||
try {
|
||||
const state = JSON.parse(savedState);
|
||||
// 5분 이내의 상태만 복원
|
||||
if (Date.now() - state.timestamp > 5 * 60 * 1000) {
|
||||
sessionStorage.removeItem('patrolSessionState');
|
||||
return;
|
||||
}
|
||||
|
||||
// categories가 비어있으면 복원 불가
|
||||
if (!categories || categories.length === 0) {
|
||||
console.log('카테고리 목록이 없어 세션 복원 불가');
|
||||
sessionStorage.removeItem('patrolSessionState');
|
||||
return;
|
||||
}
|
||||
|
||||
// 해당 카테고리가 존재하는지 확인
|
||||
const category = categories.find(c => c.category_id == state.categoryId);
|
||||
if (!category) {
|
||||
console.log('저장된 카테고리를 찾을 수 없음:', state.categoryId);
|
||||
sessionStorage.removeItem('patrolSessionState');
|
||||
return;
|
||||
}
|
||||
|
||||
// 세션 복원
|
||||
currentSession = state.session;
|
||||
checkRecords = state.checkRecords || {};
|
||||
|
||||
// 작업장 목록 로드
|
||||
await loadWorkplaces(state.categoryId);
|
||||
|
||||
// 체크리스트 항목 로드
|
||||
await loadChecklistItems(state.categoryId);
|
||||
|
||||
// UI 표시
|
||||
document.getElementById('startPatrolBtn').style.display = 'none';
|
||||
document.getElementById('factorySelectionArea').style.display = 'none';
|
||||
document.getElementById('patrolArea').style.display = 'block';
|
||||
renderSessionInfo();
|
||||
renderWorkplaceMap();
|
||||
|
||||
console.log('세션 상태 복원 완료:', state.categoryId);
|
||||
|
||||
// 복원 후 저장 상태 삭제
|
||||
sessionStorage.removeItem('patrolSessionState');
|
||||
} catch (error) {
|
||||
console.error('세션 상태 복원 실패:', error);
|
||||
sessionStorage.removeItem('patrolSessionState');
|
||||
}
|
||||
}
|
||||
|
||||
// 공장(대분류) 목록 로드
|
||||
@@ -206,10 +289,36 @@ function renderTodayStatus(statusList) {
|
||||
// 작업장 목록 로드
|
||||
async function loadWorkplaces(categoryId) {
|
||||
try {
|
||||
// 작업장 목록 로드
|
||||
const response = await axios.get(`/workplaces?category_id=${categoryId}`);
|
||||
if (response.data.success) {
|
||||
workplaces = response.data.data;
|
||||
}
|
||||
|
||||
// 지도 영역(좌표) 로드
|
||||
try {
|
||||
const regionsResponse = await axios.get(`/workplaces/categories/${categoryId}/map-regions`);
|
||||
if (regionsResponse.data.success && regionsResponse.data.data) {
|
||||
// 작업장에 좌표 정보 병합
|
||||
const regions = regionsResponse.data.data;
|
||||
workplaces = workplaces.map(wp => {
|
||||
const region = regions.find(r => r.workplace_id === wp.workplace_id);
|
||||
if (region) {
|
||||
// x_start, y_start를 x_percent, y_percent로 매핑
|
||||
return {
|
||||
...wp,
|
||||
x_percent: region.x_start,
|
||||
y_percent: region.y_start,
|
||||
x_end: region.x_end,
|
||||
y_end: region.y_end
|
||||
};
|
||||
}
|
||||
return wp;
|
||||
});
|
||||
}
|
||||
} catch (regError) {
|
||||
console.log('지도 영역 로드 스킵:', regError.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('작업장 목록 로드 실패:', error);
|
||||
}
|
||||
@@ -271,30 +380,59 @@ function renderWorkplaceMap() {
|
||||
mapContainer.style.display = 'block';
|
||||
|
||||
// 좌표가 있는 작업장만 마커 추가
|
||||
const hasMarkers = workplaces.some(wp => wp.x_percent && wp.y_percent);
|
||||
const hasMarkers = workplaces.some(wp => wp.x_percent !== undefined && wp.y_percent !== undefined);
|
||||
|
||||
workplaces.forEach(wp => {
|
||||
if (wp.x_percent && wp.y_percent) {
|
||||
const marker = document.createElement('div');
|
||||
marker.className = 'workplace-marker';
|
||||
marker.style.left = `${parseFloat(wp.x_percent) || 0}%`;
|
||||
marker.style.top = `${parseFloat(wp.y_percent) || 0}%`;
|
||||
marker.textContent = wp.workplace_name; // textContent는 자동 이스케이프
|
||||
marker.dataset.workplaceId = wp.workplace_id;
|
||||
marker.onclick = () => selectWorkplace(wp.workplace_id);
|
||||
|
||||
// 점검 상태에 따른 스타일
|
||||
const records = checkRecords[wp.workplace_id];
|
||||
if (records && records.some(r => r.is_checked)) {
|
||||
marker.classList.add(records.every(r => r.is_checked) ? 'completed' : 'in-progress');
|
||||
// 마커 위치 정보를 먼저 계산
|
||||
const markerData = workplaces
|
||||
.filter(wp => wp.x_percent !== undefined && wp.y_percent !== undefined)
|
||||
.map(wp => {
|
||||
let centerX = parseFloat(wp.x_percent) || 0;
|
||||
let centerY = parseFloat(wp.y_percent) || 0;
|
||||
if (wp.x_end && wp.y_end) {
|
||||
centerX = (parseFloat(wp.x_percent) + parseFloat(wp.x_end)) / 2;
|
||||
centerY = (parseFloat(wp.y_percent) + parseFloat(wp.y_end)) / 2;
|
||||
}
|
||||
return { wp, centerX, centerY };
|
||||
});
|
||||
|
||||
mapContainer.appendChild(marker);
|
||||
// y좌표 기준 정렬 (아래에 있을수록 나중에 추가 = 위에 표시)
|
||||
markerData.sort((a, b) => a.centerY - b.centerY);
|
||||
|
||||
markerData.forEach((data, index) => {
|
||||
const { wp, centerX, centerY } = data;
|
||||
const marker = document.createElement('div');
|
||||
marker.className = 'workplace-marker';
|
||||
|
||||
// 밀집도 체크 - 근처에 다른 마커가 있으면 compact 클래스 추가
|
||||
const nearbyMarkers = markerData.filter(other =>
|
||||
other !== data &&
|
||||
Math.abs(other.centerX - centerX) < 12 &&
|
||||
Math.abs(other.centerY - centerY) < 12
|
||||
);
|
||||
if (nearbyMarkers.length > 0) {
|
||||
marker.classList.add('compact');
|
||||
}
|
||||
|
||||
marker.style.left = `${centerX}%`;
|
||||
marker.style.top = `${centerY}%`;
|
||||
// y좌표가 클수록 (아래쪽일수록) z-index가 높아서 위에 표시
|
||||
marker.style.zIndex = Math.floor(centerY) + 10;
|
||||
marker.textContent = wp.workplace_name;
|
||||
marker.dataset.workplaceId = wp.workplace_id;
|
||||
marker.onclick = () => goToZoneDetail(wp.workplace_id);
|
||||
|
||||
// 점검 상태에 따른 스타일
|
||||
const records = checkRecords[wp.workplace_id];
|
||||
if (records && records.some(r => r.is_checked)) {
|
||||
marker.classList.add(records.every(r => r.is_checked) ? 'completed' : 'in-progress');
|
||||
}
|
||||
|
||||
mapContainer.appendChild(marker);
|
||||
});
|
||||
|
||||
// 좌표가 없는 작업장이 있으면 카드 목록도 표시
|
||||
if (!hasMarkers || workplaces.some(wp => !wp.x_percent || !wp.y_percent)) {
|
||||
const hasWorkplacesWithoutCoords = workplaces.some(wp => wp.x_percent === undefined || wp.y_percent === undefined);
|
||||
if (!hasMarkers || hasWorkplacesWithoutCoords) {
|
||||
listContainer.style.display = 'grid';
|
||||
renderWorkplaceCards(listContainer);
|
||||
} else {
|
||||
@@ -308,6 +446,13 @@ function renderWorkplaceMap() {
|
||||
}
|
||||
}
|
||||
|
||||
// 구역 상세 페이지로 이동
|
||||
function goToZoneDetail(workplaceId) {
|
||||
// 현재 세션 상태 저장
|
||||
saveSessionState();
|
||||
window.location.href = `/pages/inspection/zone-detail.html?id=${workplaceId}`;
|
||||
}
|
||||
|
||||
// 작업장 카드 렌더링
|
||||
function renderWorkplaceCards(container) {
|
||||
container.innerHTML = workplaces.map(wp => {
|
||||
@@ -319,7 +464,7 @@ function renderWorkplaceCards(container) {
|
||||
return `
|
||||
<div class="workplace-card ${isCompleted ? 'completed' : ''} ${isInProgress && !isCompleted ? 'in-progress' : ''} ${selectedWorkplace?.workplace_id === wp.workplace_id ? 'selected' : ''}"
|
||||
data-workplace-id="${workplaceId}"
|
||||
onclick="selectWorkplace(${workplaceId})">
|
||||
onclick="goToZoneDetail(${workplaceId})">
|
||||
<div class="workplace-card-name">${escapeHtml(wp.workplace_name)}</div>
|
||||
<div class="workplace-card-status">
|
||||
${isCompleted ? '점검완료' : (isInProgress ? '점검중' : '미점검')}
|
||||
@@ -360,6 +505,9 @@ async function selectWorkplace(workplaceId) {
|
||||
// 물품 현황 로드 및 표시
|
||||
await loadWorkplaceItems(workplaceId);
|
||||
|
||||
// 작업장 상세 정보 로드 (신고, TBM, 출입 등)
|
||||
await loadWorkplaceDetail(workplaceId);
|
||||
|
||||
// 액션 버튼 표시
|
||||
document.getElementById('checklistActions').style.display = 'flex';
|
||||
}
|
||||
@@ -763,5 +911,339 @@ function formatDate(dateStr) {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeItemModal();
|
||||
closeWorkplaceDetailPanel();
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== 작업장 상세 정보 패널 ====================
|
||||
|
||||
// 작업장 상세 정보 로드
|
||||
async function loadWorkplaceDetail(workplaceId) {
|
||||
try {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const response = await axios.get(`/patrol/workplaces/${workplaceId}/detail?date=${today}`);
|
||||
if (response.data.success) {
|
||||
workplaceDetail = response.data.data;
|
||||
renderWorkplaceDetailPanel();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('작업장 상세 정보 로드 실패:', error);
|
||||
// 에러 발생 시에도 기본 패널 표시
|
||||
workplaceDetail = null;
|
||||
renderWorkplaceDetailPanel();
|
||||
}
|
||||
}
|
||||
|
||||
// 상세 정보 패널 렌더링
|
||||
function renderWorkplaceDetailPanel() {
|
||||
let panel = document.getElementById('workplaceDetailPanel');
|
||||
if (!panel) {
|
||||
panel = document.createElement('div');
|
||||
panel.id = 'workplaceDetailPanel';
|
||||
panel.className = 'workplace-detail-panel';
|
||||
document.body.appendChild(panel);
|
||||
}
|
||||
|
||||
if (!workplaceDetail || !selectedWorkplace) {
|
||||
panel.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const { workplace, equipments, repairRequests, workIssues, visitRecords, tbmSessions, recentPatrol, summary } = workplaceDetail;
|
||||
|
||||
panel.innerHTML = `
|
||||
<div class="detail-panel-header">
|
||||
<div class="detail-panel-title">
|
||||
<h3>${escapeHtml(workplace.workplace_name)}</h3>
|
||||
<span class="detail-panel-subtitle">${escapeHtml(workplace.category_name || '')}</span>
|
||||
</div>
|
||||
<button class="detail-panel-close" onclick="closeWorkplaceDetailPanel()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="detail-panel-summary">
|
||||
<div class="summary-item">
|
||||
<span class="summary-value">${summary.equipmentCount}</span>
|
||||
<span class="summary-label">설비</span>
|
||||
</div>
|
||||
<div class="summary-item ${summary.pendingRepairs > 0 ? 'warning' : ''}">
|
||||
<span class="summary-value">${summary.pendingRepairs}</span>
|
||||
<span class="summary-label">수리요청</span>
|
||||
</div>
|
||||
<div class="summary-item ${summary.openIssues > 0 ? 'danger' : ''}">
|
||||
<span class="summary-value">${summary.openIssues}</span>
|
||||
<span class="summary-label">미해결 신고</span>
|
||||
</div>
|
||||
<div class="summary-item info">
|
||||
<span class="summary-value">${summary.todayVisitors}</span>
|
||||
<span class="summary-label">금일 방문자</span>
|
||||
</div>
|
||||
<div class="summary-item info">
|
||||
<span class="summary-value">${summary.todayTbmSessions}</span>
|
||||
<span class="summary-label">금일 TBM</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-panel-tabs">
|
||||
<button class="detail-tab active" data-tab="issues" onclick="switchDetailTab('issues')">
|
||||
🚨 신고/부적합 <span class="tab-badge ${workIssues.all.length > 0 ? 'show' : ''}">${workIssues.all.length}</span>
|
||||
</button>
|
||||
<button class="detail-tab" data-tab="equipment" onclick="switchDetailTab('equipment')">
|
||||
⚙️ 설비 <span class="tab-badge ${summary.needsAttention > 0 ? 'show warning' : ''}">${summary.needsAttention}</span>
|
||||
</button>
|
||||
<button class="detail-tab" data-tab="visits" onclick="switchDetailTab('visits')">
|
||||
🚶 출입 <span class="tab-badge ${visitRecords.length > 0 ? 'show' : ''}">${visitRecords.length}</span>
|
||||
</button>
|
||||
<button class="detail-tab" data-tab="tbm" onclick="switchDetailTab('tbm')">
|
||||
📋 TBM <span class="tab-badge ${tbmSessions.length > 0 ? 'show' : ''}">${tbmSessions.length}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="detail-panel-content">
|
||||
<!-- 신고/부적합 탭 -->
|
||||
<div class="detail-tab-content active" id="tab-issues">
|
||||
${renderIssuesTab(workIssues)}
|
||||
</div>
|
||||
|
||||
<!-- 설비 탭 -->
|
||||
<div class="detail-tab-content" id="tab-equipment">
|
||||
${renderEquipmentTab(equipments, repairRequests)}
|
||||
</div>
|
||||
|
||||
<!-- 출입 탭 -->
|
||||
<div class="detail-tab-content" id="tab-visits">
|
||||
${renderVisitsTab(visitRecords)}
|
||||
</div>
|
||||
|
||||
<!-- TBM 탭 -->
|
||||
<div class="detail-tab-content" id="tab-tbm">
|
||||
${renderTbmTab(tbmSessions)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
panel.style.display = 'flex';
|
||||
}
|
||||
|
||||
// 신고/부적합 탭 렌더링
|
||||
function renderIssuesTab(workIssues) {
|
||||
if (!workIssues.all.length) {
|
||||
return `<div class="detail-empty">최근 30일간 신고 내역이 없습니다.</div>`;
|
||||
}
|
||||
|
||||
const safetyIssues = workIssues.safety;
|
||||
const nonconformityIssues = workIssues.nonconformity;
|
||||
|
||||
let html = '';
|
||||
|
||||
if (safetyIssues.length > 0) {
|
||||
html += `
|
||||
<div class="issue-section">
|
||||
<h4 class="issue-section-title">🛡️ 안전 신고 (${safetyIssues.length})</h4>
|
||||
${safetyIssues.map(issue => renderIssueItem(issue)).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (nonconformityIssues.length > 0) {
|
||||
html += `
|
||||
<div class="issue-section">
|
||||
<h4 class="issue-section-title">⚠️ 부적합 사항 (${nonconformityIssues.length})</h4>
|
||||
${nonconformityIssues.map(issue => renderIssueItem(issue)).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html || `<div class="detail-empty">신고 내역이 없습니다.</div>`;
|
||||
}
|
||||
|
||||
// 신고 항목 렌더링
|
||||
function renderIssueItem(issue) {
|
||||
const statusColors = {
|
||||
'pending': 'pending',
|
||||
'received': 'info',
|
||||
'in_progress': 'warning',
|
||||
'completed': 'success',
|
||||
'closed': 'muted'
|
||||
};
|
||||
const statusLabels = {
|
||||
'pending': '대기',
|
||||
'received': '접수',
|
||||
'in_progress': '처리중',
|
||||
'completed': '완료',
|
||||
'closed': '종료'
|
||||
};
|
||||
const severityLabels = {
|
||||
'low': '경미',
|
||||
'medium': '보통',
|
||||
'high': '중요',
|
||||
'critical': '긴급'
|
||||
};
|
||||
|
||||
return `
|
||||
<div class="issue-item ${statusColors[issue.status] || ''}">
|
||||
<div class="issue-item-header">
|
||||
<span class="issue-title">${escapeHtml(issue.title)}</span>
|
||||
<span class="issue-status ${statusColors[issue.status] || ''}">${statusLabels[issue.status] || issue.status}</span>
|
||||
</div>
|
||||
<div class="issue-item-meta">
|
||||
<span class="issue-category">${escapeHtml(issue.category_name || '')}</span>
|
||||
<span class="issue-severity ${issue.severity}">${severityLabels[issue.severity] || ''}</span>
|
||||
<span class="issue-date">${formatDateTime(issue.created_at)}</span>
|
||||
</div>
|
||||
${issue.description ? `<div class="issue-desc">${escapeHtml(issue.description).slice(0, 100)}${issue.description.length > 100 ? '...' : ''}</div>` : ''}
|
||||
<div class="issue-reporter">신고자: ${escapeHtml(issue.reporter_name || '익명')}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 설비 탭 렌더링
|
||||
function renderEquipmentTab(equipments, repairRequests) {
|
||||
let html = '';
|
||||
|
||||
// 수리 요청 먼저 표시
|
||||
if (repairRequests.length > 0) {
|
||||
html += `
|
||||
<div class="equipment-section">
|
||||
<h4 class="equipment-section-title">🔧 수리 요청 (${repairRequests.length})</h4>
|
||||
${repairRequests.map(req => `
|
||||
<div class="repair-item ${req.priority}">
|
||||
<div class="repair-item-header">
|
||||
<span class="repair-equipment">${escapeHtml(req.equipment_name)} (${escapeHtml(req.equipment_code)})</span>
|
||||
<span class="repair-priority ${req.priority}">${getPriorityLabel(req.priority)}</span>
|
||||
</div>
|
||||
<div class="repair-category">${escapeHtml(req.repair_category)}</div>
|
||||
<div class="repair-desc">${escapeHtml(req.description || '')}</div>
|
||||
<div class="repair-date">${formatDate(req.request_date)}</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 설비 목록
|
||||
if (equipments.length > 0) {
|
||||
html += `
|
||||
<div class="equipment-section">
|
||||
<h4 class="equipment-section-title">📦 설비 현황 (${equipments.length})</h4>
|
||||
<div class="equipment-list">
|
||||
${equipments.map(eq => `
|
||||
<div class="equipment-item ${eq.needs_attention ? 'attention' : ''}">
|
||||
<span class="equipment-name">${escapeHtml(eq.equipment_name)}</span>
|
||||
<span class="equipment-code">${escapeHtml(eq.equipment_code)}</span>
|
||||
<span class="equipment-status ${eq.status}">${getEquipmentStatusLabel(eq.status)}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
html += `<div class="detail-empty">등록된 설비가 없습니다.</div>`;
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
// 출입 탭 렌더링
|
||||
function renderVisitsTab(visitRecords) {
|
||||
if (!visitRecords.length) {
|
||||
return `<div class="detail-empty">금일 승인된 방문자가 없습니다.</div>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="visits-list">
|
||||
${visitRecords.map(visit => `
|
||||
<div class="visit-item">
|
||||
<div class="visit-item-header">
|
||||
<span class="visit-name">${escapeHtml(visit.visitor_name)}</span>
|
||||
<span class="visit-company">${escapeHtml(visit.visitor_company || '')}</span>
|
||||
</div>
|
||||
<div class="visit-purpose">${escapeHtml(visit.purpose_name || visit.visit_purpose || '')}</div>
|
||||
<div class="visit-time">
|
||||
🕐 ${escapeHtml(visit.visit_time_from || '')} ~ ${escapeHtml(visit.visit_time_to || '')}
|
||||
</div>
|
||||
${visit.companion_count > 0 ? `<div class="visit-companion">동행 ${visit.companion_count}명</div>` : ''}
|
||||
${visit.vehicle_number ? `<div class="visit-vehicle">🚗 ${escapeHtml(visit.vehicle_number)}</div>` : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// TBM 탭 렌더링
|
||||
function renderTbmTab(tbmSessions) {
|
||||
if (!tbmSessions.length) {
|
||||
return `<div class="detail-empty">금일 TBM 세션이 없습니다.</div>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="tbm-list">
|
||||
${tbmSessions.map(tbm => `
|
||||
<div class="tbm-item">
|
||||
<div class="tbm-item-header">
|
||||
<span class="tbm-task">${escapeHtml(tbm.task_name || tbm.work_type_name || '작업')}</span>
|
||||
<span class="tbm-status ${tbm.status}">${getTbmStatusLabel(tbm.status)}</span>
|
||||
</div>
|
||||
<div class="tbm-location">📍 ${escapeHtml(tbm.work_location || '')}</div>
|
||||
<div class="tbm-leader">👷 ${escapeHtml(tbm.leader_name || tbm.leader_worker_name || '')}</div>
|
||||
${tbm.work_content ? `<div class="tbm-content">작업내용: ${escapeHtml(tbm.work_content).slice(0, 80)}...</div>` : ''}
|
||||
${tbm.team && tbm.team.length > 0 ? `
|
||||
<div class="tbm-team">
|
||||
<span class="tbm-team-label">팀원 (${tbm.team.length}명):</span>
|
||||
<span class="tbm-team-names">${tbm.team.map(m => escapeHtml(m.worker_name)).join(', ')}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${tbm.safety_measures ? `<div class="tbm-safety">⚠️ ${escapeHtml(tbm.safety_measures).slice(0, 60)}...</div>` : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 탭 전환
|
||||
function switchDetailTab(tabName) {
|
||||
// 탭 버튼 활성화
|
||||
document.querySelectorAll('.detail-tab').forEach(tab => {
|
||||
tab.classList.toggle('active', tab.dataset.tab === tabName);
|
||||
});
|
||||
// 탭 콘텐츠 표시
|
||||
document.querySelectorAll('.detail-tab-content').forEach(content => {
|
||||
content.classList.toggle('active', content.id === `tab-${tabName}`);
|
||||
});
|
||||
}
|
||||
|
||||
// 상세 패널 닫기
|
||||
function closeWorkplaceDetailPanel() {
|
||||
const panel = document.getElementById('workplaceDetailPanel');
|
||||
if (panel) {
|
||||
panel.style.display = 'none';
|
||||
}
|
||||
workplaceDetail = null;
|
||||
}
|
||||
|
||||
// 헬퍼 함수들
|
||||
function getPriorityLabel(priority) {
|
||||
const labels = { 'emergency': '긴급', 'high': '높음', 'normal': '보통', 'low': '낮음' };
|
||||
return labels[priority] || priority;
|
||||
}
|
||||
|
||||
function getEquipmentStatusLabel(status) {
|
||||
const labels = {
|
||||
'active': '정상',
|
||||
'inactive': '비활성',
|
||||
'repair_needed': '수리필요',
|
||||
'under_repair': '수리중',
|
||||
'disposed': '폐기'
|
||||
};
|
||||
return labels[status] || status;
|
||||
}
|
||||
|
||||
function getTbmStatusLabel(status) {
|
||||
const labels = { 'draft': '작성중', 'in_progress': '진행중', 'completed': '완료' };
|
||||
return labels[status] || status;
|
||||
}
|
||||
|
||||
function formatDateTime(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString('ko-KR', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
@@ -267,9 +267,14 @@ function renderTbmWorkList() {
|
||||
// 수동 입력 섹션 먼저 추가 (맨 위)
|
||||
html += `
|
||||
<div class="tbm-session-group manual-input-section">
|
||||
<div class="tbm-session-header" style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);">
|
||||
<span class="tbm-session-badge" style="background-color: #92400e; color: white;">수동 입력</span>
|
||||
<span class="tbm-session-info" style="color: white; font-weight: 500;">TBM에 없는 작업을 추가로 입력할 수 있습니다</span>
|
||||
<div class="tbm-session-header" style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem;">
|
||||
<div>
|
||||
<span class="tbm-session-badge" style="background-color: #92400e; color: white;">수동 입력</span>
|
||||
<span class="tbm-session-info" style="color: white; font-weight: 500;">TBM에 없는 작업을 추가로 입력할 수 있습니다</span>
|
||||
</div>
|
||||
<button type="button" class="btn-batch-submit" onclick="submitAllManualWorkReports()" style="background: #fff; color: #d97706; border: none; padding: 0.4rem 0.8rem; border-radius: 4px; font-weight: 600; cursor: pointer; font-size: 0.8rem;">
|
||||
📤 일괄 제출
|
||||
</button>
|
||||
</div>
|
||||
<div class="tbm-table-container">
|
||||
<table class="tbm-work-table">
|
||||
@@ -550,7 +555,8 @@ window.submitTbmWorkReport = async function(index) {
|
||||
|
||||
// 총 부적합 시간 계산
|
||||
const errorHours = defects.reduce((sum, d) => sum + (parseFloat(d.defect_hours) || 0), 0);
|
||||
const errorTypeId = defects.length > 0 && defects[0].error_type_id ? defects[0].error_type_id : null;
|
||||
// item_id를 error_type_id로 사용 (issue_report_items.item_id)
|
||||
const errorTypeId = defects.length > 0 ? (defects[0].error_type_id || defects[0].item_id || null) : null;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!totalHours || totalHours <= 0) {
|
||||
@@ -573,7 +579,7 @@ window.submitTbmWorkReport = async function(index) {
|
||||
_saved: d._saved
|
||||
})));
|
||||
|
||||
const invalidDefects = defects.filter(d => d.defect_hours > 0 && !d.error_type_id && !d.issue_report_id && !d.category_id);
|
||||
const invalidDefects = defects.filter(d => d.defect_hours > 0 && !d.error_type_id && !d.issue_report_id && !d.category_id && !d.item_id);
|
||||
if (invalidDefects.length > 0) {
|
||||
console.error('❌ 유효하지 않은 부적합:', invalidDefects);
|
||||
showMessage('부적합 시간이 있는 항목은 원인을 선택해주세요.', 'error');
|
||||
@@ -592,7 +598,7 @@ window.submitTbmWorkReport = async function(index) {
|
||||
tbm_session_id: tbm.session_id,
|
||||
worker_id: tbm.worker_id,
|
||||
project_id: tbm.project_id,
|
||||
work_type_id: tbm.work_type_id,
|
||||
work_type_id: tbm.task_id, // task_id를 work_type_id 컬럼에 저장 (직접 작업보고서와 일관성 유지)
|
||||
report_date: reportDate,
|
||||
start_time: null,
|
||||
end_time: null,
|
||||
@@ -614,7 +620,7 @@ window.submitTbmWorkReport = async function(index) {
|
||||
|
||||
// 부적합 원인이 있으면 저장 (이슈 기반 또는 레거시)
|
||||
if (defects.length > 0 && response.data?.report_id) {
|
||||
const validDefects = defects.filter(d => (d.issue_report_id || d.category_id || d.error_type_id) && d.defect_hours > 0);
|
||||
const validDefects = defects.filter(d => (d.issue_report_id || d.category_id || d.item_id || d.error_type_id) && d.defect_hours > 0);
|
||||
console.log('📋 부적합 원인 필터링:', {
|
||||
전체: defects.length,
|
||||
유효: validDefects.length,
|
||||
@@ -729,7 +735,7 @@ window.batchSubmitTbmSession = async function(sessionKey) {
|
||||
tbm_session_id: tbm.session_id,
|
||||
worker_id: tbm.worker_id,
|
||||
project_id: tbm.project_id,
|
||||
work_type_id: tbm.work_type_id,
|
||||
work_type_id: tbm.task_id, // task_id를 work_type_id 컬럼에 저장 (일관성 유지)
|
||||
report_date: reportDate,
|
||||
start_time: null,
|
||||
end_time: null,
|
||||
@@ -1032,7 +1038,12 @@ window.openWorkplaceMapForManual = async function(manualIndex) {
|
||||
${safeName}
|
||||
</button>
|
||||
`;
|
||||
}).join('');
|
||||
}).join('') + `
|
||||
<button type="button" class="btn btn-secondary" style="width: 100%; text-align: left; margin-top: 0.5rem; background-color: #f0f9ff; border-color: #0ea5e9;" onclick='selectExternalWorkplace()'>
|
||||
<span style="margin-right: 0.5rem;">🌐</span>
|
||||
외부 (외근/연차/휴무 등)
|
||||
</button>
|
||||
`;
|
||||
|
||||
// 카테고리 선택 화면 표시
|
||||
document.getElementById('categorySelectionArea').style.display = 'block';
|
||||
@@ -1307,6 +1318,43 @@ window.closeWorkplaceModal = function() {
|
||||
mapRegions = [];
|
||||
};
|
||||
|
||||
/**
|
||||
* 외부 작업장소 선택 (외근/연차/휴무 등)
|
||||
*/
|
||||
window.selectExternalWorkplace = function() {
|
||||
const manualIndex = window.currentManualIndex;
|
||||
|
||||
// 외부 작업장소 ID는 0 또는 특별한 값으로 설정 (DB에 저장시 처리 필요)
|
||||
const externalCategoryId = 0;
|
||||
const externalCategoryName = '외부';
|
||||
const externalWorkplaceId = 0;
|
||||
const externalWorkplaceName = '외부 (외근/연차/휴무)';
|
||||
|
||||
// hidden input에 값 설정
|
||||
document.getElementById(`workplaceCategory_${manualIndex}`).value = externalCategoryId;
|
||||
document.getElementById(`workplace_${manualIndex}`).value = externalWorkplaceId;
|
||||
|
||||
// 선택 결과 표시
|
||||
const displayDiv = document.getElementById(`workplaceDisplay_${manualIndex}`);
|
||||
if (displayDiv) {
|
||||
displayDiv.innerHTML = `
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.75rem; color: #0284c7; font-weight: 600;">
|
||||
<span>✓</span>
|
||||
<span>외부 선택됨</span>
|
||||
</div>
|
||||
<div style="font-size: 0.8rem; color: #111827; font-weight: 500;">
|
||||
<div>🌐 ${escapeHtml(externalWorkplaceName)}</div>
|
||||
</div>
|
||||
`;
|
||||
displayDiv.style.background = '#f0f9ff';
|
||||
displayDiv.style.borderColor = '#0ea5e9';
|
||||
}
|
||||
|
||||
// 모달 닫기
|
||||
document.getElementById('workplaceModal').style.display = 'none';
|
||||
showMessage('외부 작업장소가 선택되었습니다.', 'success');
|
||||
};
|
||||
|
||||
/**
|
||||
* 수동 작업보고서 제출
|
||||
*/
|
||||
@@ -1323,7 +1371,8 @@ window.submitManualWorkReport = async function(manualIndex) {
|
||||
// 부적합 원인 가져오기
|
||||
const defects = tempDefects[manualIndex] || [];
|
||||
const errorHours = defects.reduce((sum, d) => sum + (parseFloat(d.defect_hours) || 0), 0);
|
||||
const errorTypeId = defects.length > 0 && defects[0].error_type_id ? defects[0].error_type_id : null;
|
||||
// item_id를 error_type_id로 사용 (issue_report_items.item_id)
|
||||
const errorTypeId = defects.length > 0 ? (defects[0].error_type_id || defects[0].item_id || null) : null;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!workerId) {
|
||||
@@ -1346,7 +1395,7 @@ window.submitManualWorkReport = async function(manualIndex) {
|
||||
showMessage('작업을 선택해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
if (!workplaceId) {
|
||||
if (workplaceId === '' || workplaceId === null || workplaceId === undefined) {
|
||||
showMessage('작업장소를 선택해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
@@ -1361,59 +1410,234 @@ window.submitManualWorkReport = async function(manualIndex) {
|
||||
}
|
||||
|
||||
// 부적합 원인 유효성 검사 (issue_report_id 또는 error_type_id 필요)
|
||||
const invalidDefects = defects.filter(d => d.defect_hours > 0 && !d.error_type_id && !d.issue_report_id && !d.category_id);
|
||||
const invalidDefects = defects.filter(d => d.defect_hours > 0 && !d.error_type_id && !d.issue_report_id && !d.category_id && !d.item_id);
|
||||
if (invalidDefects.length > 0) {
|
||||
showMessage('부적합 시간이 있는 항목은 원인을 선택해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 서비스 레이어가 기대하는 형식으로 변환
|
||||
// 주의: 서비스에서 task_id를 work_type_id 컬럼에 매핑함
|
||||
const reportData = {
|
||||
worker_id: parseInt(workerId),
|
||||
project_id: parseInt(projectId),
|
||||
work_type_id: parseInt(workTypeId),
|
||||
task_id: parseInt(taskId),
|
||||
report_date: reportDate,
|
||||
workplace_category_id: parseInt(workplaceCategoryId),
|
||||
workplace_id: parseInt(workplaceId),
|
||||
start_time: null,
|
||||
end_time: null,
|
||||
total_hours: totalHours,
|
||||
error_hours: errorHours,
|
||||
error_type_id: errorTypeId ? parseInt(errorTypeId) : null,
|
||||
work_status_id: errorHours > 0 ? 2 : 1
|
||||
worker_id: parseInt(workerId),
|
||||
work_entries: [{
|
||||
project_id: parseInt(projectId),
|
||||
task_id: parseInt(taskId), // 서비스에서 work_type_id로 매핑됨
|
||||
work_hours: totalHours,
|
||||
work_status_id: errorHours > 0 ? 2 : 1,
|
||||
error_type_id: errorTypeId ? parseInt(errorTypeId) : null
|
||||
}]
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await window.apiCall('/daily-work-reports', 'POST', reportData);
|
||||
// 429 오류 재시도 로직 포함
|
||||
let response;
|
||||
let retries = 3;
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
response = await window.apiCall('/daily-work-reports', 'POST', reportData);
|
||||
break;
|
||||
} catch (err) {
|
||||
if ((err.message?.includes('429') || err.message?.includes('너무 많은 요청')) && i < retries - 1) {
|
||||
const waitTime = (i + 1) * 2000;
|
||||
showMessage(`서버가 바쁩니다. ${waitTime/1000}초 후 재시도...`, 'loading');
|
||||
await new Promise(r => setTimeout(r, waitTime));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || '작업보고서 제출 실패');
|
||||
}
|
||||
|
||||
// 부적합 원인이 있으면 저장 (이슈 기반 또는 레거시)
|
||||
if (defects.length > 0 && response.data?.workReport_ids?.[0]) {
|
||||
const validDefects = defects.filter(d => (d.issue_report_id || d.category_id || d.error_type_id) && d.defect_hours > 0);
|
||||
const reportId = response.data?.inserted_ids?.[0] || response.data?.workReport_ids?.[0];
|
||||
if (defects.length > 0 && reportId) {
|
||||
const validDefects = defects.filter(d => (d.issue_report_id || d.category_id || d.item_id || d.error_type_id) && d.defect_hours > 0);
|
||||
if (validDefects.length > 0) {
|
||||
await window.apiCall(`/daily-work-reports/${response.data.workReport_ids[0]}/defects`, 'PUT', {
|
||||
await window.apiCall(`/daily-work-reports/${reportId}/defects`, 'PUT', {
|
||||
defects: validDefects
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
showSaveResultModal(
|
||||
'success',
|
||||
'작업보고서 제출 완료',
|
||||
'작업보고서가 성공적으로 제출되었습니다.'
|
||||
);
|
||||
|
||||
// 행 제거 (부적합 임시 데이터도 함께 삭제됨)
|
||||
removeManualWorkRow(manualIndex);
|
||||
|
||||
// 목록 새로고침
|
||||
await loadIncompleteTbms();
|
||||
showMessage('작업보고서가 제출되었습니다.', 'success');
|
||||
|
||||
// 남은 행이 없으면 완료 메시지
|
||||
const remainingRows = document.querySelectorAll('#manualWorkTableBody tr[data-index]');
|
||||
if (remainingRows.length === 0) {
|
||||
showSaveResultModal(
|
||||
'success',
|
||||
'작업보고서 제출 완료',
|
||||
'모든 작업보고서가 성공적으로 제출되었습니다.'
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('수동 작업보고서 제출 오류:', error);
|
||||
showSaveResultModal('error', '제출 실패', error.message);
|
||||
showMessage('제출 실패: ' + error.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 딜레이 함수
|
||||
*/
|
||||
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
/**
|
||||
* API 호출 (429 재시도 포함)
|
||||
*/
|
||||
async function apiCallWithRetry(url, method, data, maxRetries = 3) {
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const response = await window.apiCall(url, method, data);
|
||||
return response;
|
||||
} catch (error) {
|
||||
// 429 Rate Limit 오류인 경우 재시도
|
||||
if (error.message && error.message.includes('429') || error.message.includes('너무 많은 요청')) {
|
||||
if (attempt < maxRetries) {
|
||||
const waitTime = attempt * 2000; // 2초, 4초, 6초 대기
|
||||
console.log(`Rate limit 도달. ${waitTime/1000}초 후 재시도... (${attempt}/${maxRetries})`);
|
||||
await delay(waitTime);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 수동 작업보고서 일괄 제출
|
||||
*/
|
||||
window.submitAllManualWorkReports = async function() {
|
||||
const rows = document.querySelectorAll('#manualWorkTableBody tr[data-index]');
|
||||
|
||||
if (rows.length === 0) {
|
||||
showMessage('제출할 작업보고서가 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 확인 다이얼로그
|
||||
if (!confirm(`${rows.length}개의 작업보고서를 일괄 제출하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
const errors = [];
|
||||
let currentIndex = 0;
|
||||
|
||||
showMessage(`작업보고서 제출 중... (0/${rows.length})`, 'loading');
|
||||
|
||||
// 각 행을 순차적으로 제출 (딜레이 포함)
|
||||
for (const row of rows) {
|
||||
currentIndex++;
|
||||
const manualIndex = row.dataset.index;
|
||||
|
||||
// Rate Limit 방지를 위한 딜레이 (1초)
|
||||
if (currentIndex > 1) {
|
||||
await delay(1000);
|
||||
}
|
||||
|
||||
showMessage(`작업보고서 제출 중... (${currentIndex}/${rows.length})`, 'loading');
|
||||
|
||||
try {
|
||||
const workerId = document.getElementById(`worker_${manualIndex}`).value;
|
||||
const reportDate = document.getElementById(`date_${manualIndex}`).value;
|
||||
const projectId = document.getElementById(`project_${manualIndex}`).value;
|
||||
const workTypeId = document.getElementById(`workType_${manualIndex}`).value;
|
||||
const taskId = document.getElementById(`task_${manualIndex}`).value;
|
||||
const workplaceCategoryId = document.getElementById(`workplaceCategory_${manualIndex}`).value;
|
||||
const workplaceId = document.getElementById(`workplace_${manualIndex}`).value;
|
||||
const totalHours = parseFloat(document.getElementById(`totalHours_${manualIndex}`).value);
|
||||
|
||||
// 부적합 원인 가져오기
|
||||
const defects = tempDefects[manualIndex] || [];
|
||||
const errorHours = defects.reduce((sum, d) => sum + (parseFloat(d.defect_hours) || 0), 0);
|
||||
// item_id를 error_type_id로 사용 (issue_report_items.item_id)
|
||||
const errorTypeId = defects.length > 0 ? (defects[0].error_type_id || defects[0].item_id || null) : null;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!workerId || !reportDate || !projectId || !workTypeId || !taskId || !totalHours || totalHours <= 0) {
|
||||
errors.push(`행 ${manualIndex}: 필수 항목 누락`);
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (workplaceId === '' || workplaceId === null || workplaceId === undefined) {
|
||||
errors.push(`행 ${manualIndex}: 작업장소 미선택`);
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 서비스 레이어가 기대하는 형식으로 변환
|
||||
const reportData = {
|
||||
report_date: reportDate,
|
||||
worker_id: parseInt(workerId),
|
||||
work_entries: [{
|
||||
project_id: parseInt(projectId),
|
||||
task_id: parseInt(taskId),
|
||||
work_hours: totalHours,
|
||||
work_status_id: errorHours > 0 ? 2 : 1,
|
||||
error_type_id: errorTypeId ? parseInt(errorTypeId) : null
|
||||
}]
|
||||
};
|
||||
|
||||
const response = await apiCallWithRetry('/daily-work-reports', 'POST', reportData);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || '작업보고서 제출 실패');
|
||||
}
|
||||
|
||||
// 부적합 원인이 있으면 저장
|
||||
const reportId = response.data?.inserted_ids?.[0] || response.data?.workReport_ids?.[0];
|
||||
if (defects.length > 0 && reportId) {
|
||||
const validDefects = defects.filter(d => (d.issue_report_id || d.category_id || d.item_id || d.error_type_id) && d.defect_hours > 0);
|
||||
if (validDefects.length > 0) {
|
||||
await apiCallWithRetry(`/daily-work-reports/${reportId}/defects`, 'PUT', {
|
||||
defects: validDefects
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 성공 - 행 제거
|
||||
removeManualWorkRow(manualIndex);
|
||||
successCount++;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`행 ${manualIndex} 제출 오류:`, error);
|
||||
errors.push(`행 ${manualIndex}: ${error.message}`);
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 로딩 메시지 숨기기
|
||||
hideMessage();
|
||||
|
||||
// 결과 표시
|
||||
let resultMessage = `성공: ${successCount}건`;
|
||||
if (failCount > 0) {
|
||||
resultMessage += `, 실패: ${failCount}건`;
|
||||
}
|
||||
|
||||
if (failCount > 0 && errors.length > 0) {
|
||||
showSaveResultModal(
|
||||
'warning',
|
||||
'일괄 제출 완료 (일부 실패)',
|
||||
`${resultMessage}\n\n실패 원인:\n${errors.slice(0, 5).join('\n')}${errors.length > 5 ? `\n... 외 ${errors.length - 5}건` : ''}`
|
||||
);
|
||||
} else {
|
||||
showSaveResultModal(
|
||||
'success',
|
||||
'일괄 제출 완료',
|
||||
resultMessage
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1506,6 +1730,10 @@ function renderCompletedReports(reports) {
|
||||
<span class="label">공정:</span>
|
||||
<span class="value">${escapeHtml(report.work_type_name || '-')}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">작업:</span>
|
||||
<span class="value">${escapeHtml(report.task_name || '-')}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">작업시간:</span>
|
||||
<span class="value">${parseFloat(report.total_hours || report.work_hours || 0)}시간</span>
|
||||
@@ -1537,12 +1765,207 @@ function renderCompletedReports(reports) {
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="report-actions" style="display: flex; gap: 0.5rem; margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid #e5e7eb;">
|
||||
<button type="button" class="btn btn-sm btn-secondary" onclick='openEditReportModal(${JSON.stringify(report).replace(/'/g, "'")})' style="flex: 1; padding: 0.4rem 0.6rem; font-size: 0.75rem;">
|
||||
✏️ 수정
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-danger" onclick="deleteWorkReport(${report.id})" style="flex: 1; padding: 0.4rem 0.6rem; font-size: 0.75rem; background: #fee2e2; color: #dc2626; border: 1px solid #fecaca;">
|
||||
🗑️ 삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업보고서 수정 모달 열기
|
||||
*/
|
||||
window.openEditReportModal = function(report) {
|
||||
// 수정 모달이 없으면 동적 생성
|
||||
let modal = document.getElementById('editReportModal');
|
||||
if (!modal) {
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'editReportModal';
|
||||
modal.className = 'modal-overlay';
|
||||
modal.style.cssText = 'display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); z-index: 1003; align-items: center; justify-content: center;';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-container" style="background: white; border-radius: 8px; max-width: 500px; width: 90%; max-height: 90vh; overflow-y: auto; margin: auto; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);">
|
||||
<div class="modal-header" style="padding: 1rem 1.5rem; border-bottom: 1px solid #e5e7eb; display: flex; justify-content: space-between; align-items: center;">
|
||||
<h2 style="font-size: 1.1rem; font-weight: 600; color: #111827; margin: 0;">작업보고서 수정</h2>
|
||||
<button class="modal-close" onclick="closeEditReportModal()" style="background: none; border: none; font-size: 1.5rem; cursor: pointer; color: #6b7280;">×</button>
|
||||
</div>
|
||||
<div class="modal-body" style="padding: 1.5rem;">
|
||||
<form id="editReportForm">
|
||||
<input type="hidden" id="editReportId">
|
||||
|
||||
<div class="form-group" style="margin-bottom: 1rem;">
|
||||
<label style="display: block; font-size: 0.875rem; font-weight: 500; margin-bottom: 0.25rem;">작업자</label>
|
||||
<input type="text" id="editWorkerName" readonly style="width: 100%; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 4px; background: #f3f4f6;">
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom: 1rem;">
|
||||
<label style="display: block; font-size: 0.875rem; font-weight: 500; margin-bottom: 0.25rem;">프로젝트</label>
|
||||
<select id="editProjectId" class="form-input" style="width: 100%; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 4px;">
|
||||
${projects.map(p => `<option value="${p.project_id}">${escapeHtml(p.project_name)}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom: 1rem;">
|
||||
<label style="display: block; font-size: 0.875rem; font-weight: 500; margin-bottom: 0.25rem;">공정</label>
|
||||
<select id="editWorkTypeId" class="form-input" style="width: 100%; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 4px;" onchange="loadTasksForEdit()">
|
||||
${workTypes.map(wt => `<option value="${wt.id}">${escapeHtml(wt.name)}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom: 1rem;">
|
||||
<label style="display: block; font-size: 0.875rem; font-weight: 500; margin-bottom: 0.25rem;">작업</label>
|
||||
<select id="editTaskId" class="form-input" style="width: 100%; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 4px;">
|
||||
<option value="">작업 선택</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom: 1rem;">
|
||||
<label style="display: block; font-size: 0.875rem; font-weight: 500; margin-bottom: 0.25rem;">작업시간 (시간)</label>
|
||||
<input type="number" id="editWorkHours" step="0.5" min="0" max="24" class="form-input" style="width: 100%; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 4px;">
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom: 1rem;">
|
||||
<label style="display: block; font-size: 0.875rem; font-weight: 500; margin-bottom: 0.25rem;">작업상태</label>
|
||||
<select id="editWorkStatusId" class="form-input" style="width: 100%; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 4px;">
|
||||
${workStatusTypes.map(ws => `<option value="${ws.id}">${escapeHtml(ws.name)}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer" style="padding: 1rem 1.5rem; border-top: 1px solid #e5e7eb; display: flex; gap: 0.75rem; justify-content: flex-end;">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeEditReportModal()" style="padding: 0.5rem 1rem;">취소</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveEditedReport()" style="padding: 0.5rem 1rem; background: #f59e0b; border: none; color: white; border-radius: 4px; cursor: pointer;">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
// 폼에 데이터 채우기
|
||||
document.getElementById('editReportId').value = report.id;
|
||||
document.getElementById('editWorkerName').value = report.worker_name || '작업자';
|
||||
document.getElementById('editProjectId').value = report.project_id || '';
|
||||
document.getElementById('editWorkHours').value = report.work_hours || report.total_hours || 0;
|
||||
document.getElementById('editWorkStatusId').value = report.work_status_id || 1;
|
||||
|
||||
// 공정 선택 후 작업 목록 로드
|
||||
const workTypeSelect = document.getElementById('editWorkTypeId');
|
||||
|
||||
// work_type_id가 실제로는 task_id를 저장하고 있으므로, task에서 work_type을 찾아야 함
|
||||
// 일단 task 기반으로 찾기 시도
|
||||
loadTasksForEdit().then(() => {
|
||||
const taskSelect = document.getElementById('editTaskId');
|
||||
// work_type_id 컬럼에 저장된 값이 실제로는 task_id
|
||||
if (report.work_type_id) {
|
||||
taskSelect.value = report.work_type_id;
|
||||
}
|
||||
});
|
||||
|
||||
modal.style.display = 'flex';
|
||||
};
|
||||
|
||||
/**
|
||||
* 수정 모달용 작업 목록 로드
|
||||
*/
|
||||
window.loadTasksForEdit = async function() {
|
||||
const workTypeId = document.getElementById('editWorkTypeId').value;
|
||||
const taskSelect = document.getElementById('editTaskId');
|
||||
|
||||
if (!workTypeId) {
|
||||
taskSelect.innerHTML = '<option value="">공정을 먼저 선택하세요</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await window.apiCall(`/tasks?work_type_id=${workTypeId}`);
|
||||
const tasks = response.data || response || [];
|
||||
|
||||
taskSelect.innerHTML = '<option value="">작업 선택</option>' +
|
||||
tasks.map(t => `<option value="${t.task_id}">${escapeHtml(t.task_name)}</option>`).join('');
|
||||
} catch (error) {
|
||||
console.error('작업 목록 로드 오류:', error);
|
||||
taskSelect.innerHTML = '<option value="">로드 실패</option>';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 수정 모달 닫기
|
||||
*/
|
||||
window.closeEditReportModal = function() {
|
||||
const modal = document.getElementById('editReportModal');
|
||||
if (modal) {
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 수정된 보고서 저장
|
||||
*/
|
||||
window.saveEditedReport = async function() {
|
||||
const reportId = document.getElementById('editReportId').value;
|
||||
const projectId = document.getElementById('editProjectId').value;
|
||||
const taskId = document.getElementById('editTaskId').value;
|
||||
const workHours = parseFloat(document.getElementById('editWorkHours').value);
|
||||
const workStatusId = document.getElementById('editWorkStatusId').value;
|
||||
|
||||
if (!projectId || !taskId || !workHours) {
|
||||
showMessage('필수 항목을 모두 입력해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const updateData = {
|
||||
project_id: parseInt(projectId),
|
||||
work_type_id: parseInt(taskId), // task_id가 work_type_id 컬럼에 저장됨
|
||||
work_hours: workHours,
|
||||
work_status_id: parseInt(workStatusId)
|
||||
};
|
||||
|
||||
const response = await window.apiCall(`/daily-work-reports/${reportId}`, 'PUT', updateData);
|
||||
|
||||
if (response.success) {
|
||||
showMessage('작업보고서가 수정되었습니다.', 'success');
|
||||
closeEditReportModal();
|
||||
loadCompletedReports(); // 목록 새로고침
|
||||
} else {
|
||||
throw new Error(response.message || '수정 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('작업보고서 수정 오류:', error);
|
||||
showMessage('수정 실패: ' + error.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 작업보고서 삭제
|
||||
*/
|
||||
window.deleteWorkReport = async function(reportId) {
|
||||
if (!confirm('이 작업보고서를 삭제하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await window.apiCall(`/daily-work-reports/${reportId}`, 'DELETE');
|
||||
|
||||
if (response.success) {
|
||||
showMessage('작업보고서가 삭제되었습니다.', 'success');
|
||||
loadCompletedReports(); // 목록 새로고침
|
||||
} else {
|
||||
throw new Error(response.message || '삭제 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('작업보고서 삭제 오류:', error);
|
||||
showMessage('삭제 실패: ' + error.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// =================================================================
|
||||
// 기존 함수들
|
||||
// =================================================================
|
||||
@@ -1780,15 +2203,16 @@ async function loadProjects() {
|
||||
|
||||
async function loadWorkTypes() {
|
||||
try {
|
||||
const data = await window.apiCall(`/daily-work-reports/work-types`);
|
||||
const response = await window.apiCall(`/daily-work-reports/work-types`);
|
||||
const data = response.data || response;
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
workTypes = data;
|
||||
console.log('✅ 작업 유형 API 사용 (통합 설정)');
|
||||
console.log('✅ 작업 유형 API 사용 (통합 설정):', workTypes.length + '개');
|
||||
return;
|
||||
}
|
||||
throw new Error('API 실패');
|
||||
} catch (error) {
|
||||
console.log('⚠️ 작업 유형 API 사용 불가, 기본값 사용');
|
||||
console.log('⚠️ 작업 유형 API 사용 불가, 기본값 사용:', error.message);
|
||||
workTypes = [
|
||||
{ id: 1, name: 'Base' },
|
||||
{ id: 2, name: 'Vessel' },
|
||||
@@ -1799,10 +2223,11 @@ async function loadWorkTypes() {
|
||||
|
||||
async function loadWorkStatusTypes() {
|
||||
try {
|
||||
const data = await window.apiCall(`/daily-work-reports/work-status-types`);
|
||||
const response = await window.apiCall(`/daily-work-reports/work-status-types`);
|
||||
const data = response.data || response;
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
workStatusTypes = data;
|
||||
console.log('✅ 업무 상태 유형 API 사용 (통합 설정)');
|
||||
console.log('✅ 업무 상태 유형 API 사용 (통합 설정):', workStatusTypes.length + '개');
|
||||
return;
|
||||
}
|
||||
throw new Error('API 실패');
|
||||
@@ -1818,7 +2243,8 @@ async function loadWorkStatusTypes() {
|
||||
async function loadErrorTypes() {
|
||||
// 레거시 에러 유형 로드 (호환성)
|
||||
try {
|
||||
const data = await window.apiCall(`/daily-work-reports/error-types`);
|
||||
const response = await window.apiCall(`/daily-work-reports/error-types`);
|
||||
const data = response.data || response;
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
errorTypes = data;
|
||||
}
|
||||
@@ -3610,10 +4036,11 @@ function updateHiddenDefectFields(index) {
|
||||
// 총 부적합 시간 계산
|
||||
const totalErrorHours = defects.reduce((sum, d) => sum + (parseFloat(d.defect_hours) || 0), 0);
|
||||
|
||||
// hidden input에 대표 error_type_id 저장 (첫 번째 값)
|
||||
// hidden input에 대표 error_type_id 저장 (첫 번째 값, item_id fallback)
|
||||
const errorTypeInput = document.getElementById(`errorType_${index}`);
|
||||
if (errorTypeInput && defects.length > 0 && defects[0].error_type_id) {
|
||||
errorTypeInput.value = defects[0].error_type_id;
|
||||
const errorTypeId = defects.length > 0 ? (defects[0].error_type_id || defects[0].item_id || null) : null;
|
||||
if (errorTypeInput && errorTypeId) {
|
||||
errorTypeInput.value = errorTypeId;
|
||||
} else if (errorTypeInput) {
|
||||
errorTypeInput.value = '';
|
||||
}
|
||||
@@ -3635,8 +4062,8 @@ function updateDefectSummary(index) {
|
||||
if (!summaryEl) return;
|
||||
|
||||
const defects = tempDefects[index] || [];
|
||||
// 이슈 기반 또는 레거시 부적합 중 시간이 입력된 것만 유효
|
||||
const validDefects = defects.filter(d => (d.issue_report_id || d.category_id || d.error_type_id) && d.defect_hours > 0);
|
||||
// 이슈 기반 또는 레거시 부적합 중 시간이 입력된 것만 유효 (item_id도 체크)
|
||||
const validDefects = defects.filter(d => (d.issue_report_id || d.category_id || d.item_id || d.error_type_id) && d.defect_hours > 0);
|
||||
|
||||
if (validDefects.length === 0) {
|
||||
summaryEl.textContent = '없음';
|
||||
@@ -3655,9 +4082,16 @@ function updateDefectSummary(index) {
|
||||
const issue = issues.find(i => i.report_id == validDefects[0].issue_report_id);
|
||||
typeName = issue?.issue_item_name || issue?.issue_category_name || '부적합';
|
||||
}
|
||||
} else if (validDefects[0].item_id) {
|
||||
// 신규 방식 - issue_report_items에서 이름 찾기
|
||||
typeName = issueItems.find(i => i.item_id == validDefects[0].item_id)?.item_name || '부적합';
|
||||
} else if (validDefects[0].category_id) {
|
||||
// 카테고리만 선택된 경우
|
||||
typeName = issueCategories.find(c => c.category_id == validDefects[0].category_id)?.category_name || '부적합';
|
||||
} else if (validDefects[0].error_type_id) {
|
||||
// 레거시 - error_types에서 이름 찾기
|
||||
typeName = errorTypes.find(et => et.id == validDefects[0].error_type_id)?.name || '부적합';
|
||||
// 레거시 - error_types에서 이름 찾기 또는 issue_report_items에서 찾기
|
||||
typeName = issueItems.find(i => i.item_id == validDefects[0].error_type_id)?.item_name ||
|
||||
errorTypes.find(et => et.id == validDefects[0].error_type_id)?.name || '부적합';
|
||||
}
|
||||
summaryEl.textContent = `${typeName} ${totalHours}h`;
|
||||
} else {
|
||||
|
||||
@@ -152,6 +152,40 @@ function setupNavbarEvents() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 모바일 메뉴 버튼
|
||||
const mobileMenuBtn = document.getElementById('mobileMenuBtn');
|
||||
const sidebar = document.getElementById('sidebarNav');
|
||||
const overlay = document.getElementById('sidebarOverlay');
|
||||
|
||||
if (mobileMenuBtn && sidebar) {
|
||||
mobileMenuBtn.addEventListener('click', () => {
|
||||
sidebar.classList.toggle('mobile-open');
|
||||
overlay?.classList.toggle('show');
|
||||
document.body.classList.toggle('sidebar-mobile-open');
|
||||
});
|
||||
}
|
||||
|
||||
// 오버레이 클릭시 닫기
|
||||
if (overlay) {
|
||||
overlay.addEventListener('click', () => {
|
||||
sidebar?.classList.remove('mobile-open');
|
||||
overlay.classList.remove('show');
|
||||
document.body.classList.remove('sidebar-mobile-open');
|
||||
});
|
||||
}
|
||||
|
||||
// 사이드바 토글 버튼 (모바일에서 닫기)
|
||||
const sidebarToggle = document.getElementById('sidebarToggle');
|
||||
if (sidebarToggle && sidebar) {
|
||||
sidebarToggle.addEventListener('click', () => {
|
||||
if (window.innerWidth <= 1024) {
|
||||
sidebar.classList.remove('mobile-open');
|
||||
overlay?.classList.remove('show');
|
||||
document.body.classList.remove('sidebar-mobile-open');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -160,10 +194,13 @@ function setupNavbarEvents() {
|
||||
function updateDateTime() {
|
||||
const now = new Date();
|
||||
|
||||
// 시간 업데이트
|
||||
// 시간 업데이트 (시 분 초 형식으로 고정)
|
||||
const timeElement = document.getElementById('timeValue');
|
||||
if (timeElement) {
|
||||
timeElement.textContent = now.toLocaleTimeString('ko-KR', { hour12: false });
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(now.getSeconds()).padStart(2, '0');
|
||||
timeElement.textContent = `${hours}시 ${minutes}분 ${seconds}초`;
|
||||
}
|
||||
|
||||
// 날짜 업데이트
|
||||
|
||||
@@ -122,13 +122,10 @@ function updateCurrentTime() {
|
||||
// Navbar 컴포넌트가 시간을 처리하므로 여기서는 timeValue가 있을 때만 업데이트
|
||||
if (elements.timeValue) {
|
||||
const now = new Date();
|
||||
const timeString = now.toLocaleTimeString('ko-KR', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
elements.timeValue.textContent = timeString;
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(now.getSeconds()).padStart(2, '0');
|
||||
elements.timeValue.textContent = `${hours}시 ${minutes}분 ${seconds}초`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,16 +33,14 @@ function initializePage() {
|
||||
setupSearchInput();
|
||||
}
|
||||
|
||||
// 현재 시간 업데이트
|
||||
// 현재 시간 업데이트 (시 분 초 형식으로 고정)
|
||||
function updateCurrentTime() {
|
||||
const now = new Date();
|
||||
const timeString = now.toLocaleTimeString('ko-KR', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(now.getSeconds()).padStart(2, '0');
|
||||
const timeString = `${hours}시 ${minutes}분 ${seconds}초`;
|
||||
|
||||
const timeElement = document.getElementById('timeValue');
|
||||
if (timeElement) {
|
||||
timeElement.textContent = timeString;
|
||||
|
||||
@@ -38,16 +38,14 @@ function initializePage() {
|
||||
console.log('✅ 작업 분석 페이지 초기화 완료');
|
||||
}
|
||||
|
||||
// 현재 시간 업데이트
|
||||
// 현재 시간 업데이트 (시 분 초 형식으로 고정)
|
||||
function updateCurrentTime() {
|
||||
const now = new Date();
|
||||
const timeString = now.toLocaleTimeString('ko-KR', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(now.getSeconds()).padStart(2, '0');
|
||||
const timeString = `${hours}시 ${minutes}분 ${seconds}초`;
|
||||
|
||||
const timeElement = document.getElementById('timeValue');
|
||||
if (timeElement) {
|
||||
timeElement.textContent = timeString;
|
||||
|
||||
@@ -32,16 +32,14 @@ function initializePage() {
|
||||
setupLogoutButton();
|
||||
}
|
||||
|
||||
// 현재 시간 업데이트
|
||||
// 현재 시간 업데이트 (시 분 초 형식으로 고정)
|
||||
function updateCurrentTime() {
|
||||
const now = new Date();
|
||||
const timeString = now.toLocaleTimeString('ko-KR', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(now.getSeconds()).padStart(2, '0');
|
||||
const timeString = `${hours}시 ${minutes}분 ${seconds}초`;
|
||||
|
||||
const timeElement = document.getElementById('timeValue');
|
||||
if (timeElement) {
|
||||
timeElement.textContent = timeString;
|
||||
|
||||
@@ -658,8 +658,8 @@ async function loadEquipmentMarkers(workplaceId) {
|
||||
const width = eq.map_width_percent || 8;
|
||||
const height = eq.map_height_percent || 6;
|
||||
|
||||
// 표시 이름: [코드] 이름
|
||||
const displayName = `[${eq.equipment_code}] ${eq.equipment_name}`;
|
||||
// 표시 이름: 설비 이름만
|
||||
const displayName = eq.equipment_name;
|
||||
const movedBadge = eq.is_temporarily_moved ? ' 🚚' : '';
|
||||
|
||||
markersHtml += `
|
||||
@@ -814,9 +814,8 @@ const STATUS_LABELS = {
|
||||
async function openEquipmentPanel(equipment) {
|
||||
currentPanelEquipment = equipment;
|
||||
|
||||
// 패널 헤더 설정
|
||||
document.getElementById('panelEquipmentTitle').textContent =
|
||||
`[${equipment.equipment_code}] ${equipment.equipment_name}`;
|
||||
// 패널 헤더 설정 (설비 이름만)
|
||||
document.getElementById('panelEquipmentTitle').textContent = equipment.equipment_name;
|
||||
|
||||
const statusEl = document.getElementById('panelEquipmentStatus');
|
||||
statusEl.textContent = STATUS_LABELS[equipment.status] || equipment.status;
|
||||
@@ -1286,7 +1285,7 @@ async function renderMoveDetailMap() {
|
||||
style="left: ${eq.map_x_percent}%; top: ${eq.map_y_percent}%;
|
||||
width: ${width}%; height: ${height}%;"
|
||||
title="${eq.equipment_name}">
|
||||
<span class="eq-label">${eq.equipment_code || eq.equipment_name}</span>
|
||||
<span class="eq-label">${eq.equipment_name}</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
@@ -1331,7 +1330,7 @@ function onMoveDetailMapClick(event) {
|
||||
targetMarker.style.width = width + '%';
|
||||
targetMarker.style.height = height + '%';
|
||||
targetMarker.style.display = 'flex';
|
||||
targetMarker.innerHTML = `<span class="eq-label">${currentPanelEquipment.equipment_code || currentPanelEquipment.equipment_name}</span>`;
|
||||
targetMarker.innerHTML = `<span class="eq-label">${currentPanelEquipment.equipment_name}</span>`;
|
||||
|
||||
document.getElementById('panelMoveConfirmBtn').disabled = false;
|
||||
}
|
||||
|
||||
1423
web-ui/js/zone-detail.js
Normal file
1423
web-ui/js/zone-detail.js
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user