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>
This commit is contained in:
Hyungi Ahn
2026-02-04 11:41:41 +09:00
parent 2e9d24faf2
commit 90d3e32992
101 changed files with 17421 additions and 7047 deletions

View File

@@ -2,13 +2,13 @@
// 설비 관리 페이지 JavaScript
let equipments = [];
let allEquipments = []; // 필터링 전 전체 데이터
let workplaces = [];
let equipmentTypes = [];
let currentEquipment = null;
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', async () => {
// axios 설정이 완료될 때까지 대기
await waitForAxiosConfig();
await loadInitialData();
});
@@ -22,11 +22,10 @@ function waitForAxiosConfig() {
resolve();
}
}, 50);
// 최대 5초 대기
setTimeout(() => {
clearInterval(check);
if (!axios.defaults.baseURL) {
console.error('⚠️ Axios 설정 시간 초과');
console.error('Axios 설정 시간 초과');
}
resolve();
}, 5000);
@@ -52,7 +51,9 @@ async function loadEquipments() {
try {
const response = await axios.get('/equipments');
if (response.data.success) {
equipments = response.data.data;
allEquipments = response.data.data;
equipments = [...allEquipments];
renderStats();
renderEquipmentList();
}
} catch (error) {
@@ -71,7 +72,6 @@ async function loadWorkplaces() {
}
} catch (error) {
console.error('작업장 목록 로드 실패:', error);
throw error;
}
}
@@ -85,26 +85,69 @@ async function loadEquipmentTypes() {
}
} catch (error) {
console.error('설비 유형 로드 실패:', error);
// 실패해도 계속 진행 (유형이 없을 수 있음)
}
}
// 통계 렌더링
function renderStats() {
const container = document.getElementById('statsSection');
if (!container) return;
const totalCount = allEquipments.length;
const activeCount = allEquipments.filter(e => e.status === 'active').length;
const maintenanceCount = allEquipments.filter(e => e.status === 'maintenance').length;
const inactiveCount = allEquipments.filter(e => e.status === 'inactive').length;
const totalValue = allEquipments.reduce((sum, e) => sum + (Number(e.purchase_price) || 0), 0);
const avgValue = totalCount > 0 ? totalValue / totalCount : 0;
container.innerHTML = `
<div class="eq-stat-card highlight">
<div class="eq-stat-label">전체 설비</div>
<div class="eq-stat-value">${totalCount}대</div>
<div class="eq-stat-sub">총 자산가치 ${formatPriceShort(totalValue)}</div>
</div>
<div class="eq-stat-card">
<div class="eq-stat-label">활성</div>
<div class="eq-stat-value" style="color: #16a34a;">${activeCount}대</div>
<div class="eq-stat-sub">${totalCount > 0 ? Math.round(activeCount / totalCount * 100) : 0}%</div>
</div>
<div class="eq-stat-card">
<div class="eq-stat-label">정비중</div>
<div class="eq-stat-value" style="color: #d97706;">${maintenanceCount}대</div>
<div class="eq-stat-sub">${totalCount > 0 ? Math.round(maintenanceCount / totalCount * 100) : 0}%</div>
</div>
<div class="eq-stat-card">
<div class="eq-stat-label">비활성</div>
<div class="eq-stat-value" style="color: #dc2626;">${inactiveCount}대</div>
<div class="eq-stat-sub">${totalCount > 0 ? Math.round(inactiveCount / totalCount * 100) : 0}%</div>
</div>
<div class="eq-stat-card">
<div class="eq-stat-label">평균 구입가</div>
<div class="eq-stat-value">${formatPriceShort(avgValue)}</div>
<div class="eq-stat-sub">설비당 평균</div>
</div>
`;
}
// 작업장 필터 채우기
function populateWorkplaceFilters() {
const filterWorkplace = document.getElementById('filterWorkplace');
const modalWorkplace = document.getElementById('workplaceId');
const workplaceOptions = workplaces.map(w =>
`<option value="${w.workplace_id}">${w.category_name} - ${w.workplace_name}</option>`
`<option value="${w.workplace_id}">${w.category_name ? w.category_name + ' - ' : ''}${w.workplace_name}</option>`
).join('');
filterWorkplace.innerHTML = '<option value="">전체</option>' + workplaceOptions;
modalWorkplace.innerHTML = '<option value="">선택 안함</option>' + workplaceOptions;
if (filterWorkplace) filterWorkplace.innerHTML = '<option value="">전체</option>' + workplaceOptions;
if (modalWorkplace) modalWorkplace.innerHTML = '<option value="">선택 안함</option>' + workplaceOptions;
}
// 설비 유형 필터 채우기
function populateTypeFilter() {
const filterType = document.getElementById('filterType');
if (!filterType) return;
const typeOptions = equipmentTypes.map(type =>
`<option value="${type}">${type}</option>`
).join('');
@@ -117,7 +160,7 @@ function renderEquipmentList() {
if (equipments.length === 0) {
container.innerHTML = `
<div class="empty-state">
<div class="eq-empty-state">
<p>등록된 설비가 없습니다.</p>
<button class="btn btn-primary" onclick="openEquipmentModal()">설비 추가하기</button>
</div>
@@ -126,47 +169,56 @@ function renderEquipmentList() {
}
const tableHTML = `
<table class="data-table">
<thead>
<tr>
<th>설비 코드</th>
<th>설비명</th>
<th>유형</th>
<th>작업장</th>
<th>제조사</th>
<th>모델명</th>
<th>상태</th>
<th>관리</th>
</tr>
</thead>
<tbody>
${equipments.map(equipment => `
<div class="eq-result-count">
<span>검색 결과 <strong>${equipments.length}건</strong></span>
</div>
<div class="eq-table-wrapper">
<table class="eq-table">
<thead>
<tr>
<td><strong>${equipment.equipment_code}</strong></td>
<td>${equipment.equipment_name}</td>
<td>${equipment.equipment_type || '-'}</td>
<td>${equipment.workplace_name || '-'}</td>
<td>${equipment.manufacturer || '-'}</td>
<td>${equipment.model_name || '-'}</td>
<td>
<span class="status-badge status-${equipment.status}">
${getStatusText(equipment.status)}
</span>
</td>
<td>
<div class="action-buttons">
<button class="btn-small btn-primary" onclick="editEquipment(${equipment.equipment_id})" title="수정">
✏️
</button>
<button class="btn-small btn-danger" onclick="deleteEquipment(${equipment.equipment_id})" title="삭제">
🗑️
</button>
</div>
</td>
<th>관리번호</th>
<th>설비명</th>
<th>모델명</th>
<th>규격</th>
<th>제조사</th>
<th>구입처</th>
<th style="text-align:right">구입가격</th>
<th>구입일자</th>
<th>상태</th>
<th style="width:80px">관리</th>
</tr>
`).join('')}
</tbody>
</table>
</thead>
<tbody>
${equipments.map(eq => `
<tr>
<td class="eq-col-code">${eq.equipment_code || '-'}</td>
<td class="eq-col-name" title="${eq.equipment_name || ''}">${eq.equipment_name || '-'}</td>
<td class="eq-col-model" title="${eq.model_name || ''}">${eq.model_name || '-'}</td>
<td class="eq-col-spec" title="${eq.specifications || ''}">${eq.specifications || '-'}</td>
<td>${eq.manufacturer || '-'}</td>
<td>${eq.supplier || '-'}</td>
<td class="eq-col-price">${eq.purchase_price ? formatPrice(eq.purchase_price) : '-'}</td>
<td class="eq-col-date">${eq.installation_date ? formatDate(eq.installation_date) : '-'}</td>
<td>
<span class="eq-status eq-status-${eq.status}">
${getStatusText(eq.status)}
</span>
</td>
<td>
<div class="eq-actions">
<button class="eq-btn-action eq-btn-edit" onclick="editEquipment(${eq.equipment_id})" title="수정">
✏️
</button>
<button class="eq-btn-action eq-btn-delete" onclick="deleteEquipment(${eq.equipment_id})" title="삭제">
🗑️
</button>
</div>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
container.innerHTML = tableHTML;
@@ -179,7 +231,32 @@ function getStatusText(status) {
'maintenance': '정비중',
'inactive': '비활성'
};
return statusMap[status] || status;
return statusMap[status] || status || '-';
}
// 가격 포맷팅 (전체)
function formatPrice(price) {
if (!price) return '-';
return Number(price).toLocaleString('ko-KR') + '원';
}
// 가격 포맷팅 (축약)
function formatPriceShort(price) {
if (!price) return '0원';
const num = Number(price);
if (num >= 100000000) {
return (num / 100000000).toFixed(1).replace(/\.0$/, '') + '억원';
} else if (num >= 10000) {
return (num / 10000).toFixed(0) + '만원';
}
return num.toLocaleString('ko-KR') + '원';
}
// 날짜 포맷팅
function formatDate(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit' });
}
// 필터링
@@ -189,38 +266,28 @@ function filterEquipments() {
const statusFilter = document.getElementById('filterStatus').value;
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
// API에서 필터링된 데이터를 가져오는 것이 더 효율적이지만,
// 클라이언트 측에서도 필터링을 적용합니다.
let filtered = [...equipments];
equipments = allEquipments.filter(e => {
if (workplaceFilter && e.workplace_id != workplaceFilter) return false;
if (typeFilter && e.equipment_type !== typeFilter) return false;
if (statusFilter && e.status !== statusFilter) return false;
if (searchTerm) {
const searchFields = [
e.equipment_name,
e.equipment_code,
e.manufacturer,
e.supplier,
e.model_name
].map(f => (f || '').toLowerCase());
if (!searchFields.some(f => f.includes(searchTerm))) return false;
}
return true;
});
if (workplaceFilter) {
filtered = filtered.filter(e => e.workplace_id == workplaceFilter);
}
if (typeFilter) {
filtered = filtered.filter(e => e.equipment_type === typeFilter);
}
if (statusFilter) {
filtered = filtered.filter(e => e.status === statusFilter);
}
if (searchTerm) {
filtered = filtered.filter(e =>
e.equipment_name.toLowerCase().includes(searchTerm) ||
e.equipment_code.toLowerCase().includes(searchTerm)
);
}
// 임시로 equipments를 필터링된 것으로 교체하고 렌더링
const originalEquipments = equipments;
equipments = filtered;
renderEquipmentList();
equipments = originalEquipments;
}
// 설비 추가 모달 열기
function openEquipmentModal(equipmentId = null) {
async function openEquipmentModal(equipmentId = null) {
currentEquipment = equipmentId;
const modal = document.getElementById('equipmentModal');
const modalTitle = document.getElementById('modalTitle');
@@ -234,30 +301,51 @@ function openEquipmentModal(equipmentId = null) {
loadEquipmentData(equipmentId);
} else {
modalTitle.textContent = '설비 추가';
// 새 설비일 경우 다음 관리번호 자동 생성
await loadNextEquipmentCode();
}
modal.style.display = 'flex';
}
// 다음 관리번호 로드
async function loadNextEquipmentCode() {
try {
console.log('📋 다음 관리번호 조회 중...');
const response = await axios.get('/equipments/next-code');
console.log('📋 다음 관리번호 응답:', response.data);
if (response.data.success) {
document.getElementById('equipmentCode').value = response.data.data.next_code;
console.log('✅ 다음 관리번호 설정:', response.data.data.next_code);
}
} catch (error) {
console.error('❌ 다음 관리번호 조회 실패:', error);
console.error('❌ 에러 상세:', error.response?.data || error.message);
// 오류 시 기본값으로 빈 값 유지 (사용자가 직접 입력)
}
}
// 설비 데이터 로드 (수정용)
async function loadEquipmentData(equipmentId) {
try {
const response = await axios.get(`/equipments/${equipmentId}`);
if (response.data.success) {
const equipment = response.data.data;
const eq = response.data.data;
document.getElementById('equipmentId').value = equipment.equipment_id;
document.getElementById('equipmentCode').value = equipment.equipment_code;
document.getElementById('equipmentName').value = equipment.equipment_name;
document.getElementById('equipmentType').value = equipment.equipment_type || '';
document.getElementById('workplaceId').value = equipment.workplace_id || '';
document.getElementById('manufacturer').value = equipment.manufacturer || '';
document.getElementById('modelName').value = equipment.model_name || '';
document.getElementById('serialNumber').value = equipment.serial_number || '';
document.getElementById('installationDate').value = equipment.installation_date ? equipment.installation_date.split('T')[0] : '';
document.getElementById('equipmentStatus').value = equipment.status || 'active';
document.getElementById('specifications').value = equipment.specifications || '';
document.getElementById('notes').value = equipment.notes || '';
document.getElementById('equipmentId').value = eq.equipment_id;
document.getElementById('equipmentCode').value = eq.equipment_code || '';
document.getElementById('equipmentName').value = eq.equipment_name || '';
document.getElementById('equipmentType').value = eq.equipment_type || '';
document.getElementById('workplaceId').value = eq.workplace_id || '';
document.getElementById('manufacturer').value = eq.manufacturer || '';
document.getElementById('supplier').value = eq.supplier || '';
document.getElementById('purchasePrice').value = eq.purchase_price || '';
document.getElementById('modelName').value = eq.model_name || '';
document.getElementById('serialNumber').value = eq.serial_number || '';
document.getElementById('installationDate').value = eq.installation_date ? eq.installation_date.split('T')[0] : '';
document.getElementById('equipmentStatus').value = eq.status || 'active';
document.getElementById('specifications').value = eq.specifications || '';
document.getElementById('notes').value = eq.notes || '';
}
} catch (error) {
console.error('설비 데이터 로드 실패:', error);
@@ -280,6 +368,8 @@ async function saveEquipment() {
equipment_type: document.getElementById('equipmentType').value.trim() || null,
workplace_id: document.getElementById('workplaceId').value || null,
manufacturer: document.getElementById('manufacturer').value.trim() || null,
supplier: document.getElementById('supplier').value.trim() || null,
purchase_price: document.getElementById('purchasePrice').value || null,
model_name: document.getElementById('modelName').value.trim() || null,
serial_number: document.getElementById('serialNumber').value.trim() || null,
installation_date: document.getElementById('installationDate').value || null,
@@ -288,9 +378,8 @@ async function saveEquipment() {
notes: document.getElementById('notes').value.trim() || null
};
// 유효성 검사
if (!equipmentData.equipment_code) {
alert('설비 코드를 입력해주세요.');
alert('관리번호를 입력해주세요.');
return;
}
@@ -302,10 +391,8 @@ async function saveEquipment() {
try {
let response;
if (equipmentId) {
// 수정
response = await axios.put(`/equipments/${equipmentId}`, equipmentData);
} else {
// 추가
response = await axios.post('/equipments', equipmentData);
}
@@ -313,11 +400,11 @@ async function saveEquipment() {
alert(equipmentId ? '설비가 수정되었습니다.' : '설비가 추가되었습니다.');
closeEquipmentModal();
await loadEquipments();
await loadEquipmentTypes(); // 새로운 유형이 추가될 수 있으므로
await loadEquipmentTypes();
}
} catch (error) {
console.error('설비 저장 실패:', error);
if (error.response && error.response.data && error.response.data.message) {
if (error.response?.data?.message) {
alert(error.response.data.message);
} else {
alert('설비 저장 중 오류가 발생했습니다.');
@@ -332,7 +419,7 @@ function editEquipment(equipmentId) {
// 설비 삭제
async function deleteEquipment(equipmentId) {
const equipment = equipments.find(e => e.equipment_id === equipmentId);
const equipment = allEquipments.find(e => e.equipment_id === equipmentId);
if (!equipment) return;
if (!confirm(`'${equipment.equipment_name}' 설비를 삭제하시겠습니까?`)) {
@@ -359,7 +446,7 @@ document.addEventListener('keydown', (e) => {
});
// 모달 외부 클릭 시 닫기
document.getElementById('equipmentModal').addEventListener('click', (e) => {
document.getElementById('equipmentModal')?.addEventListener('click', (e) => {
if (e.target.id === 'equipmentModal') {
closeEquipmentModal();
}