Files
tk-factory-services/gateway/html/shared/notification-bell.js
Hyungi Ahn 054518f4fc fix: loadNotifications() 에러 내성 강화 - r.ok 체크 추가
배포 시 컨테이너 재시작으로 인한 502 응답이 JSON 파싱 실패를 일으키던 문제 방지.
에러 메시지도 "잠시 후 다시 시도해주세요"로 변경.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 20:05:29 +09:00

438 lines
17 KiB
JavaScript

/**
* 공유 알림 벨 — 모든 서비스 헤더에 자동 삽입
*
* 사용법: initAuth() 성공 후 동적 <script> 로드
* const s = document.createElement('script');
* s.src = '/shared/notification-bell.js?v=1';
* document.head.appendChild(s);
*
* 요구사항: SSOAuth (nav-header.js) 또는 getToken() 함수 존재
*/
(function () {
'use strict';
/* ========== Config ========== */
var POLL_INTERVAL = 60000; // 60초
var DROPDOWN_LIMIT = 5;
var API_ORIGIN = (function () {
var h = window.location.hostname;
if (h.includes('technicalkorea.net')) return 'https://tkfb.technicalkorea.net';
return window.location.protocol + '//' + h + ':30005';
})();
var API_BASE = API_ORIGIN + '/api/notifications';
var PUSH_API_BASE = API_ORIGIN + '/api/push';
/* ========== Token helper ========== */
function _getToken() {
if (window.SSOAuth && window.SSOAuth.getToken) return window.SSOAuth.getToken();
if (typeof getToken === 'function') return getToken();
// cookie fallback
var m = document.cookie.match(/(?:^|; )sso_token=([^;]*)/);
return m ? decodeURIComponent(m[1]) : null;
}
function _authFetch(url, opts) {
opts = opts || {};
opts.headers = opts.headers || {};
var token = _getToken();
if (token) opts.headers['Authorization'] = 'Bearer ' + token;
return fetch(url, opts);
}
/* ========== State ========== */
var pollTimer = null;
var unreadCount = 0;
var dropdownOpen = false;
var pushSubscribed = false;
/* ========== UI: Bell injection ========== */
function injectBell() {
var header = document.querySelector('header');
if (!header) return;
// 벨 컨테이너
var wrapper = document.createElement('div');
wrapper.id = 'notif-bell-wrapper';
wrapper.style.cssText = 'position:relative;display:inline-flex;align-items:center;cursor:pointer;margin-left:12px;';
wrapper.innerHTML =
'<div id="notif-bell-btn" style="position:relative;padding:6px;" title="알림">' +
'<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#4B5563;">' +
'<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>' +
'<path d="M13.73 21a2 2 0 0 1-3.46 0"/>' +
'</svg>' +
'<span id="notif-badge" style="display:none;position:absolute;top:0;right:0;background:#EF4444;color:#fff;font-size:11px;font-weight:600;min-width:18px;height:18px;line-height:18px;text-align:center;border-radius:9px;padding:0 4px;">0</span>' +
'</div>' +
'<div id="notif-dropdown" style="display:none;position:fixed;width:340px;max-height:420px;background:#fff;border:1px solid #E5E7EB;border-radius:8px;box-shadow:0 10px 25px rgba(0,0,0,.15);z-index:9999;overflow:hidden;">' +
'<div style="padding:12px 16px;border-bottom:1px solid #F3F4F6;display:flex;justify-content:space-between;align-items:center;">' +
'<span style="font-weight:600;font-size:14px;color:#111827;">알림</span>' +
'<div style="display:flex;gap:8px;align-items:center;">' +
'<button id="notif-push-toggle" style="font-size:12px;color:#6B7280;background:none;border:1px solid #D1D5DB;border-radius:4px;padding:2px 8px;cursor:pointer;" title="Push 알림 설정">🔔 Push</button>' +
'<button id="notif-read-all" style="font-size:12px;color:#3B82F6;background:none;border:none;cursor:pointer;">모두 읽음</button>' +
'</div>' +
'</div>' +
'<div id="notif-list" style="max-height:300px;overflow-y:auto;"></div>' +
'<a id="notif-view-all" href="' + _getAllNotificationsUrl() + '" style="display:block;text-align:center;padding:10px;font-size:13px;color:#3B82F6;text-decoration:none;border-top:1px solid #F3F4F6;">전체보기</a>' +
'</div>';
// 삽입 위치: header 내부, 우측 영역 찾기
var rightArea = header.querySelector('.flex.items-center.gap-3') ||
header.querySelector('.flex.items-center.gap-4') ||
header.querySelector('.flex.items-center.space-x-4') ||
header.querySelector('[class*="items-center"]');
if (rightArea) {
// 로그아웃 버튼 앞에 삽입
var logoutBtn = rightArea.querySelector('button[onclick*="Logout"], button[onclick*="logout"]');
if (logoutBtn) {
rightArea.insertBefore(wrapper, logoutBtn);
} else {
rightArea.insertBefore(wrapper, rightArea.firstChild);
}
} else {
header.appendChild(wrapper);
}
// Event listeners
document.getElementById('notif-bell-btn').addEventListener('click', toggleDropdown);
document.getElementById('notif-read-all').addEventListener('click', markAllRead);
document.getElementById('notif-push-toggle').addEventListener('click', handlePushToggle);
// 외부 클릭 시 닫기
document.addEventListener('click', function (e) {
if (dropdownOpen && !wrapper.contains(e.target)) {
closeDropdown();
}
});
}
function _getAllNotificationsUrl() {
var h = window.location.hostname;
if (h.includes('technicalkorea.net')) return 'https://tkfb.technicalkorea.net/pages/admin/notifications.html';
return window.location.protocol + '//' + h + ':30080/pages/admin/notifications.html';
}
/* ========== UI: Badge ========== */
function updateBadge(count) {
unreadCount = count;
var badge = document.getElementById('notif-badge');
if (!badge) return;
if (count > 0) {
badge.textContent = count > 99 ? '99+' : count;
badge.style.display = 'block';
} else {
badge.style.display = 'none';
}
}
/* ========== UI: Dropdown ========== */
function toggleDropdown(e) {
e && e.stopPropagation();
if (dropdownOpen) { closeDropdown(); return; }
openDropdown();
}
function onScrollWhileOpen() {
closeDropdown();
}
function openDropdown() {
dropdownOpen = true;
var dd = document.getElementById('notif-dropdown');
var btn = document.getElementById('notif-bell-btn');
var rect = btn.getBoundingClientRect();
// 드롭다운 너비: 뷰포트 좁으면 양쪽 8px 여백
var ddWidth = Math.min(340, window.innerWidth - 16);
dd.style.width = ddWidth + 'px';
dd.style.top = (rect.bottom + 4) + 'px';
// 우측 정렬 기본, 왼쪽 넘치면 보정
var rightOffset = window.innerWidth - rect.right;
if (rightOffset + ddWidth > window.innerWidth - 8) {
dd.style.right = 'auto';
dd.style.left = '8px';
} else {
dd.style.left = 'auto';
dd.style.right = Math.max(8, rightOffset) + 'px';
}
dd.style.display = 'block';
window.addEventListener('scroll', onScrollWhileOpen, { once: true });
loadNotifications();
updatePushToggleUI();
}
function closeDropdown() {
dropdownOpen = false;
document.getElementById('notif-dropdown').style.display = 'none';
}
function loadNotifications() {
var list = document.getElementById('notif-list');
if (!list) return;
list.innerHTML = '<div style="padding:20px;text-align:center;color:#9CA3AF;font-size:13px;">로딩 중...</div>';
_authFetch(API_BASE + '/unread')
.then(function (r) {
if (!r.ok) throw new Error(r.status);
return r.json();
})
.then(function (data) {
if (!data.success || !data.data || data.data.length === 0) {
list.innerHTML = '<div style="padding:20px;text-align:center;color:#9CA3AF;font-size:13px;">새 알림이 없습니다</div>';
return;
}
var items = data.data.slice(0, DROPDOWN_LIMIT);
list.innerHTML = items.map(function (n) {
var timeAgo = _timeAgo(n.created_at);
var typeLabel = _typeLabel(n.type);
return '<div class="notif-item" data-id="' + n.notification_id + '" data-url="' + _escAttr(n.link_url || '') + '" style="padding:10px 16px;border-bottom:1px solid #F9FAFB;cursor:pointer;transition:background .15s;" onmouseover="this.style.background=\'#F9FAFB\'" onmouseout="this.style.background=\'transparent\'">' +
'<div style="display:flex;justify-content:space-between;align-items:flex-start;">' +
'<div style="flex:1;min-width:0;">' +
'<div style="display:flex;align-items:center;gap:6px;margin-bottom:2px;">' +
'<span style="font-size:10px;background:#EFF6FF;color:#3B82F6;padding:1px 6px;border-radius:3px;font-weight:500;">' + _escHtml(typeLabel) + '</span>' +
'<span style="font-size:11px;color:#9CA3AF;">' + _escHtml(timeAgo) + '</span>' +
'</div>' +
'<div style="font-size:13px;font-weight:500;color:#111827;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">' + _escHtml(n.title) + '</div>' +
(n.message ? '<div style="font-size:12px;color:#6B7280;margin-top:1px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">' + _escHtml(n.message) + '</div>' : '') +
'</div>' +
'<div style="width:8px;height:8px;border-radius:50%;background:#3B82F6;flex-shrink:0;margin-top:6px;margin-left:8px;"></div>' +
'</div>' +
'</div>';
}).join('');
// 클릭 이벤트
list.querySelectorAll('.notif-item').forEach(function (el) {
el.addEventListener('click', function () {
var id = el.getAttribute('data-id');
var url = el.getAttribute('data-url');
_authFetch(API_BASE + '/' + id + '/read', { method: 'POST' })
.then(function () { fetchUnreadCount(); })
.catch(function () {});
if (url) {
// 같은 서비스 내 URL이면 직접 이동, 아니면 새 탭
if (url.startsWith('/')) window.location.href = url;
else window.open(url, '_blank');
}
closeDropdown();
});
});
})
.catch(function () {
list.innerHTML = '<div style="padding:20px;text-align:center;color:#EF4444;font-size:13px;">잠시 후 다시 시도해주세요</div>';
});
}
function markAllRead(e) {
e && e.stopPropagation();
_authFetch(API_BASE + '/read-all', { method: 'POST' })
.then(function () {
updateBadge(0);
var list = document.getElementById('notif-list');
if (list) list.innerHTML = '<div style="padding:20px;text-align:center;color:#9CA3AF;font-size:13px;">새 알림이 없습니다</div>';
})
.catch(function () {});
}
/* ========== Polling ========== */
function fetchUnreadCount() {
_authFetch(API_BASE + '/unread/count')
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.success) updateBadge(data.data.count);
})
.catch(function () {});
}
function startPolling() {
fetchUnreadCount();
pollTimer = setInterval(fetchUnreadCount, POLL_INTERVAL);
}
function stopPolling() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
}
/* ========== Web Push ========== */
function handlePushToggle(e) {
e && e.stopPropagation();
if (pushSubscribed) {
unsubscribePush();
} else {
subscribePush();
}
}
function subscribePush() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
alert('이 브라우저는 Push 알림을 지원하지 않습니다.');
return;
}
// VAPID 공개키 가져오기
fetch(PUSH_API_BASE + '/vapid-public-key')
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.success || !data.data.vapidPublicKey) {
alert('Push 설정을 불러올 수 없습니다.');
return;
}
var vapidKey = data.data.vapidPublicKey;
return navigator.serviceWorker.getRegistration('/').then(function (reg) {
if (!reg) {
// push-sw.js 등록
return navigator.serviceWorker.register('/push-sw.js', { scope: '/' });
}
return reg;
}).then(function (reg) {
return reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: _urlBase64ToUint8Array(vapidKey)
});
}).then(function (subscription) {
// 서버에 구독 저장
return _authFetch(PUSH_API_BASE + '/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subscription: subscription.toJSON() })
});
}).then(function (r) { return r.json(); })
.then(function (result) {
if (result.success) {
pushSubscribed = true;
stopPolling(); // Push 구독 성공 → 폴링 중단
updatePushToggleUI();
}
});
})
.catch(function (err) {
console.error('[notification-bell] Push subscribe error:', err);
if (err.name === 'NotAllowedError') {
alert('알림 권한이 거부되었습니다. 브라우저 설정에서 허용해주세요.');
}
});
}
function unsubscribePush() {
navigator.serviceWorker.getRegistration('/').then(function (reg) {
if (!reg) return;
return reg.pushManager.getSubscription();
}).then(function (sub) {
if (!sub) return;
var endpoint = sub.endpoint;
return sub.unsubscribe().then(function () {
return _authFetch(PUSH_API_BASE + '/unsubscribe', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint: endpoint })
});
});
}).then(function () {
pushSubscribed = false;
startPolling(); // Push 해제 → 폴링 복구
updatePushToggleUI();
}).catch(function (err) {
console.error('[notification-bell] Push unsubscribe error:', err);
});
}
function checkPushStatus() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
navigator.serviceWorker.getRegistration('/').then(function (reg) {
if (!reg) return;
return reg.pushManager.getSubscription();
}).then(function (sub) {
if (sub) {
pushSubscribed = true;
stopPolling(); // 이미 구독 상태면 폴링 불필요
// Push로 뱃지만 갱신 (초기 1회)
fetchUnreadCount();
}
}).catch(function () {});
}
function updatePushToggleUI() {
var btn = document.getElementById('notif-push-toggle');
if (!btn) return;
if (pushSubscribed) {
btn.textContent = '🔕 Push 해제';
btn.style.borderColor = '#EF4444';
btn.style.color = '#EF4444';
} else {
btn.textContent = '🔔 Push';
btn.style.borderColor = '#D1D5DB';
btn.style.color = '#6B7280';
}
}
/* ========== Push SW message handler ========== */
function listenForPushMessages() {
if (!('serviceWorker' in navigator)) return;
navigator.serviceWorker.addEventListener('message', function (e) {
if (e.data && e.data.type === 'NOTIFICATION_RECEIVED') {
// Push 수신 시 뱃지 즉시 갱신
fetchUnreadCount();
}
});
}
/* ========== Helpers ========== */
function _timeAgo(dateStr) {
if (!dateStr) return '';
var now = Date.now();
var then = new Date(dateStr).getTime();
var diff = Math.floor((now - then) / 1000);
if (diff < 60) return '방금 전';
if (diff < 3600) return Math.floor(diff / 60) + '분 전';
if (diff < 86400) return Math.floor(diff / 3600) + '시간 전';
if (diff < 604800) return Math.floor(diff / 86400) + '일 전';
return dateStr.substring(0, 10);
}
function _typeLabel(type) {
var map = { system: '시스템', repair: '설비수리', safety: '안전', nonconformity: '부적합', partner_work: '협력업체', day_labor: '일용공' };
return map[type] || type || '알림';
}
function _escHtml(s) {
if (!s) return '';
var d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function _escAttr(s) {
if (!s) return '';
return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function _urlBase64ToUint8Array(base64String) {
var padding = '='.repeat((4 - base64String.length % 4) % 4);
var base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
var raw = atob(base64);
var arr = new Uint8Array(raw.length);
for (var i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
return arr;
}
/* ========== Init ========== */
function init() {
// 토큰 없으면 동작하지 않음
if (!_getToken()) return;
injectBell();
startPolling();
checkPushStatus();
listenForPushMessages();
}
// DOM ready 또는 즉시 실행
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();