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:
Hyungi Ahn
2026-03-13 15:29:29 +09:00
parent 0a712813e2
commit b1154a8bc7
13 changed files with 118 additions and 49 deletions

View File

@@ -1753,7 +1753,7 @@
<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-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 -->
<script>init();</script>
</body>

View File

@@ -192,6 +192,6 @@ async function init() {
/* ===== 알림 벨 ===== */
function _loadNotificationBell() {
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);
}

View File

@@ -2,6 +2,7 @@
let nrLoaded = false;
let nrData = {}; // { type: { label, recipients: [...] } }
let nrTypes = {}; // { type: label }
let nrCategories = {}; // { category: { label, icon, types: [...] } }
let nrAllUsers = []; // 사용자 목록 (수신자 추가용)
async function loadNotificationRecipientsTab() {
@@ -12,7 +13,8 @@ async function loadNotificationRecipientsTab() {
api('/notification-recipients'),
api('/users?status=active')
]);
nrTypes = typesRes.data || {};
nrTypes = typesRes.data?.types || typesRes.data || {};
nrCategories = typesRes.data?.categories || {};
nrData = allRes.data || {};
nrAllUsers = (usersRes.data || usersRes || []).filter(u => u.status !== 'inactive');
nrLoaded = true;
@@ -31,45 +33,76 @@ function renderNrTab() {
}
let html = '';
for (const [type, label] of Object.entries(nrTypes)) {
const recipients = nrData[type]?.recipients || [];
html += `
<div class="bg-white rounded-xl shadow-sm p-5 mb-4">
<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>`;
const categoryKeys = Object.keys(nrCategories);
if (categoryKeys.length > 0) {
// 카테고리별 그룹 렌더링
for (const [catKey, cat] of Object.entries(nrCategories)) {
html += `
<div class="mb-6">
<h2 class="text-base font-bold text-gray-700 mb-3 flex items-center gap-2">
<i class="fas ${cat.icon} text-slate-500"></i>${escapeHtml(cat.label)}
</h2>
<div class="pl-4 space-y-3">`;
for (const type of cat.types) {
const label = nrTypes[type];
if (!label) continue;
const recipients = nrData[type]?.recipients || [];
html += renderNrTypeCard(type, label, recipients);
}
html += `
</div>
</div>`;
}
} else {
// 폴백: 카테고리 없이 flat 리스트
for (const [type, label] of Object.entries(nrTypes)) {
const recipients = nrData[type]?.recipients || [];
html += renderNrTypeCard(type, label, recipients);
}
}
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) {
const icons = {
repair: 'fa-wrench',
safety: 'fa-shield-alt',
nonconformity: 'fa-exclamation-triangle',
equipment: 'fa-cogs',
maintenance: 'fa-calendar-check',
partner_work: 'fa-hard-hat',
day_labor: 'fa-user-clock',
system: 'fa-server'
};
return icons[type] || 'fa-bell';