feat: 안전 코드 tksafety 이관 + 사용자 관리 정리 + UI Tailwind 전환

Phase 1: tksafety에 출입신청/체크리스트 API·웹 추가, tkfb 안전 코드 삭제
Phase 2: 사용자 관리 페이지 삭제, API 축소, 알림 수신자 tkuser 이관
Phase 3: tkuser 권한 페이지 정의 업데이트
Phase 4: 전체 34개 페이지 Tailwind CSS + tkfb-core.js 전환,
         미사용 CSS 20개·인프라 JS 10개·템플릿·컴포넌트 삭제

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-03-13 10:46:22 +09:00
parent 8373fe9e75
commit 9fda89a374
133 changed files with 5255 additions and 26181 deletions

View File

@@ -0,0 +1,89 @@
// controllers/notificationRecipientController.js
const notificationRecipientModel = require('../models/notificationRecipientModel');
const notificationRecipientController = {
// 알림 유형 목록
getTypes: async (req, res) => {
try {
const types = notificationRecipientModel.getTypes();
res.json({ success: true, data: types });
} catch (error) {
console.error('알림 유형 조회 오류:', error);
res.status(500).json({ success: false, error: '알림 유형 조회 실패' });
}
},
// 전체 수신자 목록 (유형별 그룹화)
getAll: async (req, res) => {
try {
const recipients = await notificationRecipientModel.getAll();
res.json({ success: true, data: recipients });
} catch (error) {
console.error(' 수신자 목록 조회 오류:', error.message);
console.error(' 스택:', error.stack);
res.status(500).json({ success: false, error: '수신자 목록 조회 실패', detail: error.message });
}
},
// 유형별 수신자 조회
getByType: async (req, res) => {
try {
const { type } = req.params;
const recipients = await notificationRecipientModel.getByType(type);
res.json({ success: true, data: recipients });
} catch (error) {
console.error('수신자 조회 오류:', error);
res.status(500).json({ success: false, error: '수신자 조회 실패' });
}
},
// 수신자 추가
add: async (req, res) => {
try {
const { notification_type, user_id } = req.body;
if (!notification_type || !user_id) {
return res.status(400).json({ success: false, error: '알림 유형과 사용자 ID가 필요합니다.' });
}
await notificationRecipientModel.add(notification_type, user_id, req.user?.user_id);
res.json({ success: true, message: '수신자가 추가되었습니다.' });
} catch (error) {
console.error('수신자 추가 오류:', error);
res.status(500).json({ success: false, error: '수신자 추가 실패' });
}
},
// 수신자 제거
remove: async (req, res) => {
try {
const { type, userId } = req.params;
await notificationRecipientModel.remove(type, userId);
res.json({ success: true, message: '수신자가 제거되었습니다.' });
} catch (error) {
console.error('수신자 제거 오류:', error);
res.status(500).json({ success: false, error: '수신자 제거 실패' });
}
},
// 유형별 수신자 일괄 설정
setRecipients: async (req, res) => {
try {
const { type } = req.params;
const { user_ids } = req.body;
if (!Array.isArray(user_ids)) {
return res.status(400).json({ success: false, error: 'user_ids 배열이 필요합니다.' });
}
await notificationRecipientModel.setRecipients(type, user_ids, req.user?.user_id);
res.json({ success: true, message: '수신자가 설정되었습니다.' });
} catch (error) {
console.error('수신자 설정 오류:', error);
res.status(500).json({ success: false, error: '수신자 설정 실패' });
}
}
};
module.exports = notificationRecipientController;

View File

@@ -18,6 +18,7 @@ const equipmentRoutes = require('./routes/equipmentRoutes');
const taskRoutes = require('./routes/taskRoutes');
const vacationRoutes = require('./routes/vacationRoutes');
const partnerRoutes = require('./routes/partnerRoutes');
const notificationRecipientRoutes = require('./routes/notificationRecipientRoutes');
const app = express();
const PORT = process.env.PORT || 3000;
@@ -58,6 +59,7 @@ app.use('/api/equipments', equipmentRoutes);
app.use('/api/tasks', taskRoutes);
app.use('/api/vacations', vacationRoutes);
app.use('/api/partners', partnerRoutes);
app.use('/api/notification-recipients', notificationRecipientRoutes);
// 404
app.use((req, res) => {

View File

@@ -0,0 +1,144 @@
// models/notificationRecipientModel.js
const { getPool } = require('./userModel');
const NOTIFICATION_TYPES = {
repair: '설비 수리',
safety: '안전 신고',
nonconformity: '부적합 신고',
equipment: '설비 관련',
maintenance: '정기점검',
system: '시스템'
};
const notificationRecipientModel = {
// 알림 유형 목록 가져오기
getTypes() {
return NOTIFICATION_TYPES;
},
// 유형별 수신자 목록 조회
async getByType(notificationType) {
const db = getPool();
const [rows] = await db.query(
`SELECT nr.*, u.username, u.name as user_name
FROM notification_recipients nr
JOIN sso_users u ON nr.user_id = u.user_id
WHERE nr.notification_type = ? AND nr.is_active = 1
ORDER BY u.name`,
[notificationType]
);
return rows;
},
// 전체 수신자 목록 조회 (유형별 그룹화)
async getAll() {
const db = getPool();
const [rows] = await db.query(
`SELECT nr.*, u.username, u.name as user_name
FROM notification_recipients nr
JOIN sso_users u ON nr.user_id = u.user_id
WHERE nr.is_active = 1
ORDER BY nr.notification_type, u.name`
);
// 유형별로 그룹화
const grouped = {};
for (const type in NOTIFICATION_TYPES) {
grouped[type] = {
label: NOTIFICATION_TYPES[type],
recipients: []
};
}
rows.forEach(row => {
if (grouped[row.notification_type]) {
grouped[row.notification_type].recipients.push(row);
}
});
return grouped;
},
// 수신자 추가
async add(notificationType, userId, createdBy = null) {
const db = getPool();
const [result] = await db.query(
`INSERT INTO notification_recipients (notification_type, user_id, created_by)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE is_active = 1`,
[notificationType, userId, createdBy]
);
return result.insertId || result.affectedRows > 0;
},
// 수신자 제거 (soft delete)
async remove(notificationType, userId) {
const db = getPool();
const [result] = await db.query(
`UPDATE notification_recipients SET is_active = 0
WHERE notification_type = ? AND user_id = ?`,
[notificationType, userId]
);
return result.affectedRows > 0;
},
// 수신자 완전 삭제
async delete(notificationType, userId) {
const db = getPool();
const [result] = await db.query(
`DELETE FROM notification_recipients
WHERE notification_type = ? AND user_id = ?`,
[notificationType, userId]
);
return result.affectedRows > 0;
},
// 유형별 수신자 user_id 목록만 가져오기 (알림 생성용)
async getRecipientIds(notificationType) {
const db = getPool();
const [rows] = await db.query(
`SELECT user_id FROM notification_recipients
WHERE notification_type = ? AND is_active = 1`,
[notificationType]
);
return rows.map(r => r.user_id);
},
// 사용자가 특정 유형의 수신자인지 확인
async isRecipient(notificationType, userId) {
const db = getPool();
const [[row]] = await db.query(
`SELECT 1 FROM notification_recipients
WHERE notification_type = ? AND user_id = ? AND is_active = 1`,
[notificationType, userId]
);
return !!row;
},
// 유형별 수신자 일괄 설정
async setRecipients(notificationType, userIds, createdBy = null) {
const db = getPool();
// 기존 수신자 비활성화
await db.query(
`UPDATE notification_recipients SET is_active = 0
WHERE notification_type = ?`,
[notificationType]
);
// 새 수신자 추가
if (userIds && userIds.length > 0) {
const values = userIds.map(userId => [notificationType, userId, createdBy]);
await db.query(
`INSERT INTO notification_recipients (notification_type, user_id, created_by)
VALUES ?
ON DUPLICATE KEY UPDATE is_active = 1`,
[values]
);
}
return true;
}
};
module.exports = notificationRecipientModel;

View File

@@ -0,0 +1,28 @@
// routes/notificationRecipientRoutes.js
const express = require('express');
const router = express.Router();
const controller = require('../controllers/notificationRecipientController');
const { requireAuth, requireAdmin } = require('../middleware/auth');
// 모든 라우트에 인증 필요
router.use(requireAuth);
// 알림 유형 목록
router.get('/types', controller.getTypes);
// 전체 수신자 목록 (유형별 그룹화)
router.get('/', controller.getAll);
// 유형별 수신자 조회
router.get('/:type', controller.getByType);
// 수신자 추가 (관리자만)
router.post('/', requireAdmin, controller.add);
// 유형별 수신자 일괄 설정 (관리자만)
router.put('/:type', requireAdmin, controller.setRecipients);
// 수신자 제거 (관리자만)
router.delete('/:type/:userId', requireAdmin, controller.remove);
module.exports = router;

View File

@@ -1503,6 +1503,32 @@
</div>
</div>
<!-- ============ 알림 수신자 탭 ============ -->
<div id="tab-notificationRecipients" class="hidden">
<div class="mb-4">
<h2 class="text-base font-semibold text-gray-800"><i class="fas fa-bell text-slate-500 mr-2"></i>알림 수신자 관리</h2>
<p class="text-xs text-gray-400 mt-1">알림 유형별로 수신자를 관리합니다. 각 유형의 이벤트 발생 시 등록된 사용자에게 알림이 전달됩니다.</p>
</div>
<div id="nrContent">
<p class="text-gray-400 text-center py-8 text-sm">탭을 선택하면 데이터를 불러옵니다.</p>
</div>
</div>
<!-- 수신자 추가 모달 -->
<div id="nrAddModal" class="hidden fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center p-4" onclick="if(event.target===this)closeNrAddModal()">
<div class="bg-white rounded-xl shadow-xl max-w-md w-full p-6">
<div class="flex justify-between items-center mb-4">
<h3 id="nrAddModalTitle" class="text-lg font-semibold">수신자 추가</h3>
<button onclick="closeNrAddModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
</div>
<div id="nrAddUserList" class="max-h-[50vh] overflow-y-auto space-y-1 mb-4"></div>
<div class="flex justify-end gap-2">
<button type="button" onclick="closeNrAddModal()" class="px-4 py-2 border rounded-lg text-sm hover:bg-gray-50">취소</button>
<button type="button" onclick="submitNrAdd()" class="px-4 py-2 bg-slate-700 text-white rounded-lg text-sm hover:bg-slate-800">추가</button>
</div>
</div>
</div>
<!-- 협력업체 등록 모달 -->
<div id="addPartnerModalTkuser" class="hidden fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center p-4" onclick="if(event.target===this)closeAddPartnerTkuser()">
<div class="bg-white rounded-xl shadow-xl max-w-lg w-full p-6">
@@ -1727,6 +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>
<!-- Boot -->
<script>init();</script>
</body>

View File

@@ -0,0 +1,139 @@
/* ===== 알림 수신자 관리 ===== */
let nrLoaded = false;
let nrData = {}; // { type: { label, recipients: [...] } }
let nrTypes = {}; // { type: label }
let nrAllUsers = []; // 사용자 목록 (수신자 추가용)
async function loadNotificationRecipientsTab() {
if (nrLoaded) return;
try {
const [typesRes, allRes, usersRes] = await Promise.all([
api('/notification-recipients/types'),
api('/notification-recipients'),
api('/users?status=active')
]);
nrTypes = typesRes.data || {};
nrData = allRes.data || {};
nrAllUsers = (usersRes.data || usersRes || []).filter(u => u.status !== 'inactive');
nrLoaded = true;
renderNrTab();
} catch (e) {
document.getElementById('nrContent').innerHTML =
`<p class="text-red-500 text-center py-8">데이터 로드 실패: ${escapeHtml(e.message)}</p>`;
}
}
function renderNrTab() {
const container = document.getElementById('nrContent');
if (!Object.keys(nrTypes).length) {
container.innerHTML = '<p class="text-gray-400 text-center py-8">등록된 알림 유형이 없습니다.</p>';
return;
}
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>`;
}
container.innerHTML = html;
}
function nrTypeIcon(type) {
const icons = {
repair: 'fa-wrench',
safety: 'fa-shield-alt',
nonconformity: 'fa-exclamation-triangle',
equipment: 'fa-cogs',
maintenance: 'fa-calendar-check',
system: 'fa-server'
};
return icons[type] || 'fa-bell';
}
/* ===== 수신자 추가 모달 ===== */
let nrAddType = '';
function openNrAddModal(type) {
nrAddType = type;
const label = nrTypes[type] || type;
document.getElementById('nrAddModalTitle').textContent = `${label} 수신자 추가`;
const currentIds = (nrData[type]?.recipients || []).map(r => r.user_id);
const available = nrAllUsers.filter(u => !currentIds.includes(u.user_id));
const listEl = document.getElementById('nrAddUserList');
if (available.length === 0) {
listEl.innerHTML = '<p class="text-gray-400 text-sm text-center py-4">추가 가능한 사용자가 없습니다.</p>';
} else {
listEl.innerHTML = available.map(u => `
<label class="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-gray-50 cursor-pointer">
<input type="checkbox" value="${u.user_id}" class="nrAddCheck rounded">
<span class="text-sm">${escapeHtml(u.name || u.username)}</span>
<span class="text-xs text-gray-400">${escapeHtml(u.username)}</span>
</label>
`).join('');
}
document.getElementById('nrAddModal').classList.remove('hidden');
}
function closeNrAddModal() {
document.getElementById('nrAddModal').classList.add('hidden');
}
async function submitNrAdd() {
const checked = [...document.querySelectorAll('.nrAddCheck:checked')].map(c => Number(c.value));
if (checked.length === 0) { showToast('사용자를 선택해주세요.', 'error'); return; }
try {
for (const userId of checked) {
await api('/notification-recipients', {
method: 'POST',
body: JSON.stringify({ notification_type: nrAddType, user_id: userId })
});
}
showToast(`${checked.length}명 수신자 추가 완료`);
closeNrAddModal();
nrLoaded = false;
await loadNotificationRecipientsTab();
} catch (e) {
showToast('수신자 추가 실패: ' + e.message, 'error');
}
}
async function removeNrRecipient(type, userId) {
if (!confirm('이 수신자를 제거하시겠습니까?')) return;
try {
await api(`/notification-recipients/${type}/${userId}`, { method: 'DELETE' });
showToast('수신자 제거 완료');
nrLoaded = false;
await loadNotificationRecipientsTab();
} catch (e) {
showToast('수신자 제거 실패: ' + e.message, 'error');
}
}

View File

@@ -29,4 +29,5 @@ function switchTab(name) {
if (name === 'issueTypes' && !issueTypesLoaded) loadIssueTypes();
if (name === 'permissions' && !permissionsTabLoaded) loadPermissionsTab();
if (name === 'partners' && !partnersLoaded) loadPartnersTab();
if (name === 'notificationRecipients' && !nrLoaded) loadNotificationRecipientsTab();
}