- tkuser 탭을 5개 카테고리로 그룹핑 (인력/현장/업무/거래/시스템) - 설비 관리 탭 신규 추가 (CRUD, 필터, 상세 보기) - tkfb 사이드바 admin 메뉴 6개를 tkuser 외부 링크로 교체 - tkfb admin HTML 6개를 tkuser 리다이렉트로 변경 - gateway 알림 벨 링크를 tkuser로 변경 - _tkuserBase 헬퍼로 개발/운영 환경 자동 분기 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
560 lines
22 KiB
JavaScript
560 lines
22 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;
|
|
var ntfySubscribed = 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-ntfy-toggle" style="font-size:12px;color:#6B7280;background:none;border:1px solid #D1D5DB;border-radius:4px;padding:2px 8px;cursor:pointer;" title="ntfy 앱 알림">ntfy</button>' +
|
|
'<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.getElementById('notif-ntfy-toggle').addEventListener('click', handleNtfyToggle);
|
|
|
|
// 외부 클릭 시 닫기
|
|
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://tkuser.technicalkorea.net/?tab=notificationRecipients';
|
|
return window.location.protocol + '//' + h + ':30380/?tab=notificationRecipients';
|
|
}
|
|
|
|
/* ========== 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();
|
|
updateNtfyToggleUI();
|
|
}
|
|
|
|
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 (ntfySubscribed) {
|
|
// ntfy 활성화 시 Web Push 비활성화
|
|
btn.textContent = 'Push';
|
|
btn.style.borderColor = '#E5E7EB';
|
|
btn.style.color = '#D1D5DB';
|
|
btn.disabled = true;
|
|
btn.title = 'ntfy 사용 중에는 Push를 사용할 수 없습니다';
|
|
} else if (pushSubscribed) {
|
|
btn.textContent = 'Push 해제';
|
|
btn.style.borderColor = '#EF4444';
|
|
btn.style.color = '#EF4444';
|
|
btn.disabled = false;
|
|
btn.title = 'Push 알림 해제';
|
|
} else {
|
|
btn.textContent = 'Push';
|
|
btn.style.borderColor = '#D1D5DB';
|
|
btn.style.color = '#6B7280';
|
|
btn.disabled = false;
|
|
btn.title = 'Push 알림 설정';
|
|
}
|
|
}
|
|
|
|
/* ========== ntfy ========== */
|
|
function handleNtfyToggle(e) {
|
|
e && e.stopPropagation();
|
|
if (ntfySubscribed) {
|
|
unsubscribeNtfy();
|
|
} else {
|
|
subscribeNtfy();
|
|
}
|
|
}
|
|
|
|
function subscribeNtfy() {
|
|
_authFetch(PUSH_API_BASE + '/ntfy/subscribe', { method: 'POST' })
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (data) {
|
|
if (!data.success) {
|
|
alert('ntfy 구독 등록 실패');
|
|
return;
|
|
}
|
|
ntfySubscribed = true;
|
|
updateNtfyToggleUI();
|
|
updatePushToggleUI();
|
|
showNtfySetupModal(data.data);
|
|
})
|
|
.catch(function (err) {
|
|
console.error('[notification-bell] ntfy subscribe error:', err);
|
|
});
|
|
}
|
|
|
|
function unsubscribeNtfy() {
|
|
_authFetch(PUSH_API_BASE + '/ntfy/unsubscribe', { method: 'DELETE' })
|
|
.then(function (r) { return r.json(); })
|
|
.then(function () {
|
|
ntfySubscribed = false;
|
|
updateNtfyToggleUI();
|
|
updatePushToggleUI();
|
|
checkPushStatus();
|
|
})
|
|
.catch(function (err) {
|
|
console.error('[notification-bell] ntfy unsubscribe error:', err);
|
|
});
|
|
}
|
|
|
|
function checkNtfyStatus() {
|
|
_authFetch(PUSH_API_BASE + '/ntfy/status')
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (data) {
|
|
if (data.success && data.data.subscribed) {
|
|
ntfySubscribed = true;
|
|
updateNtfyToggleUI();
|
|
updatePushToggleUI();
|
|
}
|
|
})
|
|
.catch(function () {});
|
|
}
|
|
|
|
function updateNtfyToggleUI() {
|
|
var btn = document.getElementById('notif-ntfy-toggle');
|
|
if (!btn) return;
|
|
if (ntfySubscribed) {
|
|
btn.textContent = 'ntfy 해제';
|
|
btn.style.borderColor = '#EF4444';
|
|
btn.style.color = '#EF4444';
|
|
} else {
|
|
btn.textContent = 'ntfy';
|
|
btn.style.borderColor = '#D1D5DB';
|
|
btn.style.color = '#6B7280';
|
|
}
|
|
}
|
|
|
|
function showNtfySetupModal(info) {
|
|
// 기존 모달 제거
|
|
var existing = document.getElementById('ntfy-setup-modal');
|
|
if (existing) existing.remove();
|
|
|
|
var overlay = document.createElement('div');
|
|
overlay.id = 'ntfy-setup-modal';
|
|
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.5);z-index:10000;display:flex;align-items:center;justify-content:center;';
|
|
|
|
var modal = document.createElement('div');
|
|
modal.style.cssText = 'background:#fff;border-radius:12px;padding:24px;max-width:380px;width:90%;box-shadow:0 20px 40px rgba(0,0,0,.2);';
|
|
modal.innerHTML =
|
|
'<h3 style="margin:0 0 16px;font-size:16px;color:#111827;">ntfy 앱 설정 안내</h3>' +
|
|
'<div style="font-size:13px;color:#374151;line-height:1.6;">' +
|
|
'<p style="margin:0 0 12px;"><strong>1.</strong> ntfy 앱 설치<br>' +
|
|
'<span style="color:#6B7280;">Android: Play Store / iOS: App Store</span></p>' +
|
|
'<p style="margin:0 0 12px;"><strong>2.</strong> 앱에서 서버 추가<br>' +
|
|
'<code style="background:#F3F4F6;padding:2px 6px;border-radius:3px;font-size:12px;">' + _escHtml(info.serverUrl) + '</code></p>' +
|
|
'<p style="margin:0 0 12px;"><strong>3.</strong> 계정 로그인<br>' +
|
|
'아이디: <code style="background:#F3F4F6;padding:2px 6px;border-radius:3px;font-size:12px;">' + _escHtml(info.username) + '</code><br>' +
|
|
'비밀번호: <code style="background:#F3F4F6;padding:2px 6px;border-radius:3px;font-size:12px;">' + _escHtml(info.password) + '</code></p>' +
|
|
'<p style="margin:0;"><strong>4.</strong> 토픽 구독<br>' +
|
|
'<code style="background:#F3F4F6;padding:2px 6px;border-radius:3px;font-size:12px;">' + _escHtml(info.topic) + '</code></p>' +
|
|
'</div>' +
|
|
'<button id="ntfy-modal-close" style="margin-top:16px;width:100%;padding:10px;background:#3B82F6;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer;">확인</button>';
|
|
|
|
overlay.appendChild(modal);
|
|
document.body.appendChild(overlay);
|
|
|
|
document.getElementById('ntfy-modal-close').addEventListener('click', function () {
|
|
overlay.remove();
|
|
});
|
|
overlay.addEventListener('click', function (e) {
|
|
if (e.target === overlay) overlay.remove();
|
|
});
|
|
}
|
|
|
|
/* ========== 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, '&').replace(/"/g, '"').replace(/'/g, ''').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
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();
|
|
checkNtfyStatus();
|
|
checkPushStatus();
|
|
listenForPushMessages();
|
|
}
|
|
|
|
// DOM ready 또는 즉시 실행
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
})();
|