## 백엔드 보안 수정 - 하드코딩된 비밀번호 및 JWT 시크릿 폴백 제거 - SQL Injection 방지를 위한 화이트리스트 검증 추가 - 인증 미적용 API 라우트에 requireAuth 미들웨어 적용 - CSRF 보호 미들웨어 구현 (csrf.js) - 파일 업로드 보안 유틸리티 추가 (fileUploadSecurity.js) - 비밀번호 정책 검증 유틸리티 추가 (passwordValidator.js) ## 프론트엔드 XSS 방지 - api-base.js에 전역 escapeHtml() 함수 추가 - 17개 주요 JS 파일에 escapeHtml 적용: - tbm.js, daily-patrol.js, daily-work-report.js - task-management.js, workplace-status.js - equipment-detail.js, equipment-management.js - issue-detail.js, issue-report.js - vacation-common.js, worker-management.js - safety-report-list.js, nonconformity-list.js - project-management.js, workplace-management.js ## 정리 - 백업 폴더 및 빈 파일 삭제 - SECURITY_GUIDE.md 문서 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
768 lines
26 KiB
JavaScript
768 lines
26 KiB
JavaScript
// daily-patrol.js - 일일순회점검 페이지 JavaScript
|
|
|
|
// 전역 상태
|
|
let currentSession = null;
|
|
let categories = []; // 공장(대분류) 목록
|
|
let workplaces = []; // 작업장 목록
|
|
let checklistItems = []; // 체크리스트 항목
|
|
let checkRecords = {}; // 체크 기록 (workplace_id -> records)
|
|
let selectedWorkplace = null;
|
|
let itemTypes = []; // 물품 유형
|
|
let workplaceItems = []; // 현재 작업장 물품
|
|
let isItemEditMode = false;
|
|
|
|
// 이미지 URL 헬퍼 함수 (정적 파일용 - /api 경로 제외)
|
|
function getImageUrl(path) {
|
|
if (!path) return '';
|
|
// 이미 http로 시작하면 그대로 반환
|
|
if (path.startsWith('http')) return path;
|
|
// API_BASE_URL에서 /api 제거하여 정적 파일 서버 URL 생성
|
|
// /uploads 경로는 인증 없이 접근 가능한 정적 파일 경로
|
|
const staticUrl = window.API_BASE_URL.replace(/\/api$/, '');
|
|
return staticUrl + path;
|
|
}
|
|
|
|
// 페이지 초기화
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
await waitForAxiosConfig();
|
|
initializePage();
|
|
});
|
|
|
|
// axios 설정 대기
|
|
function waitForAxiosConfig() {
|
|
return new Promise((resolve) => {
|
|
const check = setInterval(() => {
|
|
if (axios.defaults.baseURL) {
|
|
clearInterval(check);
|
|
resolve();
|
|
}
|
|
}, 50);
|
|
setTimeout(() => {
|
|
clearInterval(check);
|
|
resolve();
|
|
}, 5000);
|
|
});
|
|
}
|
|
|
|
// 자동 날짜/시간대 결정
|
|
function getAutoPatrolDateTime() {
|
|
const now = new Date();
|
|
const patrolDate = now.toISOString().slice(0, 10);
|
|
const hour = now.getHours();
|
|
// 오전(~12시), 오후(12시~)
|
|
const patrolTime = hour < 12 ? 'morning' : 'afternoon';
|
|
return { patrolDate, patrolTime };
|
|
}
|
|
|
|
// 페이지 초기화
|
|
async function initializePage() {
|
|
// 데이터 로드
|
|
await Promise.all([
|
|
loadCategories(),
|
|
loadItemTypes(),
|
|
loadTodayStatus()
|
|
]);
|
|
}
|
|
|
|
// 공장(대분류) 목록 로드
|
|
async function loadCategories() {
|
|
try {
|
|
const response = await axios.get('/workplaces/categories');
|
|
if (response.data.success) {
|
|
categories = response.data.data;
|
|
}
|
|
} catch (error) {
|
|
console.error('공장 목록 로드 실패:', error);
|
|
}
|
|
}
|
|
|
|
// 공장 선택 화면 표시
|
|
function showFactorySelection() {
|
|
const { patrolDate, patrolTime } = getAutoPatrolDateTime();
|
|
const timeLabel = patrolTime === 'morning' ? '오전' : '오후';
|
|
const dateObj = new Date(patrolDate);
|
|
const dateLabel = dateObj.toLocaleDateString('ko-KR', { month: 'long', day: 'numeric', weekday: 'short' });
|
|
|
|
// 세션 정보 표시
|
|
document.getElementById('patrolSessionInfo').textContent = `${dateLabel} ${timeLabel} 순회점검`;
|
|
|
|
// 공장 카드 렌더링
|
|
const container = document.getElementById('factoryCardsContainer');
|
|
container.innerHTML = categories.map(c => `
|
|
<div class="factory-card" onclick="selectFactory(${parseInt(c.category_id) || 0})">
|
|
<div class="factory-card-icon">
|
|
${c.layout_image ? `<img src="${escapeHtml(getImageUrl(c.layout_image))}" alt="${escapeHtml(c.category_name)}">` : '<span>🏭</span>'}
|
|
</div>
|
|
<div class="factory-card-name">${escapeHtml(c.category_name)}</div>
|
|
</div>
|
|
`).join('');
|
|
|
|
// 시작 버튼 숨기고 공장 선택 영역 표시
|
|
document.getElementById('startPatrolBtn').style.display = 'none';
|
|
document.getElementById('factorySelectionArea').style.display = 'block';
|
|
}
|
|
|
|
// 공장 선택 후 점검 시작
|
|
async function selectFactory(categoryId) {
|
|
const { patrolDate, patrolTime } = getAutoPatrolDateTime();
|
|
|
|
try {
|
|
// 세션 생성 또는 조회
|
|
const response = await axios.post('/patrol/sessions', {
|
|
patrol_date: patrolDate,
|
|
patrol_time: patrolTime,
|
|
category_id: categoryId
|
|
});
|
|
|
|
if (response.data.success) {
|
|
currentSession = response.data.data;
|
|
currentSession.patrol_date = patrolDate;
|
|
currentSession.patrol_time = patrolTime;
|
|
currentSession.category_id = categoryId;
|
|
|
|
// 작업장 목록 로드
|
|
await loadWorkplaces(categoryId);
|
|
|
|
// 체크리스트 항목 로드
|
|
await loadChecklistItems(categoryId);
|
|
|
|
// 공장 선택 영역 숨기고 점검 영역 표시
|
|
document.getElementById('factorySelectionArea').style.display = 'none';
|
|
document.getElementById('patrolArea').style.display = 'block';
|
|
renderSessionInfo();
|
|
renderWorkplaceMap();
|
|
}
|
|
} catch (error) {
|
|
console.error('순회점검 시작 실패:', error);
|
|
alert('순회점검을 시작할 수 없습니다.');
|
|
}
|
|
}
|
|
|
|
// 물품 유형 로드
|
|
async function loadItemTypes() {
|
|
try {
|
|
const response = await axios.get('/patrol/item-types');
|
|
if (response.data.success) {
|
|
itemTypes = response.data.data;
|
|
renderItemTypesSelect();
|
|
renderItemsLegend();
|
|
}
|
|
} catch (error) {
|
|
console.error('물품 유형 로드 실패:', error);
|
|
}
|
|
}
|
|
|
|
// 오늘 점검 현황 로드
|
|
async function loadTodayStatus() {
|
|
try {
|
|
const response = await axios.get('/patrol/today-status');
|
|
if (response.data.success) {
|
|
renderTodayStatus(response.data.data);
|
|
}
|
|
} catch (error) {
|
|
console.error('오늘 현황 로드 실패:', error);
|
|
}
|
|
}
|
|
|
|
// 오늘 점검 현황 렌더링
|
|
function renderTodayStatus(statusList) {
|
|
const container = document.getElementById('todayStatusSummary');
|
|
if (!statusList || statusList.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="status-card">
|
|
<div class="status-label">오전</div>
|
|
<div class="status-value pending">미점검</div>
|
|
</div>
|
|
<div class="status-card">
|
|
<div class="status-label">오후</div>
|
|
<div class="status-value pending">미점검</div>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
const morning = statusList.find(s => s.patrol_time === 'morning');
|
|
const afternoon = statusList.find(s => s.patrol_time === 'afternoon');
|
|
|
|
container.innerHTML = `
|
|
<div class="status-card">
|
|
<div class="status-label">오전</div>
|
|
<div class="status-value ${morning?.status === 'completed' ? 'completed' : 'pending'}">
|
|
${morning ? (morning.status === 'completed' ? '완료' : '진행중') : '미점검'}
|
|
</div>
|
|
${morning ? `<div class="status-sub">${escapeHtml(morning.inspector_name || '')}</div>` : ''}
|
|
</div>
|
|
<div class="status-card">
|
|
<div class="status-label">오후</div>
|
|
<div class="status-value ${afternoon?.status === 'completed' ? 'completed' : 'pending'}">
|
|
${afternoon ? (afternoon.status === 'completed' ? '완료' : '진행중') : '미점검'}
|
|
</div>
|
|
${afternoon ? `<div class="status-sub">${escapeHtml(afternoon.inspector_name || '')}</div>` : ''}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
|
|
// 작업장 목록 로드
|
|
async function loadWorkplaces(categoryId) {
|
|
try {
|
|
const response = await axios.get(`/workplaces?category_id=${categoryId}`);
|
|
if (response.data.success) {
|
|
workplaces = response.data.data;
|
|
}
|
|
} catch (error) {
|
|
console.error('작업장 목록 로드 실패:', error);
|
|
}
|
|
}
|
|
|
|
// 체크리스트 항목 로드
|
|
async function loadChecklistItems(categoryId) {
|
|
try {
|
|
const response = await axios.get(`/patrol/checklist?category_id=${categoryId}`);
|
|
if (response.data.success) {
|
|
checklistItems = response.data.data.items;
|
|
}
|
|
} catch (error) {
|
|
console.error('체크리스트 항목 로드 실패:', error);
|
|
}
|
|
}
|
|
|
|
// 세션 정보 렌더링
|
|
function renderSessionInfo() {
|
|
const container = document.getElementById('sessionInfo');
|
|
const category = categories.find(c => c.category_id == currentSession.category_id);
|
|
const checkedCount = Object.values(checkRecords).flat().filter(r => r.is_checked).length;
|
|
const totalCount = workplaces.length * checklistItems.length;
|
|
const progress = totalCount > 0 ? Math.round(checkedCount / totalCount * 100) : 0;
|
|
|
|
container.innerHTML = `
|
|
<div class="session-info">
|
|
<div class="session-info-item">
|
|
<span class="session-info-label">점검일자</span>
|
|
<span class="session-info-value">${escapeHtml(formatDate(currentSession.patrol_date))}</span>
|
|
</div>
|
|
<div class="session-info-item">
|
|
<span class="session-info-label">시간대</span>
|
|
<span class="session-info-value">${currentSession.patrol_time === 'morning' ? '오전' : '오후'}</span>
|
|
</div>
|
|
<div class="session-info-item">
|
|
<span class="session-info-label">공장</span>
|
|
<span class="session-info-value">${escapeHtml(category?.category_name || '')}</span>
|
|
</div>
|
|
</div>
|
|
<div class="session-progress">
|
|
<div class="progress-bar">
|
|
<div class="progress-fill" style="width: ${parseInt(progress) || 0}%"></div>
|
|
</div>
|
|
<span class="progress-text">${parseInt(progress) || 0}%</span>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// 작업장 지도/목록 렌더링
|
|
function renderWorkplaceMap() {
|
|
const mapContainer = document.getElementById('patrolMapContainer');
|
|
const listContainer = document.getElementById('workplaceListContainer');
|
|
const category = categories.find(c => c.category_id == currentSession.category_id);
|
|
|
|
// 지도 이미지가 있으면 지도 표시
|
|
if (category?.layout_image) {
|
|
mapContainer.innerHTML = `<img src="${escapeHtml(getImageUrl(category.layout_image))}" alt="${escapeHtml(category.category_name)} 지도">`;
|
|
mapContainer.style.display = 'block';
|
|
|
|
// 좌표가 있는 작업장만 마커 추가
|
|
const hasMarkers = workplaces.some(wp => wp.x_percent && wp.y_percent);
|
|
|
|
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');
|
|
}
|
|
|
|
mapContainer.appendChild(marker);
|
|
}
|
|
});
|
|
|
|
// 좌표가 없는 작업장이 있으면 카드 목록도 표시
|
|
if (!hasMarkers || workplaces.some(wp => !wp.x_percent || !wp.y_percent)) {
|
|
listContainer.style.display = 'grid';
|
|
renderWorkplaceCards(listContainer);
|
|
} else {
|
|
listContainer.style.display = 'none';
|
|
}
|
|
} else {
|
|
// 지도 없으면 카드 목록으로 표시
|
|
mapContainer.style.display = 'none';
|
|
listContainer.style.display = 'grid';
|
|
renderWorkplaceCards(listContainer);
|
|
}
|
|
}
|
|
|
|
// 작업장 카드 렌더링
|
|
function renderWorkplaceCards(container) {
|
|
container.innerHTML = workplaces.map(wp => {
|
|
const records = checkRecords[wp.workplace_id];
|
|
const isCompleted = records && records.length > 0 && records.every(r => r.is_checked);
|
|
const isInProgress = records && records.some(r => r.is_checked);
|
|
const workplaceId = parseInt(wp.workplace_id) || 0;
|
|
|
|
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})">
|
|
<div class="workplace-card-name">${escapeHtml(wp.workplace_name)}</div>
|
|
<div class="workplace-card-status">
|
|
${isCompleted ? '점검완료' : (isInProgress ? '점검중' : '미점검')}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
// 작업장 선택
|
|
async function selectWorkplace(workplaceId) {
|
|
selectedWorkplace = workplaces.find(w => w.workplace_id === workplaceId);
|
|
|
|
// 마커/카드 선택 상태 업데이트
|
|
document.querySelectorAll('.workplace-marker, .workplace-card').forEach(el => {
|
|
el.classList.remove('selected');
|
|
if (el.dataset.workplaceId == workplaceId) {
|
|
el.classList.add('selected');
|
|
}
|
|
});
|
|
|
|
// 기존 체크 기록 로드
|
|
if (!checkRecords[workplaceId]) {
|
|
try {
|
|
const response = await axios.get(`/patrol/sessions/${currentSession.session_id}/records?workplace_id=${workplaceId}`);
|
|
if (response.data.success) {
|
|
checkRecords[workplaceId] = response.data.data;
|
|
}
|
|
} catch (error) {
|
|
console.error('체크 기록 로드 실패:', error);
|
|
checkRecords[workplaceId] = [];
|
|
}
|
|
}
|
|
|
|
// 체크리스트 렌더링
|
|
renderChecklist(workplaceId);
|
|
|
|
// 물품 현황 로드 및 표시
|
|
await loadWorkplaceItems(workplaceId);
|
|
|
|
// 액션 버튼 표시
|
|
document.getElementById('checklistActions').style.display = 'flex';
|
|
}
|
|
|
|
// 체크리스트 렌더링
|
|
function renderChecklist(workplaceId) {
|
|
const header = document.getElementById('checklistHeader');
|
|
const content = document.getElementById('checklistContent');
|
|
const workplace = workplaces.find(w => w.workplace_id === workplaceId);
|
|
|
|
header.innerHTML = `
|
|
<h3>${escapeHtml(workplace?.workplace_name || '')} 체크리스트</h3>
|
|
<p class="checklist-subtitle">각 항목을 점검하고 체크해주세요</p>
|
|
`;
|
|
|
|
// 카테고리별 그룹화
|
|
const grouped = {};
|
|
checklistItems.forEach(item => {
|
|
if (!grouped[item.check_category]) {
|
|
grouped[item.check_category] = [];
|
|
}
|
|
grouped[item.check_category].push(item);
|
|
});
|
|
|
|
const records = checkRecords[workplaceId] || [];
|
|
|
|
content.innerHTML = Object.entries(grouped).map(([category, items]) => `
|
|
<div class="checklist-category">
|
|
<div class="checklist-category-title">${escapeHtml(getCategoryName(category))}</div>
|
|
${items.map(item => {
|
|
const record = records.find(r => r.check_item_id === item.item_id);
|
|
const isChecked = record?.is_checked;
|
|
const checkResult = record?.check_result;
|
|
const itemId = parseInt(item.item_id) || 0;
|
|
const wpId = parseInt(workplaceId) || 0;
|
|
|
|
return `
|
|
<div class="check-item ${isChecked ? 'checked' : ''}"
|
|
data-item-id="${itemId}"
|
|
onclick="toggleCheckItem(${wpId}, ${itemId})">
|
|
<div class="check-item-checkbox">
|
|
${isChecked ? '✓' : ''}
|
|
</div>
|
|
<div class="check-item-content">
|
|
<div class="check-item-text">
|
|
${escapeHtml(item.check_item)}
|
|
${item.is_required ? '<span class="check-item-required">*</span>' : ''}
|
|
</div>
|
|
${isChecked ? `
|
|
<div class="check-result-selector" onclick="event.stopPropagation()">
|
|
<button class="check-result-btn good ${checkResult === 'good' ? 'active' : ''}"
|
|
onclick="setCheckResult(${wpId}, ${itemId}, 'good')">양호</button>
|
|
<button class="check-result-btn warning ${checkResult === 'warning' ? 'active' : ''}"
|
|
onclick="setCheckResult(${wpId}, ${itemId}, 'warning')">주의</button>
|
|
<button class="check-result-btn bad ${checkResult === 'bad' ? 'active' : ''}"
|
|
onclick="setCheckResult(${wpId}, ${itemId}, 'bad')">불량</button>
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('')}
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
// 카테고리명 변환
|
|
function getCategoryName(code) {
|
|
const names = {
|
|
'SAFETY': '안전',
|
|
'ORGANIZATION': '정리정돈',
|
|
'EQUIPMENT': '설비',
|
|
'ENVIRONMENT': '환경'
|
|
};
|
|
return names[code] || code;
|
|
}
|
|
|
|
// 체크 항목 토글
|
|
function toggleCheckItem(workplaceId, itemId) {
|
|
if (!checkRecords[workplaceId]) {
|
|
checkRecords[workplaceId] = [];
|
|
}
|
|
|
|
const records = checkRecords[workplaceId];
|
|
const existingIndex = records.findIndex(r => r.check_item_id === itemId);
|
|
|
|
if (existingIndex >= 0) {
|
|
records[existingIndex].is_checked = !records[existingIndex].is_checked;
|
|
if (!records[existingIndex].is_checked) {
|
|
records[existingIndex].check_result = null;
|
|
}
|
|
} else {
|
|
records.push({
|
|
check_item_id: itemId,
|
|
is_checked: true,
|
|
check_result: 'good',
|
|
note: null
|
|
});
|
|
}
|
|
|
|
renderChecklist(workplaceId);
|
|
renderWorkplaceMap();
|
|
renderSessionInfo();
|
|
}
|
|
|
|
// 체크 결과 설정
|
|
function setCheckResult(workplaceId, itemId, result) {
|
|
const records = checkRecords[workplaceId];
|
|
const record = records.find(r => r.check_item_id === itemId);
|
|
if (record) {
|
|
record.check_result = result;
|
|
renderChecklist(workplaceId);
|
|
}
|
|
}
|
|
|
|
// 임시 저장
|
|
async function saveChecklistDraft() {
|
|
if (!selectedWorkplace) return;
|
|
|
|
try {
|
|
const records = checkRecords[selectedWorkplace.workplace_id] || [];
|
|
await axios.post(`/patrol/sessions/${currentSession.session_id}/records/batch`, {
|
|
workplace_id: selectedWorkplace.workplace_id,
|
|
records: records
|
|
});
|
|
alert('임시 저장되었습니다.');
|
|
} catch (error) {
|
|
console.error('임시 저장 실패:', error);
|
|
alert('저장에 실패했습니다.');
|
|
}
|
|
}
|
|
|
|
// 저장 후 다음
|
|
async function saveChecklist() {
|
|
if (!selectedWorkplace) return;
|
|
|
|
try {
|
|
const records = checkRecords[selectedWorkplace.workplace_id] || [];
|
|
await axios.post(`/patrol/sessions/${currentSession.session_id}/records/batch`, {
|
|
workplace_id: selectedWorkplace.workplace_id,
|
|
records: records
|
|
});
|
|
|
|
// 다음 미점검 작업장으로 이동
|
|
const currentIndex = workplaces.findIndex(w => w.workplace_id === selectedWorkplace.workplace_id);
|
|
const nextWorkplace = workplaces.slice(currentIndex + 1).find(w => {
|
|
const records = checkRecords[w.workplace_id];
|
|
return !records || records.length === 0 || !records.every(r => r.is_checked);
|
|
});
|
|
|
|
if (nextWorkplace) {
|
|
selectWorkplace(nextWorkplace.workplace_id);
|
|
} else {
|
|
alert('모든 작업장 점검이 완료되었습니다!');
|
|
}
|
|
} catch (error) {
|
|
console.error('저장 실패:', error);
|
|
alert('저장에 실패했습니다.');
|
|
}
|
|
}
|
|
|
|
// 순회점검 완료
|
|
async function completePatrol() {
|
|
if (!currentSession) return;
|
|
|
|
// 미점검 작업장 확인
|
|
const uncheckedCount = workplaces.filter(w => {
|
|
const records = checkRecords[w.workplace_id];
|
|
return !records || records.length === 0;
|
|
}).length;
|
|
|
|
if (uncheckedCount > 0) {
|
|
if (!confirm(`아직 ${uncheckedCount}개 작업장이 미점검 상태입니다. 그래도 완료하시겠습니까?`)) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
const notes = document.getElementById('patrolNotes').value;
|
|
if (notes) {
|
|
await axios.patch(`/patrol/sessions/${currentSession.session_id}/notes`, { notes });
|
|
}
|
|
|
|
await axios.patch(`/patrol/sessions/${currentSession.session_id}/complete`);
|
|
|
|
alert('순회점검이 완료되었습니다.');
|
|
location.reload();
|
|
} catch (error) {
|
|
console.error('순회점검 완료 실패:', error);
|
|
alert('순회점검 완료에 실패했습니다.');
|
|
}
|
|
}
|
|
|
|
// ==================== 물품 현황 ====================
|
|
|
|
// 작업장 물품 로드
|
|
async function loadWorkplaceItems(workplaceId) {
|
|
try {
|
|
const response = await axios.get(`/patrol/workplaces/${workplaceId}/items`);
|
|
if (response.data.success) {
|
|
workplaceItems = response.data.data;
|
|
renderItemsSection(workplaceId);
|
|
}
|
|
} catch (error) {
|
|
console.error('물품 로드 실패:', error);
|
|
workplaceItems = [];
|
|
}
|
|
}
|
|
|
|
// 물품 섹션 렌더링
|
|
function renderItemsSection(workplaceId) {
|
|
const section = document.getElementById('itemsSection');
|
|
const workplace = workplaces.find(w => w.workplace_id === workplaceId);
|
|
const container = document.getElementById('itemsMapContainer');
|
|
|
|
document.getElementById('selectedWorkplaceName').textContent = workplace?.workplace_name || '';
|
|
|
|
// 작업장 레이아웃 이미지가 있으면 표시
|
|
if (workplace?.layout_image) {
|
|
container.innerHTML = `<img src="${escapeHtml(getImageUrl(workplace.layout_image))}" alt="${escapeHtml(workplace.workplace_name)}">`;
|
|
|
|
// 물품 마커 추가
|
|
workplaceItems.forEach(item => {
|
|
if (item.x_percent && item.y_percent) {
|
|
const marker = document.createElement('div');
|
|
// item_type은 화이트리스트로 검증
|
|
const safeItemType = ['container', 'plate', 'material', 'tool', 'other'].includes(item.item_type) ? item.item_type : 'other';
|
|
marker.className = `item-marker ${safeItemType}`;
|
|
marker.style.left = `${parseFloat(item.x_percent) || 0}%`;
|
|
marker.style.top = `${parseFloat(item.y_percent) || 0}%`;
|
|
marker.style.width = `${parseFloat(item.width_percent) || 5}%`;
|
|
marker.style.height = `${parseFloat(item.height_percent) || 5}%`;
|
|
marker.textContent = item.icon || getItemTypeIcon(item.item_type); // textContent 사용
|
|
marker.title = `${item.item_name || item.type_name} (${parseInt(item.quantity) || 0}개)`;
|
|
marker.dataset.itemId = item.item_id;
|
|
marker.onclick = () => openItemModal(item);
|
|
container.appendChild(marker);
|
|
}
|
|
});
|
|
} else {
|
|
container.innerHTML = '<p style="padding: 2rem; text-align: center; color: #64748b;">작업장 레이아웃 이미지가 없습니다.</p>';
|
|
}
|
|
|
|
section.style.display = 'block';
|
|
}
|
|
|
|
// 물품 유형 아이콘
|
|
function getItemTypeIcon(typeCode) {
|
|
const icons = {
|
|
'container': '📦',
|
|
'plate': '🔲',
|
|
'material': '🧱',
|
|
'tool': '🔧',
|
|
'other': '📍'
|
|
};
|
|
return icons[typeCode] || '📍';
|
|
}
|
|
|
|
// 물품 유형 셀렉트 렌더링
|
|
function renderItemTypesSelect() {
|
|
const select = document.getElementById('itemType');
|
|
if (!select) return;
|
|
select.innerHTML = itemTypes.map(t =>
|
|
`<option value="${escapeHtml(t.type_code)}">${escapeHtml(t.icon)} ${escapeHtml(t.type_name)}</option>`
|
|
).join('');
|
|
}
|
|
|
|
// 물품 범례 렌더링
|
|
function renderItemsLegend() {
|
|
const container = document.getElementById('itemsLegend');
|
|
if (!container) return;
|
|
// 색상 값 검증 (hex color만 허용)
|
|
const isValidColor = (color) => /^#[0-9A-Fa-f]{3,6}$/.test(color);
|
|
container.innerHTML = itemTypes.map(t => {
|
|
const safeColor = isValidColor(t.color) ? t.color : '#888888';
|
|
return `
|
|
<div class="item-legend-item">
|
|
<div class="item-legend-icon" style="background: ${safeColor}20; border: 1px solid ${safeColor};">
|
|
${escapeHtml(t.icon)}
|
|
</div>
|
|
<span>${escapeHtml(t.type_name)}</span>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
// 편집 모드 토글
|
|
function toggleItemEditMode() {
|
|
isItemEditMode = !isItemEditMode;
|
|
document.getElementById('itemEditModeText').textContent = isItemEditMode ? '편집모드 종료' : '편집모드';
|
|
|
|
if (isItemEditMode) {
|
|
// 지도 클릭으로 물품 추가
|
|
const container = document.getElementById('itemsMapContainer');
|
|
container.style.cursor = 'crosshair';
|
|
container.onclick = (e) => {
|
|
if (e.target === container || e.target.tagName === 'IMG') {
|
|
const rect = container.getBoundingClientRect();
|
|
const x = ((e.clientX - rect.left) / rect.width * 100).toFixed(2);
|
|
const y = ((e.clientY - rect.top) / rect.height * 100).toFixed(2);
|
|
openItemModal(null, x, y);
|
|
}
|
|
};
|
|
} else {
|
|
const container = document.getElementById('itemsMapContainer');
|
|
container.style.cursor = 'default';
|
|
container.onclick = null;
|
|
}
|
|
}
|
|
|
|
// 물품 모달 열기
|
|
function openItemModal(item = null, x = null, y = null) {
|
|
const modal = document.getElementById('itemModal');
|
|
const title = document.getElementById('itemModalTitle');
|
|
const deleteBtn = document.getElementById('deleteItemBtn');
|
|
|
|
if (item) {
|
|
title.textContent = '물품 수정';
|
|
document.getElementById('itemId').value = item.item_id;
|
|
document.getElementById('itemType').value = item.item_type;
|
|
document.getElementById('itemName').value = item.item_name || '';
|
|
document.getElementById('itemQuantity').value = item.quantity || 1;
|
|
deleteBtn.style.display = 'inline-block';
|
|
} else {
|
|
title.textContent = '물품 추가';
|
|
document.getElementById('itemForm').reset();
|
|
document.getElementById('itemId').value = '';
|
|
document.getElementById('itemId').dataset.x = x;
|
|
document.getElementById('itemId').dataset.y = y;
|
|
deleteBtn.style.display = 'none';
|
|
}
|
|
|
|
modal.style.display = 'flex';
|
|
}
|
|
|
|
// 물품 모달 닫기
|
|
function closeItemModal() {
|
|
document.getElementById('itemModal').style.display = 'none';
|
|
}
|
|
|
|
// 물품 저장
|
|
async function saveItem() {
|
|
if (!selectedWorkplace) return;
|
|
|
|
const itemId = document.getElementById('itemId').value;
|
|
const data = {
|
|
item_type: document.getElementById('itemType').value,
|
|
item_name: document.getElementById('itemName').value,
|
|
quantity: parseInt(document.getElementById('itemQuantity').value) || 1,
|
|
patrol_session_id: currentSession?.session_id
|
|
};
|
|
|
|
// 새 물품일 경우 위치 추가
|
|
if (!itemId) {
|
|
data.x_percent = parseFloat(document.getElementById('itemId').dataset.x);
|
|
data.y_percent = parseFloat(document.getElementById('itemId').dataset.y);
|
|
data.width_percent = 5;
|
|
data.height_percent = 5;
|
|
}
|
|
|
|
try {
|
|
if (itemId) {
|
|
await axios.put(`/patrol/items/${itemId}`, data);
|
|
} else {
|
|
await axios.post(`/patrol/workplaces/${selectedWorkplace.workplace_id}/items`, data);
|
|
}
|
|
|
|
closeItemModal();
|
|
await loadWorkplaceItems(selectedWorkplace.workplace_id);
|
|
} catch (error) {
|
|
console.error('물품 저장 실패:', error);
|
|
alert('물품 저장에 실패했습니다.');
|
|
}
|
|
}
|
|
|
|
// 물품 삭제
|
|
async function deleteItem() {
|
|
const itemId = document.getElementById('itemId').value;
|
|
if (!itemId) return;
|
|
|
|
if (!confirm('이 물품을 삭제하시겠습니까?')) return;
|
|
|
|
try {
|
|
await axios.delete(`/patrol/items/${itemId}`);
|
|
closeItemModal();
|
|
await loadWorkplaceItems(selectedWorkplace.workplace_id);
|
|
} catch (error) {
|
|
console.error('물품 삭제 실패:', error);
|
|
alert('물품 삭제에 실패했습니다.');
|
|
}
|
|
}
|
|
|
|
// 유틸리티 함수
|
|
function formatDate(dateStr) {
|
|
if (!dateStr) return '';
|
|
const date = new Date(dateStr);
|
|
return date.toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric', weekday: 'short' });
|
|
}
|
|
|
|
// ESC 키로 모달 닫기
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') {
|
|
closeItemModal();
|
|
}
|
|
});
|