Files
TK-FB-Project/web-ui/js/daily-patrol.js
Hyungi Ahn 90d3e32992 feat: 일일순회점검 시스템 구축 및 관리 기능 개선
- 일일순회점검 시스템 신규 구현
  - DB 테이블: patrol_checklist_items, daily_patrol_sessions, patrol_check_records, workplace_items, item_types
  - API: /api/patrol/* 엔드포인트
  - 프론트엔드: 지도 기반 작업장 점검 UI

- 설비 관리 기능 개선
  - 구매 관련 필드 추가 (구매일, 가격, 공급업체 등)
  - 설비 코드 자동 생성 (TKP-XXX 형식)

- 작업장 관리 개선
  - 레이아웃 이미지 업로드 기능
  - 마커 위치 저장 기능

- 부서 관리 기능 추가
- 사이드바 네비게이션 카테고리 재구성
- 이미지 401 오류 수정 (정적 파일 경로 처리)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 11:41:41 +09:00

733 lines
24 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);
});
}
// 페이지 초기화
async function initializePage() {
// 오늘 날짜 설정
const today = new Date().toISOString().slice(0, 10);
document.getElementById('patrolDate').value = today;
// 시간대 버튼 이벤트
document.querySelectorAll('.patrol-time-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.patrol-time-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
// 데이터 로드
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;
const select = document.getElementById('categorySelect');
select.innerHTML = '<option value="">공장 선택...</option>' +
categories.map(c => `<option value="${c.category_id}">${c.category_name}</option>`).join('');
}
} catch (error) {
console.error('공장 목록 로드 실패:', error);
}
}
// 물품 유형 로드
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">${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">${afternoon.inspector_name || ''}</div>` : ''}
</div>
`;
}
// 순회점검 시작
async function startPatrol() {
const patrolDate = document.getElementById('patrolDate').value;
const patrolTime = document.querySelector('.patrol-time-btn.active')?.dataset.time;
const categoryId = document.getElementById('categorySelect').value;
if (!patrolDate || !patrolTime || !categoryId) {
alert('점검 일자, 시간대, 공장을 모두 선택해주세요.');
return;
}
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('patrolArea').style.display = 'block';
renderSessionInfo();
renderWorkplaceMap();
// 시작 버튼 비활성화
document.getElementById('startPatrolBtn').textContent = '점검 진행중...';
document.getElementById('startPatrolBtn').disabled = true;
}
} catch (error) {
console.error('순회점검 시작 실패:', error);
alert('순회점검을 시작할 수 없습니다.');
}
}
// 작업장 목록 로드
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">${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">${category?.category_name || ''}</span>
</div>
</div>
<div class="session-progress">
<div class="progress-bar">
<div class="progress-fill" style="width: ${progress}%"></div>
</div>
<span class="progress-text">${progress}%</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="${getImageUrl(category.layout_image)}" alt="${category.category_name} 지도">`;
mapContainer.style.display = 'block';
listContainer.style.display = 'none';
// 작업장 마커 추가
workplaces.forEach(wp => {
if (wp.x_percent && wp.y_percent) {
const marker = document.createElement('div');
marker.className = 'workplace-marker';
marker.style.left = `${wp.x_percent}%`;
marker.style.top = `${wp.y_percent}%`;
marker.textContent = wp.workplace_name;
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);
}
});
} else {
// 지도 없으면 카드 목록으로 표시
mapContainer.style.display = 'none';
listContainer.style.display = 'grid';
listContainer.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);
return `
<div class="workplace-card ${isCompleted ? 'completed' : ''} ${selectedWorkplace?.workplace_id === wp.workplace_id ? 'selected' : ''}"
data-workplace-id="${wp.workplace_id}"
onclick="selectWorkplace(${wp.workplace_id})">
<div class="workplace-card-name">${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>${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">${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;
return `
<div class="check-item ${isChecked ? 'checked' : ''}"
data-item-id="${item.item_id}"
onclick="toggleCheckItem(${workplaceId}, ${item.item_id})">
<div class="check-item-checkbox">
${isChecked ? '&#10003;' : ''}
</div>
<div class="check-item-content">
<div class="check-item-text">
${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(${workplaceId}, ${item.item_id}, 'good')">양호</button>
<button class="check-result-btn warning ${checkResult === 'warning' ? 'active' : ''}"
onclick="setCheckResult(${workplaceId}, ${item.item_id}, 'warning')">주의</button>
<button class="check-result-btn bad ${checkResult === 'bad' ? 'active' : ''}"
onclick="setCheckResult(${workplaceId}, ${item.item_id}, '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="${getImageUrl(workplace.layout_image)}" alt="${workplace.workplace_name}">`;
// 물품 마커 추가
workplaceItems.forEach(item => {
if (item.x_percent && item.y_percent) {
const marker = document.createElement('div');
marker.className = `item-marker ${item.item_type}`;
marker.style.left = `${item.x_percent}%`;
marker.style.top = `${item.y_percent}%`;
marker.style.width = `${item.width_percent || 5}%`;
marker.style.height = `${item.height_percent || 5}%`;
marker.innerHTML = item.icon || getItemTypeIcon(item.item_type);
marker.title = `${item.item_name || item.type_name} (${item.quantity}개)`;
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="${t.type_code}">${t.icon} ${t.type_name}</option>`
).join('');
}
// 물품 범례 렌더링
function renderItemsLegend() {
const container = document.getElementById('itemsLegend');
if (!container) return;
container.innerHTML = itemTypes.map(t => `
<div class="item-legend-item">
<div class="item-legend-icon" style="background: ${t.color}20; border: 1px solid ${t.color};">
${t.icon}
</div>
<span>${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();
}
});