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

@@ -70,34 +70,12 @@ async function initializePage() {
}
// ========== 사용자 정보 설정 ========== //
// navbar/sidebar는 app-init.js에서 공통 처리
function setupUserInfo() {
const authData = getAuthData();
if (authData && authData.user) {
currentUser = authData.user;
// 사용자 이름 설정
if (elements.userName) {
elements.userName.textContent = currentUser.name || currentUser.username;
}
// 사용자 역할 설정
const roleMap = {
'admin': '관리자',
'system': '시스템 관리자',
'leader': '그룹장',
'user': '작업자'
};
if (elements.userRole) {
elements.userRole.textContent = roleMap[currentUser.role] || '작업자';
}
// 아바타 초기값 설정
if (elements.userInitial) {
const initial = (currentUser.name || currentUser.username).charAt(0);
elements.userInitial.textContent = initial;
}
console.log('👤 사용자 정보 설정 완료:', currentUser.name);
console.log('👤 사용자 정보 로드 완료:', currentUser.name, currentUser.role);
}
}
@@ -249,10 +227,10 @@ function renderUsersTable() {
<button class="action-btn reset-pw" onclick="resetPassword(${user.user_id}, '${user.username}')" title="비밀번호 000000으로 초기화">
비번초기화
</button>
<button class="action-btn toggle" onclick="toggleUserStatus(${user.user_id})">
<button class="action-btn ${user.is_active ? 'deactivate' : 'activate'}" onclick="toggleUserStatus(${user.user_id})">
${user.is_active ? '비활성화' : '활성화'}
</button>
<button class="action-btn delete" onclick="deleteUser(${user.user_id})">
<button class="action-btn delete danger" onclick="permanentDeleteUser(${user.user_id}, '${user.username}')" title="영구 삭제 (복구 불가)">
삭제
</button>
</div>
@@ -325,25 +303,31 @@ function handleFilter(e) {
// ========== 모달 관리 ========== //
function openAddUserModal() {
currentEditingUser = null;
if (elements.modalTitle) {
elements.modalTitle.textContent = '새 사용자 추가';
}
// 폼 초기화
if (elements.userForm) {
elements.userForm.reset();
}
// 비밀번호 필드 표시
if (elements.passwordGroup) {
elements.passwordGroup.style.display = 'block';
}
if (elements.userPasswordInput) {
elements.userPasswordInput.required = true;
}
// 작업자 연결 섹션 숨기기 (새 사용자 추가 시)
const workerLinkGroup = document.getElementById('workerLinkGroup');
if (workerLinkGroup) {
workerLinkGroup.style.display = 'none';
}
if (elements.userModal) {
elements.userModal.style.display = 'flex';
}
@@ -373,16 +357,23 @@ function editUser(userId) {
if (elements.userRoleSelect) elements.userRoleSelect.value = roleToValueMap[user.role] || 'user';
if (elements.userEmailInput) elements.userEmailInput.value = user.email || '';
if (elements.userPhoneInput) elements.userPhoneInput.value = user.phone || '';
// 비밀번호 필드 숨기기 (수정 시에는 선택사항)
if (elements.passwordGroup) {
elements.passwordGroup.style.display = 'none';
}
if (elements.userPasswordInput) {
elements.userPasswordInput.required = false;
}
// 작업자 연결 섹션 표시 (수정 시에만)
const workerLinkGroup = document.getElementById('workerLinkGroup');
if (workerLinkGroup) {
workerLinkGroup.style.display = 'block';
updateLinkedWorkerDisplay(user);
}
if (elements.userModal) {
elements.userModal.style.display = 'flex';
}
@@ -395,14 +386,29 @@ function closeUserModal() {
currentEditingUser = null;
}
function deleteUser(userId) {
const user = users.find(u => u.user_id === userId);
if (!user) return;
currentEditingUser = user;
if (elements.deleteModal) {
elements.deleteModal.style.display = 'flex';
// 영구 삭제 (Hard Delete)
async function permanentDeleteUser(userId, username) {
if (!confirm(`⚠️ 경고: "${username}" 사용자를 영구 삭제하시겠습니까?\n\n이 작업은 되돌릴 수 없습니다!\n관련된 모든 데이터(로그인 기록, 권한 설정 등)도 함께 삭제됩니다.`)) {
return;
}
// 이중 확인
if (!confirm(`정말로 "${username}"을(를) 영구 삭제하시겠습니까?\n\n[확인]을 누르면 즉시 삭제됩니다.`)) {
return;
}
try {
const response = await window.apiCall(`/users/${userId}/permanent`, 'DELETE');
if (response.success) {
showToast(`"${username}" 사용자가 영구 삭제되었습니다.`, 'success');
await loadUsers();
} else {
throw new Error(response.message || '사용자 삭제에 실패했습니다.');
}
} catch (error) {
console.error('사용자 영구 삭제 오류:', error);
showToast(`사용자 삭제 중 오류가 발생했습니다: ${error.message}`, 'error');
}
}
@@ -566,10 +572,10 @@ function showToast(message, type = 'info', duration = 3000) {
// ========== 전역 함수 (HTML에서 호출) ========== //
window.editUser = editUser;
window.deleteUser = deleteUser;
window.toggleUserStatus = toggleUserStatus;
window.closeUserModal = closeUserModal;
window.closeDeleteModal = closeDeleteModal;
window.permanentDeleteUser = permanentDeleteUser;
// ========== 페이지 권한 관리 ========== //
let allPages = [];
@@ -599,75 +605,6 @@ async function loadUserPageAccess(userId) {
}
}
// 페이지 권한 체크박스 렌더링
function renderPageAccessList(userRole) {
const pageAccessList = document.getElementById('pageAccessList');
const pageAccessGroup = document.getElementById('pageAccessGroup');
if (!pageAccessList || !pageAccessGroup) return;
// Admin 사용자는 권한 설정 불필요
if (userRole === 'admin') {
pageAccessGroup.style.display = 'none';
return;
}
pageAccessGroup.style.display = 'block';
// 카테고리별로 페이지 그룹화
const pagesByCategory = {
'work': [],
'admin': [],
'common': [],
'profile': []
};
allPages.forEach(page => {
const category = page.category || 'common';
if (pagesByCategory[category]) {
pagesByCategory[category].push(page);
}
});
const categoryNames = {
'common': '공통',
'work': '작업',
'admin': '관리',
'profile': '프로필'
};
// HTML 생성
let html = '';
Object.keys(pagesByCategory).forEach(category => {
const pages = pagesByCategory[category];
if (pages.length === 0) return;
const catName = categoryNames[category] || category;
html += '<div class="page-access-category">';
html += '<div class="page-access-category-title">' + catName + '</div>';
pages.forEach(page => {
// 프로필과 대시보드는 모든 사용자가 접근 가능하므로 체크박스 비활성화
const isAlwaysAccessible = page.page_key === 'dashboard' || page.page_key.startsWith('profile.');
const isChecked = userPageAccess.find(p => p.page_id === page.id && p.can_access === 1) || isAlwaysAccessible;
html += '<div class="page-access-item"><label>';
html += '<input type="checkbox" class="page-access-checkbox" ';
html += 'data-page-id="' + page.id + '" ';
html += 'data-page-key="' + page.page_key + '" ';
html += (isChecked ? 'checked ' : '');
html += (isAlwaysAccessible ? 'disabled ' : '');
html += '>';
html += '<span class="page-name">' + page.page_name + '</span>';
html += '</label></div>';
});
html += '</div>';
});
pageAccessList.innerHTML = html;
}
// 페이지 권한 저장
async function savePageAccess(userId, containerId = null) {
@@ -701,61 +638,6 @@ async function savePageAccess(userId, containerId = null) {
}
}
// editUser 함수를 수정하여 페이지 권한 로드 추가
const originalEditUser = window.editUser;
window.editUser = async function(userId) {
// 페이지 목록이 없으면 로드
if (allPages.length === 0) {
await loadAllPages();
}
// 원래 editUser 함수 실행
if (originalEditUser) {
originalEditUser(userId);
}
// 사용자의 페이지 권한 로드
await loadUserPageAccess(userId);
// 사용자 정보 가져오기
const user = users.find(u => u.user_id === userId);
if (!user) return;
// 페이지 권한 체크박스 렌더링
const roleToValueMap = {
'Admin': 'admin',
'System Admin': 'admin',
'User': 'user',
'Guest': 'user'
};
const userRole = roleToValueMap[user.role] || 'user';
renderPageAccessList(userRole);
};
// saveUser 함수를 수정하여 페이지 권한 저장 추가
const originalSaveUser = window.saveUser;
window.saveUser = async function() {
try {
// 원래 saveUser 함수 실행
if (originalSaveUser) {
await originalSaveUser();
}
// 사용자 편집 시에만 페이지 권한 저장
if (currentEditingUser && currentEditingUser.user_id) {
const userRole = document.getElementById('userRole')?.value;
// Admin이 아닌 경우에만 페이지 권한 저장
if (userRole !== 'admin') {
await savePageAccess(currentEditingUser.user_id);
}
}
} catch (error) {
console.error('❌ 저장 오류:', error);
throw error;
}
};
@@ -806,66 +688,106 @@ function closePageAccessModal() {
currentPageAccessUser = null;
}
// 페이지 권한 체크박스 렌더링 (모달용)
// 페이지 권한 체크박스 렌더링 (모달용) - 폴더 구조 형태
function renderPageAccessModalList() {
const pageAccessList = document.getElementById('pageAccessModalList');
if (!pageAccessList) return;
// 카테고리별로 페이지 그룹화
const pagesByCategory = {
'work': [],
'admin': [],
'common': [],
'profile': []
// 폴더 구조 정의 (page_key 패턴 기준)
const folderStructure = {
'dashboard': { name: '대시보드', icon: '📊', pages: [] },
'work': { name: '작업 관리', icon: '📋', pages: [] },
'safety': { name: '안전 관리', icon: '🛡️', pages: [] },
'attendance': { name: '근태 관리', icon: '📅', pages: [] },
'admin': { name: '시스템 관리', icon: '⚙️', pages: [] },
'profile': { name: '내 정보', icon: '👤', pages: [] }
};
// 페이지를 폴더별로 분류
allPages.forEach(page => {
const category = page.category || 'common';
if (pagesByCategory[category]) {
pagesByCategory[category].push(page);
const pageKey = page.page_key || '';
if (pageKey === 'dashboard') {
folderStructure['dashboard'].pages.push(page);
} else if (pageKey.startsWith('work.')) {
folderStructure['work'].pages.push(page);
} else if (pageKey.startsWith('safety.')) {
folderStructure['safety'].pages.push(page);
} else if (pageKey.startsWith('attendance.')) {
folderStructure['attendance'].pages.push(page);
} else if (pageKey.startsWith('admin.')) {
folderStructure['admin'].pages.push(page);
} else if (pageKey.startsWith('profile.')) {
folderStructure['profile'].pages.push(page);
}
});
const categoryNames = {
'common': '공통',
'work': '작업',
'admin': '관리',
'profile': '프로필'
};
// HTML 생성 - 폴더 트리 형태
let html = '<div class="folder-tree">';
// HTML 생성
let html = '';
Object.keys(folderStructure).forEach(folderKey => {
const folder = folderStructure[folderKey];
if (folder.pages.length === 0) return;
Object.keys(pagesByCategory).forEach(category => {
const pages = pagesByCategory[category];
if (pages.length === 0) return;
const folderId = 'folder-' + folderKey;
const catName = categoryNames[category] || category;
html += '<div class="page-access-category">';
html += '<div class="page-access-category-title">' + catName + '</div>';
html += '<div class="folder-group">';
html += '<div class="folder-header" onclick="toggleFolder(\'' + folderId + '\')">';
html += '<span class="folder-icon">' + folder.icon + '</span>';
html += '<span class="folder-name">' + folder.name + '</span>';
html += '<span class="folder-count">(' + folder.pages.length + ')</span>';
html += '<span class="folder-toggle" id="toggle-' + folderId + '">▼</span>';
html += '</div>';
pages.forEach(page => {
html += '<div class="folder-content" id="' + folderId + '">';
folder.pages.forEach(page => {
// 프로필과 대시보드는 모든 사용자가 접근 가능
const isAlwaysAccessible = page.page_key === 'dashboard' || page.page_key.startsWith('profile.');
const isChecked = userPageAccess.find(p => p.page_id === page.id && p.can_access === 1) || isAlwaysAccessible;
html += '<div class="page-access-item"><label>';
// 파일명만 추출 (page_key에서)
const fileName = page.page_key.split('.').pop() || page.page_key;
html += '<div class="page-item">';
html += '<label class="page-label">';
html += '<input type="checkbox" class="page-access-checkbox" ';
html += 'data-page-id="' + page.id + '" ';
html += 'data-page-key="' + page.page_key + '" ';
html += (isChecked ? 'checked ' : '');
html += (isAlwaysAccessible ? 'disabled ' : '');
html += '>';
html += '<span class="file-icon">📄</span>';
html += '<span class="page-name">' + page.page_name + '</span>';
html += '</label></div>';
if (isAlwaysAccessible) {
html += '<span class="always-access-badge">기본</span>';
}
html += '</label>';
html += '</div>';
});
html += '</div>';
html += '</div>'; // folder-content
html += '</div>'; // folder-group
});
html += '</div>'; // folder-tree
pageAccessList.innerHTML = html;
}
// 폴더 접기/펼치기
function toggleFolder(folderId) {
const content = document.getElementById(folderId);
const toggle = document.getElementById('toggle-' + folderId);
if (content && toggle) {
const isExpanded = content.style.display !== 'none';
content.style.display = isExpanded ? 'none' : 'block';
toggle.textContent = isExpanded ? '▶' : '▼';
}
}
window.toggleFolder = toggleFolder;
// 페이지 권한 저장 (모달용)
async function savePageAccessFromModal() {
if (!currentPageAccessUser) {
@@ -899,3 +821,256 @@ document.addEventListener('DOMContentLoaded', () => {
saveBtn.addEventListener('click', savePageAccessFromModal);
}
});
// ========== 작업자 연결 기능 ========== //
let departments = [];
let selectedWorkerId = null;
// 연결된 작업자 정보 표시 업데이트
function updateLinkedWorkerDisplay(user) {
const linkedWorkerInfo = document.getElementById('linkedWorkerInfo');
if (!linkedWorkerInfo) return;
if (user.worker_id && user.worker_name) {
linkedWorkerInfo.innerHTML = `
<span class="worker-badge">
<span class="worker-name">👤 ${user.worker_name}</span>
${user.department_name ? `<span class="dept-name">(${user.department_name})</span>` : ''}
</span>
`;
} else {
linkedWorkerInfo.innerHTML = '<span class="no-worker">연결된 작업자 없음</span>';
}
}
// 작업자 선택 모달 열기
async function openWorkerSelectModal() {
if (!currentEditingUser) {
showToast('사용자 정보가 없습니다.', 'error');
return;
}
selectedWorkerId = currentEditingUser.worker_id || null;
// 부서 목록 로드
await loadDepartmentsForSelect();
// 모달 표시
document.getElementById('workerSelectModal').style.display = 'flex';
}
window.openWorkerSelectModal = openWorkerSelectModal;
// 작업자 선택 모달 닫기
function closeWorkerSelectModal() {
document.getElementById('workerSelectModal').style.display = 'none';
selectedWorkerId = null;
}
window.closeWorkerSelectModal = closeWorkerSelectModal;
// 부서 목록 로드
async function loadDepartmentsForSelect() {
try {
const response = await window.apiCall('/departments');
departments = response.data || response || [];
renderDepartmentList();
} catch (error) {
console.error('부서 목록 로드 실패:', error);
showToast('부서 목록을 불러오는데 실패했습니다.', 'error');
}
}
// 부서 목록 렌더링
function renderDepartmentList() {
const container = document.getElementById('departmentList');
if (!container) return;
if (departments.length === 0) {
container.innerHTML = '<div class="empty-message">등록된 부서가 없습니다</div>';
return;
}
container.innerHTML = departments.map(dept => `
<div class="department-item" data-dept-id="${dept.department_id}" onclick="selectDepartment(${dept.department_id})">
<span class="dept-icon">📁</span>
<span class="dept-name">${dept.department_name}</span>
<span class="dept-count">${dept.worker_count || 0}명</span>
</div>
`).join('');
}
// 부서 선택
async function selectDepartment(departmentId) {
// 활성 상태 업데이트
document.querySelectorAll('.department-item').forEach(item => {
item.classList.remove('active');
});
document.querySelector(`.department-item[data-dept-id="${departmentId}"]`)?.classList.add('active');
// 해당 부서의 작업자 목록 로드
await loadWorkersForSelect(departmentId);
}
window.selectDepartment = selectDepartment;
// 부서별 작업자 목록 로드
async function loadWorkersForSelect(departmentId) {
try {
const response = await window.apiCall(`/departments/${departmentId}/workers`);
const workers = response.data || response || [];
renderWorkerListForSelect(workers);
} catch (error) {
console.error('작업자 목록 로드 실패:', error);
showToast('작업자 목록을 불러오는데 실패했습니다.', 'error');
}
}
// 작업자 목록 렌더링 (선택용)
function renderWorkerListForSelect(workers) {
const container = document.getElementById('workerListForSelect');
if (!container) return;
if (workers.length === 0) {
container.innerHTML = '<div class="empty-message">이 부서에 작업자가 없습니다</div>';
return;
}
// 이미 다른 계정에 연결된 작업자 확인을 위해 users 배열 사용
const linkedWorkerIds = users
.filter(u => u.worker_id && u.user_id !== currentEditingUser?.user_id)
.map(u => u.worker_id);
container.innerHTML = workers.map(worker => {
const isSelected = selectedWorkerId === worker.worker_id;
const isLinkedToOther = linkedWorkerIds.includes(worker.worker_id);
const linkedUser = isLinkedToOther ? users.find(u => u.worker_id === worker.worker_id) : null;
return `
<div class="worker-select-item ${isSelected ? 'selected' : ''} ${isLinkedToOther ? 'disabled' : ''}"
onclick="${isLinkedToOther ? '' : `selectWorker(${worker.worker_id}, '${worker.worker_name}')`}">
<div class="worker-avatar">${worker.worker_name.charAt(0)}</div>
<div class="worker-info">
<div class="worker-name">${worker.worker_name}</div>
<div class="worker-role">${getJobTypeName(worker.job_type)}</div>
</div>
${isLinkedToOther ? `<span class="already-linked">${linkedUser?.username} 연결됨</span>` : ''}
<div class="select-indicator">${isSelected ? '✓' : ''}</div>
</div>
`;
}).join('');
}
// 직책 한글 변환
function getJobTypeName(jobType) {
const names = {
leader: '그룹장',
worker: '작업자',
admin: '관리자'
};
return names[jobType] || jobType || '-';
}
// 작업자 선택
async function selectWorker(workerId, workerName) {
selectedWorkerId = workerId;
// UI 업데이트
document.querySelectorAll('.worker-select-item').forEach(item => {
item.classList.remove('selected');
item.querySelector('.select-indicator').textContent = '';
});
const selectedItem = document.querySelector(`.worker-select-item[onclick*="${workerId}"]`);
if (selectedItem) {
selectedItem.classList.add('selected');
selectedItem.querySelector('.select-indicator').textContent = '✓';
}
// 서버에 저장
try {
const response = await window.apiCall(`/users/${currentEditingUser.user_id}`, 'PUT', {
worker_id: workerId
});
if (response.success) {
// currentEditingUser 업데이트
currentEditingUser.worker_id = workerId;
currentEditingUser.worker_name = workerName;
// 부서 정보도 업데이트
const dept = departments.find(d =>
document.querySelector(`.department-item.active`)?.dataset.deptId == d.department_id
);
if (dept) {
currentEditingUser.department_name = dept.department_name;
}
// users 배열 업데이트
const userIndex = users.findIndex(u => u.user_id === currentEditingUser.user_id);
if (userIndex !== -1) {
users[userIndex] = { ...users[userIndex], ...currentEditingUser };
}
// 표시 업데이트
updateLinkedWorkerDisplay(currentEditingUser);
showToast(`${workerName} 작업자가 연결되었습니다.`, 'success');
closeWorkerSelectModal();
} else {
throw new Error(response.message || '작업자 연결에 실패했습니다.');
}
} catch (error) {
console.error('작업자 연결 오류:', error);
showToast(`작업자 연결 중 오류가 발생했습니다: ${error.message}`, 'error');
}
}
window.selectWorker = selectWorker;
// 작업자 연결 해제
async function unlinkWorker() {
if (!currentEditingUser) {
showToast('사용자 정보가 없습니다.', 'error');
return;
}
if (!currentEditingUser.worker_id) {
showToast('연결된 작업자가 없습니다.', 'warning');
closeWorkerSelectModal();
return;
}
if (!confirm('작업자 연결을 해제하시겠습니까?')) {
return;
}
try {
const response = await window.apiCall(`/users/${currentEditingUser.user_id}`, 'PUT', {
worker_id: null
});
if (response.success) {
// currentEditingUser 업데이트
currentEditingUser.worker_id = null;
currentEditingUser.worker_name = null;
currentEditingUser.department_name = null;
// users 배열 업데이트
const userIndex = users.findIndex(u => u.user_id === currentEditingUser.user_id);
if (userIndex !== -1) {
users[userIndex] = { ...users[userIndex], worker_id: null, worker_name: null, department_name: null };
}
// 표시 업데이트
updateLinkedWorkerDisplay(currentEditingUser);
showToast('작업자 연결이 해제되었습니다.', 'success');
closeWorkerSelectModal();
} else {
throw new Error(response.message || '연결 해제에 실패했습니다.');
}
} catch (error) {
console.error('작업자 연결 해제 오류:', error);
showToast(`연결 해제 중 오류가 발생했습니다: ${error.message}`, 'error');
}
}
window.unlinkWorker = unlinkWorker;