feat: 알림 시스템 및 시설설비 관리 기능 구현

- 알림 시스템 구축 (navbar 알림 아이콘, 드롭다운)
- 알림 수신자 설정 기능 (계정관리 페이지)
- 시설설비 관리 페이지 추가 (수리 워크플로우)
- 수리 신청 → 접수 → 처리중 → 완료 상태 관리
- 사이드바 메뉴 구조 개선 (공장 관리 카테고리)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-02-04 15:56:57 +09:00
parent d1aec517a6
commit b8ccde7f17
24 changed files with 3204 additions and 9 deletions

View File

@@ -1074,3 +1074,190 @@ async function unlinkWorker() {
}
}
window.unlinkWorker = unlinkWorker;
// ========== 알림 수신자 관리 ========== //
let notificationRecipients = {};
let allUsersForRecipient = [];
let currentNotificationType = null;
const NOTIFICATION_TYPE_CONFIG = {
repair: { name: '설비 수리', icon: '🔧', description: '설비 수리 신청 시 알림을 받을 사용자' },
safety: { name: '안전 신고', icon: '⚠️', description: '안전 관련 신고 시 알림을 받을 사용자' },
nonconformity: { name: '부적합 신고', icon: '🚫', description: '부적합 사항 신고 시 알림을 받을 사용자' },
equipment: { name: '설비 관련', icon: '🔩', description: '설비 관련 알림을 받을 사용자' },
maintenance: { name: '정기점검', icon: '🛠️', description: '정기점검 알림을 받을 사용자' },
system: { name: '시스템', icon: '📢', description: '시스템 알림을 받을 사용자' }
};
// 알림 수신자 목록 로드
async function loadNotificationRecipients() {
try {
const response = await window.apiCall('/notification-recipients');
if (response.success) {
notificationRecipients = response.data || {};
renderNotificationTypeCards();
}
} catch (error) {
console.error('알림 수신자 로드 오류:', error);
}
}
// 알림 유형 카드 렌더링
function renderNotificationTypeCards() {
const container = document.getElementById('notificationTypeCards');
if (!container) return;
let html = '';
Object.keys(NOTIFICATION_TYPE_CONFIG).forEach(type => {
const config = NOTIFICATION_TYPE_CONFIG[type];
const recipients = notificationRecipients[type]?.recipients || [];
html += `
<div class="notification-type-card ${type}">
<div class="notification-type-header">
<div class="notification-type-title">
<span class="notification-type-icon">${config.icon}</span>
<span>${config.name}</span>
</div>
<button class="edit-recipients-btn" onclick="openRecipientModal('${type}')">
편집
</button>
</div>
<div class="recipient-list">
${recipients.length > 0
? recipients.map(r => `
<span class="recipient-tag">
<span class="tag-icon">👤</span>
${r.user_name || r.username}
</span>
`).join('')
: '<span class="no-recipients">지정된 수신자 없음</span>'
}
</div>
</div>
`;
});
container.innerHTML = html;
}
// 수신자 편집 모달 열기
async function openRecipientModal(notificationType) {
currentNotificationType = notificationType;
const config = NOTIFICATION_TYPE_CONFIG[notificationType];
// 모달 정보 업데이트
document.getElementById('recipientModalTitle').textContent = config.name + ' 알림 수신자';
document.getElementById('recipientModalDesc').textContent = config.description;
// 사용자 목록 로드 (users가 이미 로드되어 있으면 사용)
if (users.length === 0) {
await loadUsers();
}
allUsersForRecipient = users.filter(u => u.is_active);
// 현재 수신자 목록
const currentRecipients = notificationRecipients[notificationType]?.recipients || [];
const currentRecipientIds = currentRecipients.map(r => r.user_id);
// 사용자 목록 렌더링
renderRecipientUserList(currentRecipientIds);
// 검색 이벤트
const searchInput = document.getElementById('recipientSearchInput');
searchInput.value = '';
searchInput.oninput = (e) => {
renderRecipientUserList(currentRecipientIds, e.target.value);
};
// 모달 표시
document.getElementById('notificationRecipientModal').style.display = 'flex';
}
window.openRecipientModal = openRecipientModal;
// 수신자 사용자 목록 렌더링
function renderRecipientUserList(selectedIds, searchTerm = '') {
const container = document.getElementById('recipientUserList');
if (!container) return;
let filteredUsers = allUsersForRecipient;
if (searchTerm) {
const term = searchTerm.toLowerCase();
filteredUsers = filteredUsers.filter(u =>
(u.name && u.name.toLowerCase().includes(term)) ||
(u.username && u.username.toLowerCase().includes(term))
);
}
if (filteredUsers.length === 0) {
container.innerHTML = '<div style="padding: 2rem; text-align: center; color: #6c757d;">사용자가 없습니다</div>';
return;
}
container.innerHTML = filteredUsers.map(user => {
const isSelected = selectedIds.includes(user.user_id);
return `
<div class="recipient-user-item ${isSelected ? 'selected' : ''}" onclick="toggleRecipientUser(${user.user_id}, this)">
<input type="checkbox" ${isSelected ? 'checked' : ''} data-user-id="${user.user_id}">
<div class="user-avatar-small">${(user.name || user.username).charAt(0)}</div>
<div class="recipient-user-info">
<div class="recipient-user-name">${user.name || user.username}</div>
<div class="recipient-user-role">${getRoleName(user.role)}</div>
</div>
</div>
`;
}).join('');
}
// 수신자 토글
function toggleRecipientUser(userId, element) {
const checkbox = element.querySelector('input[type="checkbox"]');
checkbox.checked = !checkbox.checked;
element.classList.toggle('selected', checkbox.checked);
}
window.toggleRecipientUser = toggleRecipientUser;
// 수신자 모달 닫기
function closeRecipientModal() {
document.getElementById('notificationRecipientModal').style.display = 'none';
currentNotificationType = null;
}
window.closeRecipientModal = closeRecipientModal;
// 알림 수신자 저장
async function saveNotificationRecipients() {
if (!currentNotificationType) {
showToast('알림 유형이 선택되지 않았습니다.', 'error');
return;
}
try {
const checkboxes = document.querySelectorAll('#recipientUserList input[type="checkbox"]:checked');
const userIds = Array.from(checkboxes).map(cb => parseInt(cb.dataset.userId));
const response = await window.apiCall(`/notification-recipients/${currentNotificationType}`, 'PUT', {
user_ids: userIds
});
if (response.success) {
showToast('알림 수신자가 저장되었습니다.', 'success');
closeRecipientModal();
await loadNotificationRecipients();
} else {
throw new Error(response.message || '저장에 실패했습니다.');
}
} catch (error) {
console.error('알림 수신자 저장 오류:', error);
showToast(`저장 중 오류가 발생했습니다: ${error.message}`, 'error');
}
}
window.saveNotificationRecipients = saveNotificationRecipients;
// 초기화 시 알림 수신자 로드
const originalInitializePage = initializePage;
initializePage = async function() {
await originalInitializePage();
await loadNotificationRecipients();
};

View File

@@ -254,6 +254,114 @@
}
});
}
// 알림 버튼 이벤트
const notificationBtn = document.getElementById('notificationBtn');
const notificationDropdown = document.getElementById('notificationDropdown');
const notificationWrapper = document.getElementById('notificationWrapper');
if (notificationBtn && notificationDropdown) {
notificationBtn.addEventListener('click', (e) => {
e.stopPropagation();
notificationDropdown.classList.toggle('show');
});
document.addEventListener('click', (e) => {
if (notificationWrapper && !notificationWrapper.contains(e.target)) {
notificationDropdown.classList.remove('show');
}
});
}
}
// ===== 알림 로드 =====
async function loadNotifications() {
try {
const token = localStorage.getItem('token');
if (!token) return;
const response = await fetch(`${window.API_BASE_URL}/notifications/unread`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) return;
const result = await response.json();
if (result.success) {
const notifications = result.data || [];
updateNotificationBadge(notifications.length);
renderNotificationList(notifications);
}
} catch (error) {
console.warn('알림 로드 오류:', error.message);
}
}
function updateNotificationBadge(count) {
const badge = document.getElementById('notificationBadge');
const btn = document.getElementById('notificationBtn');
if (!badge) return;
if (count > 0) {
badge.textContent = count > 99 ? '99+' : count;
badge.style.display = 'flex';
btn?.classList.add('has-notifications');
} else {
badge.style.display = 'none';
btn?.classList.remove('has-notifications');
}
}
function renderNotificationList(notifications) {
const list = document.getElementById('notificationList');
if (!list) return;
if (notifications.length === 0) {
list.innerHTML = '<div class="notification-empty">새 알림이 없습니다.</div>';
return;
}
const icons = { repair: '🔧', safety: '⚠️', system: '📢', equipment: '🔩', maintenance: '🛠️' };
list.innerHTML = notifications.slice(0, 5).map(n => `
<div class="notification-item ${n.is_read ? '' : 'unread'}" data-id="${n.notification_id}" data-url="${n.link_url || ''}">
<div class="notification-item-icon ${n.type || 'repair'}">${icons[n.type] || '🔔'}</div>
<div class="notification-item-content">
<div class="notification-item-title">${escapeHtml(n.title)}</div>
<div class="notification-item-desc">${escapeHtml(n.message || '')}</div>
</div>
<div class="notification-item-time">${formatTimeAgo(n.created_at)}</div>
</div>
`).join('');
list.querySelectorAll('.notification-item').forEach(item => {
item.addEventListener('click', () => {
const url = item.dataset.url;
// 수리 알림은 클릭해도 읽음 처리 안함 (수리 처리 페이지에서 확인 처리해야 함)
window.location.href = url || '/pages/admin/notifications.html';
});
});
}
function formatTimeAgo(dateString) {
const date = new Date(dateString);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return '방금 전';
if (diffMins < 60) return `${diffMins}분 전`;
if (diffHours < 24) return `${diffHours}시간 전`;
if (diffDays < 7) return `${diffDays}일 전`;
return date.toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' });
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// ===== 날짜/시간 업데이트 =====
@@ -387,6 +495,10 @@
// 8. 날씨 (백그라운드)
setTimeout(updateWeather, 100);
// 9. 알림 로드 (30초마다 갱신)
setTimeout(loadNotifications, 200);
setInterval(loadNotifications, 30000);
console.log('✅ app-init 완료');
}

View File

@@ -2,7 +2,7 @@
import { config } from './config.js';
// 캐시 버전 (컴포넌트 변경 시 증가)
const CACHE_VERSION = 'v1';
const CACHE_VERSION = 'v4';
/**
* 컴포넌트 HTML을 캐시에서 가져오거나 fetch

View File

@@ -269,6 +269,151 @@ async function updateWeather() {
}
}
// ==========================================
// 알림 시스템
// ==========================================
/**
* 알림 관련 이벤트 설정
*/
function setupNotificationEvents() {
const notificationBtn = document.getElementById('notificationBtn');
const notificationDropdown = document.getElementById('notificationDropdown');
const notificationWrapper = document.getElementById('notificationWrapper');
if (notificationBtn) {
notificationBtn.addEventListener('click', (e) => {
e.stopPropagation();
notificationDropdown?.classList.toggle('show');
});
}
// 외부 클릭시 드롭다운 닫기
document.addEventListener('click', (e) => {
if (notificationWrapper && notificationDropdown && !notificationWrapper.contains(e.target)) {
notificationDropdown.classList.remove('show');
}
});
}
/**
* 알림 목록 로드
*/
async function loadNotifications() {
try {
const token = localStorage.getItem('token');
if (!token) return;
const response = await fetch(`${window.API_BASE_URL}/notifications/unread`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) return;
const result = await response.json();
if (result.success) {
const notifications = result.data || [];
updateNotificationBadge(notifications.length);
renderNotificationList(notifications);
}
} catch (error) {
console.warn('알림 로드 오류:', error.message);
}
}
/**
* 배지 업데이트
*/
function updateNotificationBadge(count) {
const badge = document.getElementById('notificationBadge');
const btn = document.getElementById('notificationBtn');
if (!badge) return;
if (count > 0) {
badge.textContent = count > 99 ? '99+' : count;
badge.style.display = 'flex';
btn?.classList.add('has-notifications');
} else {
badge.style.display = 'none';
btn?.classList.remove('has-notifications');
}
}
/**
* 알림 목록 렌더링
*/
function renderNotificationList(notifications) {
const list = document.getElementById('notificationList');
if (!list) return;
if (notifications.length === 0) {
list.innerHTML = '<div class="notification-empty">새 알림이 없습니다.</div>';
return;
}
const NOTIF_ICONS = {
repair: '🔧',
safety: '⚠️',
system: '📢',
equipment: '🔩',
maintenance: '🛠️'
};
list.innerHTML = notifications.slice(0, 5).map(n => `
<div class="notification-item ${n.is_read ? '' : 'unread'}" data-id="${n.notification_id}" data-url="${n.link_url || ''}">
<div class="notification-item-icon ${n.type || 'repair'}">
${NOTIF_ICONS[n.type] || '🔔'}
</div>
<div class="notification-item-content">
<div class="notification-item-title">${escapeHtml(n.title)}</div>
<div class="notification-item-desc">${escapeHtml(n.message || '')}</div>
</div>
<div class="notification-item-time">${formatTimeAgo(n.created_at)}</div>
</div>
`).join('');
// 클릭 이벤트 추가
list.querySelectorAll('.notification-item').forEach(item => {
item.addEventListener('click', () => {
const linkUrl = item.dataset.url;
// 수리 알림은 클릭해도 읽음 처리 안함 (수리 처리 페이지에서 확인 처리)
window.location.href = linkUrl || '/pages/admin/notifications.html';
});
});
}
/**
* 시간 포맷팅
*/
function formatTimeAgo(dateString) {
const date = new Date(dateString);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return '방금 전';
if (diffMins < 60) return `${diffMins}분 전`;
if (diffHours < 24) return `${diffHours}시간 전`;
if (diffDays < 7) return `${diffDays}일 전`;
return date.toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' });
}
/**
* HTML 이스케이프
*/
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 메인 로직: DOMContentLoaded 시 실행
document.addEventListener('DOMContentLoaded', async () => {
if (getUser()) {
@@ -285,5 +430,10 @@ document.addEventListener('DOMContentLoaded', async () => {
// 4. 날씨 정보 로드 (10분마다 갱신)
updateWeather();
setInterval(updateWeather, 10 * 60 * 1000);
// 5. 알림 이벤트 설정 및 로드 (30초마다 갱신)
setupNotificationEvents();
loadNotifications();
setInterval(loadNotifications, 30000);
}
});

View File

@@ -1398,27 +1398,73 @@ function openPanelRepairModal() {
panelRepairCategories.forEach(item => {
select.innerHTML += `<option value="${item.item_id}">${item.item_name}</option>`;
});
// 새로 추가 옵션
select.innerHTML += '<option value="__new__">+ 새로 추가</option>';
document.getElementById('panelRepairDesc').value = '';
document.getElementById('panelRepairPhotoInput').value = '';
document.getElementById('newRepairTypeName').value = '';
document.getElementById('newRepairTypeGroup').style.display = 'none';
panelRepairPhotoBases = [];
document.getElementById('panelRepairModal').style.display = 'flex';
}
function onRepairTypeChange() {
const select = document.getElementById('panelRepairItem');
const newTypeGroup = document.getElementById('newRepairTypeGroup');
if (select.value === '__new__') {
newTypeGroup.style.display = 'block';
document.getElementById('newRepairTypeName').focus();
} else {
newTypeGroup.style.display = 'none';
}
}
function closePanelRepairModal() {
document.getElementById('panelRepairModal').style.display = 'none';
}
async function submitPanelRepair() {
const itemId = document.getElementById('panelRepairItem').value;
const selectValue = document.getElementById('panelRepairItem').value;
const description = document.getElementById('panelRepairDesc').value;
const newTypeName = document.getElementById('newRepairTypeName').value.trim();
if (!description) {
alert('수리 내용을 입력하세요.');
return;
}
let itemId = selectValue;
// 새 유형 추가하는 경우
if (selectValue === '__new__') {
if (!newTypeName) {
alert('새 수리 유형 이름을 입력하세요.');
return;
}
try {
const addResponse = await window.apiCall('/equipments/repair-categories', 'POST', {
item_name: newTypeName
});
if (addResponse && addResponse.success) {
itemId = addResponse.data.item_id;
// 목록에 추가
panelRepairCategories.push({ item_id: itemId, item_name: newTypeName });
} else {
alert('새 유형 추가에 실패했습니다.');
return;
}
} catch (error) {
console.error('새 유형 추가 실패:', error);
alert('새 유형 추가에 실패했습니다.');
return;
}
}
// 사진 처리
const fileInput = document.getElementById('panelRepairPhotoInput');
const photos = [];
@@ -1728,6 +1774,7 @@ window.confirmPanelMove = confirmPanelMove;
window.openPanelRepairModal = openPanelRepairModal;
window.closePanelRepairModal = closePanelRepairModal;
window.submitPanelRepair = submitPanelRepair;
window.onRepairTypeChange = onRepairTypeChange;
window.openPanelExportModal = openPanelExportModal;
window.closePanelExportModal = closePanelExportModal;
window.submitPanelExport = submitPanelExport;