Files
tk-factory-services/system1-factory/web/pages/admin/notifications.html
Hyungi Ahn 4388628788 refactor: TBM/작업보고 코드 통합 및 API 쿼리 버그 수정
- 공통 유틸리티 추출 (common/utils.js, common/base-state.js)
- TBM 모바일 인라인 JS/CSS 외부 파일로 분리 (tbm-mobile.js, tbm-mobile.css)
- 미사용 코드 삭제 (index.js, work-report-*.js 등 5개 파일)
- TBM/작업보고 state.js, utils.js를 공통 모듈 기반으로 전환
- 작업보고서 SSO 인증 호환 수정 (token/user 함수)
- tbmModel.js: incomplete-reports 쿼리에서 users→sso_users 조인 수정, leader_name 조인 추가
- docker-compose.yml: system1-web 볼륨 마운트 추가
- 모바일 인계(handover) 기능 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 07:51:24 +09:00

555 lines
17 KiB
HTML

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>알림 관리 | (주)테크니컬코리아</title>
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js?v=2"></script>
<script src="/js/app-init.js?v=9" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<style>
.notification-page-container {
max-width: 1000px;
margin: 0 auto;
padding: 1.5rem;
}
.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-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>
<div id="navbar-placeholder"></div>
<div id="sidebar-placeholder"></div>
<main class="main-content">
<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>
</main>
<script src="/js/load-sidebar.js"></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: '📢',
equipment: '🔩',
maintenance: '🛠️'
};
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>
</body>
</html>