Phase 1 CRITICAL XSS: - marked.parse() → DOMPurify.sanitize() (system3 ai-assistant, issues-management) - toast innerHTML에 escapeHtml 적용 (system1 api-base, system3 common-header) - onclick 핸들러 → data 속성 + addEventListener (system2 issue-detail) Phase 2 HIGH 인가: - getUserBalance 본인확인 추가 (tksupport vacationController) Phase 3 HIGH 토큰+CSP: - localStorage 토큰 저장 제거 — 쿠키 전용 (7개 서비스) - unsafe-eval CSP 제거 (system1 security.js) Phase 4 MEDIUM: - nginx 보안 헤더 추가 (8개 서비스) - 500 에러 메시지 마스킹 (5개 API) - path traversal 방지 (system3 file_service.py) - cookie fallback 데드코드 제거 (4개 auth.js) - /login/form rate limiting 추가 (sso-auth) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
585 lines
19 KiB
HTML
585 lines
19 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>알림 관리 - TK 공장관리</title>
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
|
<link rel="stylesheet" href="/static/css/tkfb.css">
|
|
<style>
|
|
.notification-page-container {
|
|
max-width: 1000px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.notification-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.notification-header h1 {
|
|
font-size: 1.5rem;
|
|
font-weight: 700;
|
|
color: var(--text-primary);
|
|
margin: 0;
|
|
}
|
|
|
|
.header-actions {
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.btn-mark-all {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.5rem 1rem;
|
|
background: var(--primary-500);
|
|
color: white;
|
|
border: none;
|
|
border-radius: var(--radius-md);
|
|
cursor: pointer;
|
|
font-weight: 500;
|
|
transition: var(--transition-fast);
|
|
}
|
|
|
|
.btn-mark-all:hover {
|
|
background: var(--primary-600);
|
|
}
|
|
|
|
.notification-stats {
|
|
display: flex;
|
|
gap: 1rem;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.stat-card {
|
|
flex: 1;
|
|
background: white;
|
|
padding: 1rem 1.5rem;
|
|
border-radius: var(--radius-lg);
|
|
box-shadow: var(--shadow-sm);
|
|
border: 1px solid var(--border-light);
|
|
}
|
|
|
|
.stat-value {
|
|
font-size: 1.75rem;
|
|
font-weight: 700;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.stat-label {
|
|
font-size: 0.875rem;
|
|
color: var(--text-tertiary);
|
|
margin-top: 0.25rem;
|
|
}
|
|
|
|
.stat-card.unread .stat-value {
|
|
color: var(--primary-500);
|
|
}
|
|
|
|
.notification-list-container {
|
|
background: white;
|
|
border-radius: var(--radius-lg);
|
|
box-shadow: var(--shadow-sm);
|
|
border: 1px solid var(--border-light);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.notification-list-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 1rem 1.5rem;
|
|
border-bottom: 1px solid var(--border-light);
|
|
background: var(--bg-secondary);
|
|
}
|
|
|
|
.notification-list-header h2 {
|
|
font-size: 1rem;
|
|
font-weight: 600;
|
|
margin: 0;
|
|
}
|
|
|
|
.filter-tabs {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.filter-tab {
|
|
padding: 0.375rem 0.75rem;
|
|
background: transparent;
|
|
border: 1px solid var(--border-light);
|
|
border-radius: var(--radius-md);
|
|
cursor: pointer;
|
|
font-size: 0.875rem;
|
|
color: var(--text-secondary);
|
|
transition: var(--transition-fast);
|
|
}
|
|
|
|
.filter-tab:hover {
|
|
background: var(--bg-tertiary);
|
|
}
|
|
|
|
.filter-tab.active {
|
|
background: var(--primary-500);
|
|
color: white;
|
|
border-color: var(--primary-500);
|
|
}
|
|
|
|
.notification-list {
|
|
max-height: 600px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.notification-item {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 1rem;
|
|
padding: 1rem 1.5rem;
|
|
border-bottom: 1px solid var(--border-light);
|
|
cursor: pointer;
|
|
transition: var(--transition-fast);
|
|
position: relative;
|
|
}
|
|
|
|
.notification-item:hover {
|
|
background: var(--bg-secondary);
|
|
}
|
|
|
|
.notification-item.unread {
|
|
background: rgba(14, 165, 233, 0.05);
|
|
}
|
|
|
|
.notification-item.unread::before {
|
|
content: '';
|
|
position: absolute;
|
|
left: 0;
|
|
top: 0;
|
|
bottom: 0;
|
|
width: 4px;
|
|
background: var(--primary-500);
|
|
}
|
|
|
|
.notification-icon {
|
|
width: 44px;
|
|
height: 44px;
|
|
border-radius: var(--radius-full);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 1.25rem;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.notification-icon.repair {
|
|
background: var(--warning-100);
|
|
}
|
|
|
|
.notification-icon.safety {
|
|
background: var(--error-100);
|
|
}
|
|
|
|
.notification-icon.system {
|
|
background: var(--primary-100);
|
|
}
|
|
|
|
.notification-icon.nonconformity {
|
|
background: #FEE2E2;
|
|
}
|
|
|
|
.notification-icon.partner_work {
|
|
background: #DBEAFE;
|
|
}
|
|
|
|
.notification-icon.day_labor {
|
|
background: #E0E7FF;
|
|
}
|
|
|
|
.notification-content {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.notification-title {
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
|
|
.notification-message {
|
|
font-size: 0.875rem;
|
|
color: var(--text-secondary);
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.notification-meta {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
font-size: 0.75rem;
|
|
color: var(--text-tertiary);
|
|
}
|
|
|
|
.notification-actions {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.btn-action {
|
|
padding: 0.375rem 0.75rem;
|
|
background: transparent;
|
|
border: 1px solid var(--border-light);
|
|
border-radius: var(--radius-md);
|
|
cursor: pointer;
|
|
font-size: 0.75rem;
|
|
color: var(--text-secondary);
|
|
transition: var(--transition-fast);
|
|
}
|
|
|
|
.btn-action:hover {
|
|
background: var(--bg-tertiary);
|
|
}
|
|
|
|
.btn-action.danger:hover {
|
|
background: var(--error-50);
|
|
color: var(--error-600);
|
|
border-color: var(--error-200);
|
|
}
|
|
|
|
.empty-state {
|
|
padding: 3rem;
|
|
text-align: center;
|
|
color: var(--text-tertiary);
|
|
}
|
|
|
|
.empty-state-icon {
|
|
font-size: 3rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.pagination {
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 1rem;
|
|
border-top: 1px solid var(--border-light);
|
|
}
|
|
|
|
.pagination-btn {
|
|
padding: 0.5rem 1rem;
|
|
background: white;
|
|
border: 1px solid var(--border-light);
|
|
border-radius: var(--radius-md);
|
|
cursor: pointer;
|
|
transition: var(--transition-fast);
|
|
}
|
|
|
|
.pagination-btn:hover:not(:disabled) {
|
|
background: var(--bg-secondary);
|
|
}
|
|
|
|
.pagination-btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.pagination-info {
|
|
font-size: 0.875rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.notification-stats {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.notification-header {
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.notification-item {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.notification-actions {
|
|
width: 100%;
|
|
justify-content: flex-end;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body class="bg-gray-50">
|
|
<header class="bg-orange-700 text-white sticky top-0 z-50">
|
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
<div class="flex justify-between items-center h-14">
|
|
<div class="flex items-center gap-3">
|
|
<button id="mobileMenuBtn" class="lg:hidden text-orange-200 hover:text-white"><i class="fas fa-bars text-xl"></i></button>
|
|
<i class="fas fa-industry text-xl text-orange-200"></i>
|
|
<h1 class="text-lg font-semibold">TK 공장관리</h1>
|
|
</div>
|
|
<div class="flex items-center gap-4">
|
|
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
|
|
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
|
|
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃"><i class="fas fa-sign-out-alt"></i></button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
|
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
|
|
<div class="flex gap-6">
|
|
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
|
|
<div class="flex-1 min-w-0">
|
|
<div class="notification-page-container">
|
|
<div class="notification-header">
|
|
<h1>알림 관리</h1>
|
|
<div class="header-actions">
|
|
<button class="btn-mark-all" onclick="markAllAsRead()">
|
|
<span>모두 읽음 처리</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="notification-stats">
|
|
<div class="stat-card unread">
|
|
<div class="stat-value" id="unreadCount">0</div>
|
|
<div class="stat-label">읽지 않은 알림</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value" id="totalCount">0</div>
|
|
<div class="stat-label">전체 알림</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="notification-list-container">
|
|
<div class="notification-list-header">
|
|
<h2>알림 목록</h2>
|
|
<div class="filter-tabs">
|
|
<button class="filter-tab active" data-filter="all" onclick="setFilter('all')">전체</button>
|
|
<button class="filter-tab" data-filter="unread" onclick="setFilter('unread')">읽지 않음</button>
|
|
<button class="filter-tab" data-filter="repair" onclick="setFilter('repair')">수리</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="notification-list" id="notificationList">
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">🔔</div>
|
|
<p>알림이 없습니다.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="pagination" id="pagination" style="display: none;">
|
|
<button class="pagination-btn" id="prevBtn" onclick="changePage(-1)">이전</button>
|
|
<span class="pagination-info" id="pageInfo">1 / 1</span>
|
|
<button class="pagination-btn" id="nextBtn" onclick="changePage(1)">다음</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="/static/js/tkfb-core.js?v=20260313"></script>
|
|
<script src="/js/api-base.js?v=20260313"></script>
|
|
<script>
|
|
let currentPage = 1;
|
|
let totalPages = 1;
|
|
let currentFilter = 'all';
|
|
let allNotifications = [];
|
|
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
loadNotifications();
|
|
});
|
|
|
|
async function loadNotifications() {
|
|
try {
|
|
const response = await window.apiCall(`/notifications?page=${currentPage}&limit=20`);
|
|
if (response.success) {
|
|
allNotifications = response.data || [];
|
|
totalPages = response.pagination?.totalPages || 1;
|
|
|
|
updateStats();
|
|
renderNotifications();
|
|
updatePagination();
|
|
}
|
|
} catch (error) {
|
|
console.error('알림 로드 오류:', error);
|
|
}
|
|
}
|
|
|
|
function updateStats() {
|
|
const unreadCount = allNotifications.filter(n => !n.is_read).length;
|
|
document.getElementById('unreadCount').textContent = unreadCount;
|
|
document.getElementById('totalCount').textContent = allNotifications.length;
|
|
}
|
|
|
|
function renderNotifications() {
|
|
const list = document.getElementById('notificationList');
|
|
|
|
// 필터 적용
|
|
let filtered = allNotifications;
|
|
if (currentFilter === 'unread') {
|
|
filtered = allNotifications.filter(n => !n.is_read);
|
|
} else if (currentFilter === 'repair') {
|
|
filtered = allNotifications.filter(n => n.type === 'repair');
|
|
}
|
|
|
|
if (filtered.length === 0) {
|
|
list.innerHTML = `
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">🔔</div>
|
|
<p>알림이 없습니다.</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = filtered.map(n => `
|
|
<div class="notification-item ${n.is_read ? '' : 'unread'}" data-id="${n.notification_id}">
|
|
<div class="notification-icon ${n.type || 'system'}">
|
|
${getNotificationIcon(n.type)}
|
|
</div>
|
|
<div class="notification-content" onclick="handleNotificationClick(${n.notification_id}, '${n.link_url || ''}')">
|
|
<div class="notification-title">${escapeHtml(n.title)}</div>
|
|
<div class="notification-message">${escapeHtml(n.message || '')}</div>
|
|
<div class="notification-meta">
|
|
<span>${formatDateTime(n.created_at)}</span>
|
|
<span>${n.is_read ? '읽음' : '읽지 않음'}</span>
|
|
</div>
|
|
</div>
|
|
<div class="notification-actions">
|
|
${!n.is_read ? `<button class="btn-action" onclick="markAsRead(${n.notification_id})">읽음</button>` : ''}
|
|
<button class="btn-action danger" onclick="deleteNotification(${n.notification_id})">삭제</button>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
function getNotificationIcon(type) {
|
|
const icons = {
|
|
repair: '🔧',
|
|
safety: '⚠️',
|
|
system: '📢',
|
|
nonconformity: '❗',
|
|
partner_work: '🏗️',
|
|
day_labor: '👷'
|
|
};
|
|
return icons[type] || '🔔';
|
|
}
|
|
|
|
function formatDateTime(dateString) {
|
|
if (!dateString) return '';
|
|
const date = new Date(dateString);
|
|
return date.toLocaleString('ko-KR', {
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
if (!text) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function setFilter(filter) {
|
|
currentFilter = filter;
|
|
document.querySelectorAll('.filter-tab').forEach(tab => {
|
|
tab.classList.toggle('active', tab.dataset.filter === filter);
|
|
});
|
|
renderNotifications();
|
|
}
|
|
|
|
async function handleNotificationClick(notificationId, linkUrl) {
|
|
await markAsRead(notificationId);
|
|
if (linkUrl) {
|
|
window.location.href = linkUrl;
|
|
}
|
|
}
|
|
|
|
async function markAsRead(notificationId) {
|
|
try {
|
|
const response = await window.apiCall(`/notifications/${notificationId}/read`, { method: 'POST' });
|
|
if (response.success) {
|
|
const notification = allNotifications.find(n => n.notification_id === notificationId);
|
|
if (notification) {
|
|
notification.is_read = true;
|
|
}
|
|
updateStats();
|
|
renderNotifications();
|
|
}
|
|
} catch (error) {
|
|
console.error('알림 읽음 처리 오류:', error);
|
|
}
|
|
}
|
|
|
|
async function markAllAsRead() {
|
|
try {
|
|
const response = await window.apiCall('/notifications/read-all', { method: 'POST' });
|
|
if (response.success) {
|
|
allNotifications.forEach(n => n.is_read = true);
|
|
updateStats();
|
|
renderNotifications();
|
|
alert('모든 알림을 읽음 처리했습니다.');
|
|
}
|
|
} catch (error) {
|
|
console.error('전체 읽음 처리 오류:', error);
|
|
}
|
|
}
|
|
|
|
async function deleteNotification(notificationId) {
|
|
if (!confirm('이 알림을 삭제하시겠습니까?')) return;
|
|
|
|
try {
|
|
const response = await window.apiCall(`/notifications/${notificationId}`, { method: 'DELETE' });
|
|
if (response.success) {
|
|
allNotifications = allNotifications.filter(n => n.notification_id !== notificationId);
|
|
updateStats();
|
|
renderNotifications();
|
|
}
|
|
} catch (error) {
|
|
console.error('알림 삭제 오류:', error);
|
|
}
|
|
}
|
|
|
|
function updatePagination() {
|
|
const pagination = document.getElementById('pagination');
|
|
const pageInfo = document.getElementById('pageInfo');
|
|
const prevBtn = document.getElementById('prevBtn');
|
|
const nextBtn = document.getElementById('nextBtn');
|
|
|
|
if (totalPages <= 1) {
|
|
pagination.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
pagination.style.display = 'flex';
|
|
pageInfo.textContent = `${currentPage} / ${totalPages}`;
|
|
prevBtn.disabled = currentPage <= 1;
|
|
nextBtn.disabled = currentPage >= totalPages;
|
|
}
|
|
|
|
function changePage(delta) {
|
|
const newPage = currentPage + delta;
|
|
if (newPage >= 1 && newPage <= totalPages) {
|
|
currentPage = newPage;
|
|
loadNotifications();
|
|
}
|
|
}
|
|
</script>
|
|
<script>initAuth();</script>
|
|
</body>
|
|
</html>
|