feat(notifications): 알림 유형 개선 - 카테고리 그룹화 + 구매팀 세분화
- equipment/maintenance 삭제, partner_work/day_labor 신규 추가 - 알림 수신자 관리 UI: 카테고리별 그룹 렌더링 (생산/안전/구매/시스템) - tkpurchase 컨트롤러 알림 타입 변경 - notification-bell 라벨 및 notifications.html 아이콘 업데이트 - 전 서비스 cache busting 갱신 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -365,7 +365,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _typeLabel(type) {
|
function _typeLabel(type) {
|
||||||
var map = { system: '시스템', repair: '수리', safety: '안전', maintenance: '구매' };
|
var map = { system: '시스템', repair: '설비수리', safety: '안전', nonconformity: '부적합', partner_work: '협력업체', day_labor: '일용공' };
|
||||||
return map[type] || type || '알림';
|
return map[type] || type || '알림';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -187,6 +187,18 @@
|
|||||||
background: var(--primary-100);
|
background: var(--primary-100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notification-icon.nonconformity {
|
||||||
|
background: #FEE2E2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-icon.partner_work {
|
||||||
|
background: #DBEAFE;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-icon.day_labor {
|
||||||
|
background: #E0E7FF;
|
||||||
|
}
|
||||||
|
|
||||||
.notification-content {
|
.notification-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -456,8 +468,9 @@
|
|||||||
repair: '🔧',
|
repair: '🔧',
|
||||||
safety: '⚠️',
|
safety: '⚠️',
|
||||||
system: '📢',
|
system: '📢',
|
||||||
equipment: '🔩',
|
nonconformity: '❗',
|
||||||
maintenance: '🛠️'
|
partner_work: '🏗️',
|
||||||
|
day_labor: '👷'
|
||||||
};
|
};
|
||||||
return icons[type] || '🔔';
|
return icons[type] || '🔔';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -288,6 +288,6 @@ async function initAuth() {
|
|||||||
/* ===== 알림 벨 ===== */
|
/* ===== 알림 벨 ===== */
|
||||||
function _loadNotificationBell() {
|
function _loadNotificationBell() {
|
||||||
const s = document.createElement('script');
|
const s = document.createElement('script');
|
||||||
s.src = (location.hostname.includes('technicalkorea.net') ? 'https://tkfb.technicalkorea.net' : location.protocol + '//' + location.hostname + ':30000') + '/shared/notification-bell.js?v=1';
|
s.src = (location.hostname.includes('technicalkorea.net') ? 'https://tkfb.technicalkorea.net' : location.protocol + '//' + location.hostname + ':30000') + '/shared/notification-bell.js?v=2';
|
||||||
document.head.appendChild(s);
|
document.head.appendChild(s);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ if ('serviceWorker' in navigator) {
|
|||||||
window._loadNotificationBell = function() {
|
window._loadNotificationBell = function() {
|
||||||
var h = window.location.hostname;
|
var h = window.location.hostname;
|
||||||
var s = document.createElement('script');
|
var s = document.createElement('script');
|
||||||
s.src = (h.includes('technicalkorea.net') ? 'https://tkfb.technicalkorea.net' : window.location.protocol + '//' + h + ':30000') + '/shared/notification-bell.js?v=1';
|
s.src = (h.includes('technicalkorea.net') ? 'https://tkfb.technicalkorea.net' : window.location.protocol + '//' + h + ':30000') + '/shared/notification-bell.js?v=2';
|
||||||
document.head.appendChild(s);
|
document.head.appendChild(s);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -401,7 +401,7 @@ class App {
|
|||||||
_loadNotificationBell() {
|
_loadNotificationBell() {
|
||||||
var h = window.location.hostname;
|
var h = window.location.hostname;
|
||||||
var s = document.createElement('script');
|
var s = document.createElement('script');
|
||||||
s.src = (h.includes('technicalkorea.net') ? 'https://tkfb.technicalkorea.net' : window.location.protocol + '//' + h + ':30000') + '/shared/notification-bell.js?v=1';
|
s.src = (h.includes('technicalkorea.net') ? 'https://tkfb.technicalkorea.net' : window.location.protocol + '//' + h + ':30000') + '/shared/notification-bell.js?v=2';
|
||||||
document.head.appendChild(s);
|
document.head.appendChild(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ async function create(req, res) {
|
|||||||
|
|
||||||
// 알림: 일용공 신청
|
// 알림: 일용공 신청
|
||||||
notify.send({
|
notify.send({
|
||||||
type: 'maintenance',
|
type: 'day_labor',
|
||||||
title: '일용공 신청',
|
title: '일용공 신청',
|
||||||
message: `${work_date} ${worker_count}명 일용공 신청`,
|
message: `${work_date} ${worker_count}명 일용공 신청`,
|
||||||
link_url: '/daylabor.html',
|
link_url: '/daylabor.html',
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ async function create(req, res) {
|
|||||||
|
|
||||||
// 알림: 작업 보고서 제출
|
// 알림: 작업 보고서 제출
|
||||||
notify.send({
|
notify.send({
|
||||||
type: 'maintenance',
|
type: 'partner_work',
|
||||||
title: '작업 보고서 제출',
|
title: '작업 보고서 제출',
|
||||||
message: `${report_date} 작업 보고서가 제출되었습니다.`,
|
message: `${report_date} 작업 보고서가 제출되었습니다.`,
|
||||||
link_url: '/workreport.html',
|
link_url: '/workreport.html',
|
||||||
|
|||||||
@@ -156,6 +156,6 @@ function initAuth() {
|
|||||||
/* ===== 알림 벨 ===== */
|
/* ===== 알림 벨 ===== */
|
||||||
function _loadNotificationBell() {
|
function _loadNotificationBell() {
|
||||||
const s = document.createElement('script');
|
const s = document.createElement('script');
|
||||||
s.src = (location.hostname.includes('technicalkorea.net') ? 'https://tkfb.technicalkorea.net' : location.protocol + '//' + location.hostname + ':30000') + '/shared/notification-bell.js?v=1';
|
s.src = (location.hostname.includes('technicalkorea.net') ? 'https://tkfb.technicalkorea.net' : location.protocol + '//' + location.hostname + ':30000') + '/shared/notification-bell.js?v=2';
|
||||||
document.head.appendChild(s);
|
document.head.appendChild(s);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,6 +147,6 @@ function initAuth() {
|
|||||||
/* ===== 알림 벨 ===== */
|
/* ===== 알림 벨 ===== */
|
||||||
function _loadNotificationBell() {
|
function _loadNotificationBell() {
|
||||||
const s = document.createElement('script');
|
const s = document.createElement('script');
|
||||||
s.src = (location.hostname.includes('technicalkorea.net') ? 'https://tkfb.technicalkorea.net' : location.protocol + '//' + location.hostname + ':30000') + '/shared/notification-bell.js?v=1';
|
s.src = (location.hostname.includes('technicalkorea.net') ? 'https://tkfb.technicalkorea.net' : location.protocol + '//' + location.hostname + ':30000') + '/shared/notification-bell.js?v=2';
|
||||||
document.head.appendChild(s);
|
document.head.appendChild(s);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,41 @@
|
|||||||
const { getPool } = require('./userModel');
|
const { getPool } = require('./userModel');
|
||||||
|
|
||||||
const NOTIFICATION_TYPES = {
|
const NOTIFICATION_TYPES = {
|
||||||
repair: '설비 수리',
|
repair: '설비수리',
|
||||||
safety: '안전 신고',
|
safety: '안전신고',
|
||||||
nonconformity: '부적합 신고',
|
nonconformity: '부적합 신고',
|
||||||
equipment: '설비 관련',
|
system: '시스템',
|
||||||
maintenance: '정기점검',
|
partner_work: '협력업체 작업',
|
||||||
system: '시스템'
|
day_labor: '일용공 신청'
|
||||||
|
};
|
||||||
|
|
||||||
|
const NOTIFICATION_CATEGORIES = {
|
||||||
|
production: {
|
||||||
|
label: '생산',
|
||||||
|
icon: 'fa-industry',
|
||||||
|
types: ['repair', 'nonconformity']
|
||||||
|
},
|
||||||
|
safety: {
|
||||||
|
label: '안전',
|
||||||
|
icon: 'fa-shield-alt',
|
||||||
|
types: ['safety']
|
||||||
|
},
|
||||||
|
purchase: {
|
||||||
|
label: '구매',
|
||||||
|
icon: 'fa-shopping-cart',
|
||||||
|
types: ['partner_work', 'day_labor']
|
||||||
|
},
|
||||||
|
system: {
|
||||||
|
label: '시스템',
|
||||||
|
icon: 'fa-server',
|
||||||
|
types: ['system']
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const notificationRecipientModel = {
|
const notificationRecipientModel = {
|
||||||
// 알림 유형 목록 가져오기
|
// 알림 유형 목록 가져오기
|
||||||
getTypes() {
|
getTypes() {
|
||||||
return NOTIFICATION_TYPES;
|
return { types: NOTIFICATION_TYPES, categories: NOTIFICATION_CATEGORIES };
|
||||||
},
|
},
|
||||||
|
|
||||||
// 유형별 수신자 목록 조회
|
// 유형별 수신자 목록 조회
|
||||||
|
|||||||
@@ -1753,7 +1753,7 @@
|
|||||||
<script src="/static/js/tkuser-vacations.js?v=20260224"></script>
|
<script src="/static/js/tkuser-vacations.js?v=20260224"></script>
|
||||||
<script src="/static/js/tkuser-layout-map.js?v=20260305"></script>
|
<script src="/static/js/tkuser-layout-map.js?v=20260305"></script>
|
||||||
<script src="/static/js/tkuser-partners.js?v=20260312"></script>
|
<script src="/static/js/tkuser-partners.js?v=20260312"></script>
|
||||||
<script src="/static/js/tkuser-notificationRecipients.js?v=20260313"></script>
|
<script src="/static/js/tkuser-notificationRecipients.js?v=20260313b"></script>
|
||||||
<!-- Boot -->
|
<!-- Boot -->
|
||||||
<script>init();</script>
|
<script>init();</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -192,6 +192,6 @@ async function init() {
|
|||||||
/* ===== 알림 벨 ===== */
|
/* ===== 알림 벨 ===== */
|
||||||
function _loadNotificationBell() {
|
function _loadNotificationBell() {
|
||||||
const s = document.createElement('script');
|
const s = document.createElement('script');
|
||||||
s.src = (location.hostname.includes('technicalkorea.net') ? 'https://tkfb.technicalkorea.net' : location.protocol + '//' + location.hostname + ':30000') + '/shared/notification-bell.js?v=1';
|
s.src = (location.hostname.includes('technicalkorea.net') ? 'https://tkfb.technicalkorea.net' : location.protocol + '//' + location.hostname + ':30000') + '/shared/notification-bell.js?v=2';
|
||||||
document.head.appendChild(s);
|
document.head.appendChild(s);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
let nrLoaded = false;
|
let nrLoaded = false;
|
||||||
let nrData = {}; // { type: { label, recipients: [...] } }
|
let nrData = {}; // { type: { label, recipients: [...] } }
|
||||||
let nrTypes = {}; // { type: label }
|
let nrTypes = {}; // { type: label }
|
||||||
|
let nrCategories = {}; // { category: { label, icon, types: [...] } }
|
||||||
let nrAllUsers = []; // 사용자 목록 (수신자 추가용)
|
let nrAllUsers = []; // 사용자 목록 (수신자 추가용)
|
||||||
|
|
||||||
async function loadNotificationRecipientsTab() {
|
async function loadNotificationRecipientsTab() {
|
||||||
@@ -12,7 +13,8 @@ async function loadNotificationRecipientsTab() {
|
|||||||
api('/notification-recipients'),
|
api('/notification-recipients'),
|
||||||
api('/users?status=active')
|
api('/users?status=active')
|
||||||
]);
|
]);
|
||||||
nrTypes = typesRes.data || {};
|
nrTypes = typesRes.data?.types || typesRes.data || {};
|
||||||
|
nrCategories = typesRes.data?.categories || {};
|
||||||
nrData = allRes.data || {};
|
nrData = allRes.data || {};
|
||||||
nrAllUsers = (usersRes.data || usersRes || []).filter(u => u.status !== 'inactive');
|
nrAllUsers = (usersRes.data || usersRes || []).filter(u => u.status !== 'inactive');
|
||||||
nrLoaded = true;
|
nrLoaded = true;
|
||||||
@@ -31,45 +33,76 @@ function renderNrTab() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let html = '';
|
let html = '';
|
||||||
for (const [type, label] of Object.entries(nrTypes)) {
|
const categoryKeys = Object.keys(nrCategories);
|
||||||
const recipients = nrData[type]?.recipients || [];
|
|
||||||
html += `
|
if (categoryKeys.length > 0) {
|
||||||
<div class="bg-white rounded-xl shadow-sm p-5 mb-4">
|
// 카테고리별 그룹 렌더링
|
||||||
<div class="flex items-center justify-between mb-3">
|
for (const [catKey, cat] of Object.entries(nrCategories)) {
|
||||||
<h3 class="text-sm font-semibold text-gray-800">
|
html += `
|
||||||
<i class="fas ${nrTypeIcon(type)} text-slate-500 mr-2"></i>${escapeHtml(label)}
|
<div class="mb-6">
|
||||||
<span class="ml-2 text-xs font-normal text-gray-400">${recipients.length}명</span>
|
<h2 class="text-base font-bold text-gray-700 mb-3 flex items-center gap-2">
|
||||||
</h3>
|
<i class="fas ${cat.icon} text-slate-500"></i>${escapeHtml(cat.label)}
|
||||||
<button onclick="openNrAddModal('${type}')" class="px-3 py-1 bg-slate-700 text-white rounded-lg text-xs hover:bg-slate-800">
|
</h2>
|
||||||
<i class="fas fa-plus mr-1"></i>추가
|
<div class="pl-4 space-y-3">`;
|
||||||
</button>
|
|
||||||
</div>
|
for (const type of cat.types) {
|
||||||
<div class="flex flex-wrap gap-2" id="nrList-${type}">
|
const label = nrTypes[type];
|
||||||
${recipients.length === 0
|
if (!label) continue;
|
||||||
? '<span class="text-gray-400 text-sm">수신자 없음</span>'
|
const recipients = nrData[type]?.recipients || [];
|
||||||
: recipients.map(r => `
|
html += renderNrTypeCard(type, label, recipients);
|
||||||
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-gray-100 rounded-full text-sm">
|
}
|
||||||
<span class="w-6 h-6 bg-slate-600 text-white rounded-full flex items-center justify-center text-xs font-bold">${escapeHtml((r.user_name || r.username || '?')[0])}</span>
|
|
||||||
${escapeHtml(r.user_name || r.username)}
|
html += `
|
||||||
<button onclick="removeNrRecipient('${type}', ${r.user_id})" class="text-gray-400 hover:text-red-500 ml-1" title="제거">
|
</div>
|
||||||
<i class="fas fa-times text-xs"></i>
|
</div>`;
|
||||||
</button>
|
}
|
||||||
</span>
|
} else {
|
||||||
`).join('')
|
// 폴백: 카테고리 없이 flat 리스트
|
||||||
}
|
for (const [type, label] of Object.entries(nrTypes)) {
|
||||||
</div>
|
const recipients = nrData[type]?.recipients || [];
|
||||||
</div>`;
|
html += renderNrTypeCard(type, label, recipients);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderNrTypeCard(type, label, recipients) {
|
||||||
|
return `
|
||||||
|
<div class="bg-white rounded-xl shadow-sm p-5 mb-3">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-800">
|
||||||
|
<i class="fas ${nrTypeIcon(type)} text-slate-500 mr-2"></i>${escapeHtml(label)}
|
||||||
|
<span class="ml-2 text-xs font-normal text-gray-400">${recipients.length}명</span>
|
||||||
|
</h3>
|
||||||
|
<button onclick="openNrAddModal('${type}')" class="px-3 py-1 bg-slate-700 text-white rounded-lg text-xs hover:bg-slate-800">
|
||||||
|
<i class="fas fa-plus mr-1"></i>추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2" id="nrList-${type}">
|
||||||
|
${recipients.length === 0
|
||||||
|
? '<span class="text-gray-400 text-sm">수신자 없음</span>'
|
||||||
|
: recipients.map(r => `
|
||||||
|
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-gray-100 rounded-full text-sm">
|
||||||
|
<span class="w-6 h-6 bg-slate-600 text-white rounded-full flex items-center justify-center text-xs font-bold">${escapeHtml((r.user_name || r.username || '?')[0])}</span>
|
||||||
|
${escapeHtml(r.user_name || r.username)}
|
||||||
|
<button onclick="removeNrRecipient('${type}', ${r.user_id})" class="text-gray-400 hover:text-red-500 ml-1" title="제거">
|
||||||
|
<i class="fas fa-times text-xs"></i>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
`).join('')
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
function nrTypeIcon(type) {
|
function nrTypeIcon(type) {
|
||||||
const icons = {
|
const icons = {
|
||||||
repair: 'fa-wrench',
|
repair: 'fa-wrench',
|
||||||
safety: 'fa-shield-alt',
|
safety: 'fa-shield-alt',
|
||||||
nonconformity: 'fa-exclamation-triangle',
|
nonconformity: 'fa-exclamation-triangle',
|
||||||
equipment: 'fa-cogs',
|
partner_work: 'fa-hard-hat',
|
||||||
maintenance: 'fa-calendar-check',
|
day_labor: 'fa-user-clock',
|
||||||
system: 'fa-server'
|
system: 'fa-server'
|
||||||
};
|
};
|
||||||
return icons[type] || 'fa-bell';
|
return icons[type] || 'fa-bell';
|
||||||
|
|||||||
Reference in New Issue
Block a user