feat: 알림 시스템 및 시설설비 관리 기능 구현

- 알림 시스템 구축 (navbar 알림 아이콘, 드롭다운)
- 알림 수신자 설정 기능 (계정관리 페이지)
- 시설설비 관리 페이지 추가 (수리 워크플로우)
- 수리 신청 → 접수 → 처리중 → 완료 상태 관리
- 사이드바 메뉴 구조 개선 (공장 관리 카테고리)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-02-04 15:56:57 +09:00
parent d1aec517a6
commit b8ccde7f17
24 changed files with 3204 additions and 9 deletions

View File

@@ -51,6 +51,8 @@ function setupRoutes(app) {
const workIssueRoutes = require('../routes/workIssueRoutes');
const departmentRoutes = require('../routes/departmentRoutes');
const patrolRoutes = require('../routes/patrolRoutes');
const notificationRoutes = require('../routes/notificationRoutes');
const notificationRecipientRoutes = require('../routes/notificationRecipientRoutes');
// Rate Limiters 설정
const rateLimit = require('express-rate-limit');
@@ -157,6 +159,8 @@ function setupRoutes(app) {
app.use('/api/work-issues', workIssueRoutes); // 문제 신고 시스템
app.use('/api/departments', departmentRoutes); // 부서 관리
app.use('/api/patrol', patrolRoutes); // 일일순회점검 시스템
app.use('/api/notifications', notificationRoutes); // 알림 시스템
app.use('/api/notification-recipients', notificationRecipientRoutes); // 알림 수신자 설정
app.use('/api', uploadBgRoutes);
// Swagger API 문서

View File

@@ -903,6 +903,42 @@ const EquipmentController = {
message: '서버 오류가 발생했습니다.'
});
}
},
// ADD REPAIR CATEGORY - 새 수리 항목 추가
addRepairCategory: (req, res) => {
try {
const { item_name } = req.body;
if (!item_name || !item_name.trim()) {
return res.status(400).json({
success: false,
message: '수리 유형 이름을 입력하세요.'
});
}
EquipmentModel.addRepairCategory(item_name.trim(), (error, result) => {
if (error) {
console.error('수리 항목 추가 오류:', error);
return res.status(500).json({
success: false,
message: '수리 항목 추가 중 오류가 발생했습니다.'
});
}
res.status(201).json({
success: true,
message: result.isNew ? '새 수리 유형이 추가되었습니다.' : '기존 수리 유형을 사용합니다.',
data: result
});
});
} catch (error) {
console.error('수리 항목 추가 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
}
};

View File

@@ -0,0 +1,165 @@
// controllers/notificationController.js
const notificationModel = require('../models/notificationModel');
const notificationController = {
// 읽지 않은 알림 조회
async getUnread(req, res) {
try {
const userId = req.user?.id || null;
const notifications = await notificationModel.getUnread(userId);
res.json({
success: true,
data: notifications
});
} catch (error) {
console.error('읽지 않은 알림 조회 오류:', error);
res.status(500).json({
success: false,
message: '알림 조회 중 오류가 발생했습니다.'
});
}
},
// 전체 알림 조회
async getAll(req, res) {
try {
const userId = req.user?.id || null;
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
const result = await notificationModel.getAll(userId, page, limit);
res.json({
success: true,
data: result.notifications,
pagination: {
total: result.total,
page: result.page,
limit: result.limit,
totalPages: Math.ceil(result.total / result.limit)
}
});
} catch (error) {
console.error('알림 목록 조회 오류:', error);
res.status(500).json({
success: false,
message: '알림 조회 중 오류가 발생했습니다.'
});
}
},
// 읽지 않은 알림 개수
async getUnreadCount(req, res) {
try {
const userId = req.user?.id || null;
const count = await notificationModel.getUnreadCount(userId);
res.json({
success: true,
data: { count }
});
} catch (error) {
console.error('알림 개수 조회 오류:', error);
res.status(500).json({
success: false,
message: '알림 개수 조회 중 오류가 발생했습니다.'
});
}
},
// 알림 읽음 처리
async markAsRead(req, res) {
try {
const { id } = req.params;
const success = await notificationModel.markAsRead(id);
res.json({
success,
message: success ? '알림을 읽음 처리했습니다.' : '알림을 찾을 수 없습니다.'
});
} catch (error) {
console.error('알림 읽음 처리 오류:', error);
res.status(500).json({
success: false,
message: '알림 처리 중 오류가 발생했습니다.'
});
}
},
// 모든 알림 읽음 처리
async markAllAsRead(req, res) {
try {
const userId = req.user?.id || null;
const count = await notificationModel.markAllAsRead(userId);
res.json({
success: true,
message: `${count}개의 알림을 읽음 처리했습니다.`,
data: { count }
});
} catch (error) {
console.error('전체 읽음 처리 오류:', error);
res.status(500).json({
success: false,
message: '알림 처리 중 오류가 발생했습니다.'
});
}
},
// 알림 삭제
async delete(req, res) {
try {
const { id } = req.params;
const success = await notificationModel.delete(id);
res.json({
success,
message: success ? '알림을 삭제했습니다.' : '알림을 찾을 수 없습니다.'
});
} catch (error) {
console.error('알림 삭제 오류:', error);
res.status(500).json({
success: false,
message: '알림 삭제 중 오류가 발생했습니다.'
});
}
},
// 알림 생성 (시스템용)
async create(req, res) {
try {
const { type, title, message, link_url, user_id } = req.body;
if (!title) {
return res.status(400).json({
success: false,
message: '알림 제목은 필수입니다.'
});
}
const notificationId = await notificationModel.create({
user_id,
type,
title,
message,
link_url,
created_by: req.user?.id
});
res.json({
success: true,
message: '알림이 생성되었습니다.',
data: { notification_id: notificationId }
});
} catch (error) {
console.error('알림 생성 오류:', error);
res.status(500).json({
success: false,
message: '알림 생성 중 오류가 발생했습니다.'
});
}
}
};
module.exports = notificationController;

View File

@@ -0,0 +1,91 @@
// 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 {
console.log('🔔 알림 수신자 목록 조회 시작');
const recipients = await notificationRecipientModel.getAll();
console.log('✅ 알림 수신자 목록 조회 완료:', recipients);
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

@@ -0,0 +1,23 @@
-- 알림 수신자 설정 테이블
-- 알림 유형별로 지정된 사용자에게만 알림이 전송됨
CREATE TABLE IF NOT EXISTS notification_recipients (
id INT AUTO_INCREMENT PRIMARY KEY,
notification_type ENUM('repair', 'safety', 'nonconformity', 'equipment', 'maintenance', 'system') NOT NULL COMMENT '알림 유형',
user_id INT NOT NULL COMMENT '수신자 ID',
is_active BOOLEAN DEFAULT TRUE COMMENT '활성 여부',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL COMMENT '등록자',
UNIQUE KEY unique_type_user (notification_type, user_id),
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE,
INDEX idx_nr_type (notification_type),
INDEX idx_nr_active (is_active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='알림 수신자 설정';
-- 알림 유형 설명:
-- repair: 설비 수리 신청
-- safety: 안전 신고
-- nonconformity: 부적합 신고
-- equipment: 설비 관련
-- maintenance: 정기점검
-- system: 시스템 알림

View File

@@ -0,0 +1,46 @@
-- 알림 시스템 테이블 생성
-- 실행: docker exec -i tkfb_db mysql -u hyungi -p'your_password' hyungi < db/migrations/20260205004000_create_notifications.sql
-- ============================================
-- STEP 1: notifications 테이블 생성
-- ============================================
CREATE TABLE IF NOT EXISTS notifications (
notification_id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NULL COMMENT '특정 사용자에게만 표시 (NULL이면 전체)',
type ENUM('repair', 'safety', 'system', 'equipment', 'maintenance') NOT NULL DEFAULT 'system',
title VARCHAR(200) NOT NULL,
message TEXT,
link_url VARCHAR(500) COMMENT '클릭시 이동할 URL',
reference_type VARCHAR(50) COMMENT '연관 테이블 (equipment_repair_requests 등)',
reference_id INT COMMENT '연관 레코드 ID',
is_read BOOLEAN DEFAULT FALSE,
read_at DATETIME NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
INDEX idx_notifications_user (user_id),
INDEX idx_notifications_type (type),
INDEX idx_notifications_is_read (is_read),
INDEX idx_notifications_created (created_at DESC)
);
-- 수리 요청 테이블 수정 (알림 연동을 위해)
-- equipment_repair_requests 테이블이 없으면 생성
CREATE TABLE IF NOT EXISTS equipment_repair_requests (
request_id INT AUTO_INCREMENT PRIMARY KEY,
equipment_id INT UNSIGNED NOT NULL,
repair_type VARCHAR(100) NOT NULL,
description TEXT,
urgency ENUM('low', 'normal', 'high', 'urgent') DEFAULT 'normal',
status ENUM('pending', 'in_progress', 'completed', 'cancelled') DEFAULT 'pending',
requested_by INT,
assigned_to INT NULL,
completed_at DATETIME NULL,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (equipment_id) REFERENCES equipments(equipment_id) ON DELETE CASCADE,
INDEX idx_err_equipment (equipment_id),
INDEX idx_err_status (status),
INDEX idx_err_created (created_at DESC)
);

View File

@@ -1,5 +1,6 @@
// models/equipmentModel.js
const { getDb } = require('../dbPool');
const notificationModel = require('./notificationModel');
const EquipmentModel = {
// CREATE - 설비 생성
@@ -823,6 +824,20 @@ const EquipmentModel = {
['repair_needed', requestData.equipment_id]
);
// 알림 생성
try {
await notificationModel.createRepairNotification({
equipment_id: requestData.equipment_id,
equipment_name: requestData.equipment_name || '설비',
repair_type: requestData.repair_type || '일반 수리',
request_id: result.insertId,
created_by: requestData.reported_by
});
} catch (notifError) {
console.error('알림 생성 실패:', notifError);
// 알림 생성 실패해도 수리 신청은 성공으로 처리
}
callback(null, {
report_id: result.insertId,
equipment_id: requestData.equipment_id,
@@ -881,6 +896,53 @@ const EquipmentModel = {
} catch (error) {
callback(error);
}
},
// ADD REPAIR CATEGORY - 새 수리 항목 추가
addRepairCategory: async (itemName, callback) => {
try {
const db = await getDb();
// 설비 수리 카테고리 ID 조회
const [categories] = await db.query(
"SELECT category_id FROM issue_report_categories WHERE category_name = '설비 수리'"
);
if (categories.length === 0) {
return callback(new Error('설비 수리 카테고리가 없습니다.'));
}
const categoryId = categories[0].category_id;
// 중복 확인
const [existing] = await db.query(
'SELECT item_id FROM issue_report_items WHERE category_id = ? AND item_name = ?',
[categoryId, itemName]
);
if (existing.length > 0) {
// 이미 존재하면 해당 ID 반환
return callback(null, { item_id: existing[0].item_id, item_name: itemName, isNew: false });
}
// 다음 display_order 구하기
const [maxOrder] = await db.query(
'SELECT MAX(display_order) as max_order FROM issue_report_items WHERE category_id = ?',
[categoryId]
);
const nextOrder = (maxOrder[0].max_order || 0) + 1;
// 새 항목 추가
const [result] = await db.query(
`INSERT INTO issue_report_items (category_id, item_name, display_order, is_active)
VALUES (?, ?, ?, 1)`,
[categoryId, itemName, nextOrder]
);
callback(null, { item_id: result.insertId, item_name: itemName, isNew: true });
} catch (error) {
callback(error);
}
}
};

View File

@@ -0,0 +1,197 @@
// models/notificationModel.js
const { getDb } = require('../dbPool');
// 순환 참조를 피하기 위해 함수 내에서 require
async function getRecipientIds(notificationType) {
const db = await getDb();
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);
}
const notificationModel = {
// 알림 생성
async create(notificationData) {
const db = await getDb();
const { user_id, type, title, message, link_url, reference_type, reference_id, created_by } = notificationData;
const [result] = await db.query(
`INSERT INTO notifications (user_id, type, title, message, link_url, reference_type, reference_id, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[user_id || null, type || 'system', title, message || null, link_url || null, reference_type || null, reference_id || null, created_by || null]
);
return result.insertId;
},
// 읽지 않은 알림 조회 (특정 사용자 또는 전체)
async getUnread(userId = null) {
const db = await getDb();
const [rows] = await db.query(
`SELECT * FROM notifications
WHERE is_read = 0
AND (user_id IS NULL OR user_id = ?)
ORDER BY created_at DESC
LIMIT 50`,
[userId || 0]
);
return rows;
},
// 전체 알림 조회 (페이징)
async getAll(userId = null, page = 1, limit = 20) {
const db = await getDb();
const offset = (page - 1) * limit;
const [rows] = await db.query(
`SELECT * FROM notifications
WHERE (user_id IS NULL OR user_id = ?)
ORDER BY created_at DESC
LIMIT ? OFFSET ?`,
[userId || 0, limit, offset]
);
const [[{ total }]] = await db.query(
`SELECT COUNT(*) as total FROM notifications
WHERE (user_id IS NULL OR user_id = ?)`,
[userId || 0]
);
return { notifications: rows, total, page, limit };
},
// 알림 읽음 처리
async markAsRead(notificationId) {
const db = await getDb();
const [result] = await db.query(
`UPDATE notifications SET is_read = 1, read_at = NOW() WHERE notification_id = ?`,
[notificationId]
);
return result.affectedRows > 0;
},
// 모든 알림 읽음 처리
async markAllAsRead(userId = null) {
const db = await getDb();
const [result] = await db.query(
`UPDATE notifications SET is_read = 1, read_at = NOW()
WHERE is_read = 0 AND (user_id IS NULL OR user_id = ?)`,
[userId || 0]
);
return result.affectedRows;
},
// 알림 삭제
async delete(notificationId) {
const db = await getDb();
const [result] = await db.query(
`DELETE FROM notifications WHERE notification_id = ?`,
[notificationId]
);
return result.affectedRows > 0;
},
// 오래된 알림 삭제 (30일 이상)
async deleteOld(days = 30) {
const db = await getDb();
const [result] = await db.query(
`DELETE FROM notifications WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)`,
[days]
);
return result.affectedRows;
},
// 읽지 않은 알림 개수
async getUnreadCount(userId = null) {
const db = await getDb();
const [[{ count }]] = await db.query(
`SELECT COUNT(*) as count FROM notifications
WHERE is_read = 0 AND (user_id IS NULL OR user_id = ?)`,
[userId || 0]
);
return count;
},
// 수리 신청 알림 생성 헬퍼 (지정된 수신자에게 전송)
async createRepairNotification(repairData) {
const { equipment_id, equipment_name, repair_type, request_id, created_by } = repairData;
// 수리 알림 수신자 목록 가져오기
const recipientIds = await getRecipientIds('repair');
if (recipientIds.length === 0) {
// 수신자가 지정되지 않은 경우 전체 알림 (user_id = null)
return await this.create({
type: 'repair',
title: `수리 신청: ${equipment_name || '설비'}`,
message: `${repair_type} 수리가 신청되었습니다.`,
link_url: `/pages/admin/repair-management.html`,
reference_type: 'work_issue_reports',
reference_id: request_id,
created_by
});
}
// 지정된 수신자 각각에게 알림 생성
const results = [];
for (const userId of recipientIds) {
const notificationId = await this.create({
user_id: userId,
type: 'repair',
title: `수리 신청: ${equipment_name || '설비'}`,
message: `${repair_type} 수리가 신청되었습니다.`,
link_url: `/pages/admin/repair-management.html`,
reference_type: 'work_issue_reports',
reference_id: request_id,
created_by
});
results.push(notificationId);
}
return results;
},
// 일반 알림 생성 (유형별 지정된 수신자에게 전송)
async createTypedNotification(notificationData) {
const { type, title, message, link_url, reference_type, reference_id, created_by } = notificationData;
// 해당 유형의 수신자 목록 가져오기
const recipientIds = await getRecipientIds(type);
if (recipientIds.length === 0) {
// 수신자가 지정되지 않은 경우 전체 알림
return await this.create({
type,
title,
message,
link_url,
reference_type,
reference_id,
created_by
});
}
// 지정된 수신자 각각에게 알림 생성
const results = [];
for (const userId of recipientIds) {
const notificationId = await this.create({
user_id: userId,
type,
title,
message,
link_url,
reference_type,
reference_id,
created_by
});
results.push(notificationId);
}
return results;
}
};
module.exports = notificationModel;

View File

@@ -0,0 +1,146 @@
// models/notificationRecipientModel.js
const { getDb } = require('../dbPool');
const NOTIFICATION_TYPES = {
repair: '설비 수리',
safety: '안전 신고',
nonconformity: '부적합 신고',
equipment: '설비 관련',
maintenance: '정기점검',
system: '시스템'
};
const notificationRecipientModel = {
// 알림 유형 목록 가져오기
getTypes() {
return NOTIFICATION_TYPES;
},
// 유형별 수신자 목록 조회
async getByType(notificationType) {
const db = await getDb();
const [rows] = await db.query(
`SELECT nr.*, u.username, u.name as user_name, r.name as role
FROM notification_recipients nr
JOIN users u ON nr.user_id = u.user_id
LEFT JOIN roles r ON u.role_id = r.id
WHERE nr.notification_type = ? AND nr.is_active = 1
ORDER BY u.name`,
[notificationType]
);
return rows;
},
// 전체 수신자 목록 조회 (유형별 그룹화)
async getAll() {
const db = await getDb();
const [rows] = await db.query(
`SELECT nr.*, u.username, u.name as user_name, r.name as role
FROM notification_recipients nr
JOIN users u ON nr.user_id = u.user_id
LEFT JOIN roles r ON u.role_id = r.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 = await getDb();
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 = await getDb();
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 = await getDb();
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 = await getDb();
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 = await getDb();
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 = await getDb();
// 기존 수신자 비활성화
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

@@ -43,6 +43,9 @@ router.post('/external-logs/:logId/return', equipmentController.returnEquipment)
// 수리 항목 목록 조회
router.get('/repair-categories', equipmentController.getRepairCategories);
// 새 수리 항목 추가
router.post('/repair-categories', equipmentController.addRepairCategory);
// ==================== 사진 관리 ====================
// 사진 삭제 (설비 ID 없이 photo_id만으로)

View File

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

View File

@@ -0,0 +1,27 @@
// routes/notificationRoutes.js
const express = require('express');
const router = express.Router();
const notificationController = require('../controllers/notificationController');
// 읽지 않은 알림 조회
router.get('/unread', notificationController.getUnread);
// 읽지 않은 알림 개수
router.get('/unread/count', notificationController.getUnreadCount);
// 전체 알림 조회 (페이징)
router.get('/', notificationController.getAll);
// 알림 생성 (시스템/관리자용)
router.post('/', notificationController.create);
// 모든 알림 읽음 처리
router.post('/read-all', notificationController.markAllAsRead);
// 특정 알림 읽음 처리
router.post('/:id/read', notificationController.markAsRead);
// 알림 삭제
router.delete('/:id', notificationController.delete);
module.exports = router;

View File

@@ -27,6 +27,26 @@
</div>
<div class="header-right">
<!-- 알림 버튼 -->
<div class="notification-wrapper" id="notificationWrapper">
<button class="notification-btn" id="notificationBtn">
<svg class="notification-icon-svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path>
<path d="M13.73 21a2 2 0 0 1-3.46 0"></path>
</svg>
<span class="notification-badge" id="notificationBadge" style="display:none;">0</span>
</button>
<div class="notification-dropdown" id="notificationDropdown">
<div class="notification-header">
<h4>알림</h4>
<a href="/pages/admin/notifications.html" class="view-all-link">모두 보기</a>
</div>
<div class="notification-list" id="notificationList">
<div class="notification-empty">새 알림이 없습니다.</div>
</div>
</div>
</div>
<a href="/pages/dashboard.html" id="dashboardBtn" class="dashboard-btn">
<span class="btn-icon">📊</span>
<span class="btn-text">대시보드</span>
@@ -376,6 +396,208 @@ body {
font-family: inherit;
}
/* 알림 버튼 스타일 */
.notification-wrapper {
position: relative;
}
.notification-btn {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
background: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: var(--radius-lg);
cursor: pointer;
transition: var(--transition-normal);
position: relative;
}
.notification-btn:hover {
background: rgba(255, 255, 255, 0.25);
transform: translateY(-2px);
}
.notification-icon-svg {
width: 20px;
height: 20px;
color: white;
}
.notification-btn.has-notifications {
animation: pulse-btn 2s infinite;
}
@keyframes pulse-btn {
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
50% { box-shadow: 0 0 0 8px rgba(239, 68, 68, 0); }
}
.notification-badge {
position: absolute;
top: -4px;
right: -4px;
min-width: 18px;
height: 18px;
padding: 0 5px;
background: var(--error-500);
color: white;
font-size: 11px;
font-weight: var(--font-bold);
border-radius: var(--radius-full);
display: flex;
align-items: center;
justify-content: center;
animation: pulse-badge 2s infinite;
}
@keyframes pulse-badge {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.1); }
}
.notification-dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: var(--space-2);
width: 320px;
max-height: 400px;
background: var(--bg-primary);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl);
border: 1px solid var(--border-light);
opacity: 0;
visibility: hidden;
transform: translateY(-10px);
transition: var(--transition-normal);
z-index: 1000;
overflow: hidden;
}
.notification-dropdown.show {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.notification-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border-light);
background: var(--bg-secondary);
}
.notification-header h4 {
margin: 0;
font-size: var(--text-base);
font-weight: var(--font-semibold);
color: var(--text-primary);
}
.view-all-link {
font-size: var(--text-sm);
color: var(--primary-500);
text-decoration: none;
font-weight: var(--font-medium);
}
.view-all-link:hover {
text-decoration: underline;
}
.notification-list {
max-height: 320px;
overflow-y: auto;
}
.notification-item {
display: flex;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border-light);
cursor: pointer;
transition: var(--transition-fast);
}
.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: 3px;
background: var(--primary-500);
}
.notification-item {
position: relative;
}
.notification-item-icon {
width: 36px;
height: 36px;
border-radius: var(--radius-full);
background: var(--warning-100);
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
flex-shrink: 0;
}
.notification-item-icon.repair {
background: var(--warning-100);
}
.notification-item-content {
flex: 1;
min-width: 0;
}
.notification-item-title {
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--text-primary);
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.notification-item-desc {
font-size: var(--text-xs);
color: var(--text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.notification-item-time {
font-size: var(--text-xs);
color: var(--text-tertiary);
white-space: nowrap;
}
.notification-empty {
padding: var(--space-6);
text-align: center;
color: var(--text-tertiary);
font-size: var(--text-sm);
}
/* 반응형 디자인 */
@media (max-width: 768px) {
.dashboard-header {
@@ -407,5 +629,10 @@ body {
.report-btn {
padding: var(--space-2) var(--space-3);
}
.notification-dropdown {
width: 280px;
right: -50px;
}
}
</style>

View File

@@ -38,14 +38,17 @@
</div>
</div>
<!-- 일간작업장 점검 -->
<div class="nav-category" data-category="daily-inspection">
<!-- 공장 관리 -->
<div class="nav-category" data-category="factory">
<button class="nav-category-header">
<span class="nav-icon">&#128269;</span>
<span class="nav-text">일간작업장 점검</span>
<span class="nav-icon">&#127981;</span>
<span class="nav-text">공장 관리</span>
<span class="nav-arrow">&#9662;</span>
</button>
<div class="nav-category-items">
<a href="/pages/admin/repair-management.html" class="nav-item" data-page-key="factory.repair_management">
<span class="nav-text">시설설비 관리</span>
</a>
<a href="/pages/inspection/daily-patrol.html" class="nav-item" data-page-key="inspection.daily_patrol">
<span class="nav-text">일일순회점검</span>
</a>

View File

@@ -1128,3 +1128,193 @@
padding: 0.375rem 0.75rem;
font-size: 0.8rem;
}
/* ========================================
알림 수신자 설정 섹션
======================================== */
#notificationRecipientsSection {
margin-top: 2rem;
}
#notificationRecipientsSection .section-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.section-description {
font-size: 0.875rem;
color: #6c757d;
margin: 0;
}
.notification-recipients-container {
padding: 1.5rem 2rem;
}
.notification-type-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.notification-type-card {
background: #f8f9fa;
border: 2px solid #e9ecef;
border-radius: 12px;
padding: 1.25rem;
transition: all 0.2s ease;
}
.notification-type-card:hover {
border-color: #007bff;
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.1);
}
.notification-type-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.notification-type-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
font-size: 1rem;
color: #1a1a1a;
}
.notification-type-icon {
font-size: 1.25rem;
}
.notification-type-card.repair .notification-type-icon { color: #fd7e14; }
.notification-type-card.safety .notification-type-icon { color: #dc3545; }
.notification-type-card.nonconformity .notification-type-icon { color: #6f42c1; }
.notification-type-card.equipment .notification-type-icon { color: #17a2b8; }
.notification-type-card.maintenance .notification-type-icon { color: #28a745; }
.notification-type-card.system .notification-type-icon { color: #6c757d; }
.edit-recipients-btn {
padding: 0.375rem 0.75rem;
background: white;
border: 1px solid #dee2e6;
border-radius: 6px;
font-size: 0.75rem;
color: #495057;
cursor: pointer;
transition: all 0.2s ease;
}
.edit-recipients-btn:hover {
background: #007bff;
border-color: #007bff;
color: white;
}
.recipient-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
min-height: 32px;
}
.recipient-tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.625rem;
background: white;
border: 1px solid #dee2e6;
border-radius: 16px;
font-size: 0.75rem;
color: #495057;
}
.recipient-tag .tag-icon {
font-size: 0.875rem;
}
.no-recipients {
font-size: 0.8rem;
color: #adb5bd;
font-style: italic;
}
/* 알림 수신자 편집 모달 */
.modal-description {
font-size: 0.875rem;
color: #6c757d;
margin-bottom: 1rem;
}
.recipient-search-box {
margin-bottom: 1rem;
}
.recipient-user-list {
max-height: 350px;
overflow-y: auto;
border: 1px solid #e9ecef;
border-radius: 8px;
background: #f8f9fa;
}
.recipient-user-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid #e9ecef;
cursor: pointer;
transition: all 0.15s ease;
}
.recipient-user-item:last-child {
border-bottom: none;
}
.recipient-user-item:hover {
background: #e9ecef;
}
.recipient-user-item.selected {
background: #e7f3ff;
}
.recipient-user-item input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: #007bff;
}
.recipient-user-item .user-avatar-small {
width: 32px;
height: 32px;
font-size: 0.8rem;
}
.recipient-user-info {
flex: 1;
}
.recipient-user-name {
font-weight: 500;
font-size: 0.875rem;
color: #1a1a1a;
}
.recipient-user-role {
font-size: 0.75rem;
color: #6c757d;
}
@media (max-width: 768px) {
.notification-type-cards {
grid-template-columns: 1fr;
}
}

View File

@@ -1074,3 +1074,190 @@ async function unlinkWorker() {
}
}
window.unlinkWorker = unlinkWorker;
// ========== 알림 수신자 관리 ========== //
let notificationRecipients = {};
let allUsersForRecipient = [];
let currentNotificationType = null;
const NOTIFICATION_TYPE_CONFIG = {
repair: { name: '설비 수리', icon: '🔧', description: '설비 수리 신청 시 알림을 받을 사용자' },
safety: { name: '안전 신고', icon: '⚠️', description: '안전 관련 신고 시 알림을 받을 사용자' },
nonconformity: { name: '부적합 신고', icon: '🚫', description: '부적합 사항 신고 시 알림을 받을 사용자' },
equipment: { name: '설비 관련', icon: '🔩', description: '설비 관련 알림을 받을 사용자' },
maintenance: { name: '정기점검', icon: '🛠️', description: '정기점검 알림을 받을 사용자' },
system: { name: '시스템', icon: '📢', description: '시스템 알림을 받을 사용자' }
};
// 알림 수신자 목록 로드
async function loadNotificationRecipients() {
try {
const response = await window.apiCall('/notification-recipients');
if (response.success) {
notificationRecipients = response.data || {};
renderNotificationTypeCards();
}
} catch (error) {
console.error('알림 수신자 로드 오류:', error);
}
}
// 알림 유형 카드 렌더링
function renderNotificationTypeCards() {
const container = document.getElementById('notificationTypeCards');
if (!container) return;
let html = '';
Object.keys(NOTIFICATION_TYPE_CONFIG).forEach(type => {
const config = NOTIFICATION_TYPE_CONFIG[type];
const recipients = notificationRecipients[type]?.recipients || [];
html += `
<div class="notification-type-card ${type}">
<div class="notification-type-header">
<div class="notification-type-title">
<span class="notification-type-icon">${config.icon}</span>
<span>${config.name}</span>
</div>
<button class="edit-recipients-btn" onclick="openRecipientModal('${type}')">
편집
</button>
</div>
<div class="recipient-list">
${recipients.length > 0
? recipients.map(r => `
<span class="recipient-tag">
<span class="tag-icon">👤</span>
${r.user_name || r.username}
</span>
`).join('')
: '<span class="no-recipients">지정된 수신자 없음</span>'
}
</div>
</div>
`;
});
container.innerHTML = html;
}
// 수신자 편집 모달 열기
async function openRecipientModal(notificationType) {
currentNotificationType = notificationType;
const config = NOTIFICATION_TYPE_CONFIG[notificationType];
// 모달 정보 업데이트
document.getElementById('recipientModalTitle').textContent = config.name + ' 알림 수신자';
document.getElementById('recipientModalDesc').textContent = config.description;
// 사용자 목록 로드 (users가 이미 로드되어 있으면 사용)
if (users.length === 0) {
await loadUsers();
}
allUsersForRecipient = users.filter(u => u.is_active);
// 현재 수신자 목록
const currentRecipients = notificationRecipients[notificationType]?.recipients || [];
const currentRecipientIds = currentRecipients.map(r => r.user_id);
// 사용자 목록 렌더링
renderRecipientUserList(currentRecipientIds);
// 검색 이벤트
const searchInput = document.getElementById('recipientSearchInput');
searchInput.value = '';
searchInput.oninput = (e) => {
renderRecipientUserList(currentRecipientIds, e.target.value);
};
// 모달 표시
document.getElementById('notificationRecipientModal').style.display = 'flex';
}
window.openRecipientModal = openRecipientModal;
// 수신자 사용자 목록 렌더링
function renderRecipientUserList(selectedIds, searchTerm = '') {
const container = document.getElementById('recipientUserList');
if (!container) return;
let filteredUsers = allUsersForRecipient;
if (searchTerm) {
const term = searchTerm.toLowerCase();
filteredUsers = filteredUsers.filter(u =>
(u.name && u.name.toLowerCase().includes(term)) ||
(u.username && u.username.toLowerCase().includes(term))
);
}
if (filteredUsers.length === 0) {
container.innerHTML = '<div style="padding: 2rem; text-align: center; color: #6c757d;">사용자가 없습니다</div>';
return;
}
container.innerHTML = filteredUsers.map(user => {
const isSelected = selectedIds.includes(user.user_id);
return `
<div class="recipient-user-item ${isSelected ? 'selected' : ''}" onclick="toggleRecipientUser(${user.user_id}, this)">
<input type="checkbox" ${isSelected ? 'checked' : ''} data-user-id="${user.user_id}">
<div class="user-avatar-small">${(user.name || user.username).charAt(0)}</div>
<div class="recipient-user-info">
<div class="recipient-user-name">${user.name || user.username}</div>
<div class="recipient-user-role">${getRoleName(user.role)}</div>
</div>
</div>
`;
}).join('');
}
// 수신자 토글
function toggleRecipientUser(userId, element) {
const checkbox = element.querySelector('input[type="checkbox"]');
checkbox.checked = !checkbox.checked;
element.classList.toggle('selected', checkbox.checked);
}
window.toggleRecipientUser = toggleRecipientUser;
// 수신자 모달 닫기
function closeRecipientModal() {
document.getElementById('notificationRecipientModal').style.display = 'none';
currentNotificationType = null;
}
window.closeRecipientModal = closeRecipientModal;
// 알림 수신자 저장
async function saveNotificationRecipients() {
if (!currentNotificationType) {
showToast('알림 유형이 선택되지 않았습니다.', 'error');
return;
}
try {
const checkboxes = document.querySelectorAll('#recipientUserList input[type="checkbox"]:checked');
const userIds = Array.from(checkboxes).map(cb => parseInt(cb.dataset.userId));
const response = await window.apiCall(`/notification-recipients/${currentNotificationType}`, 'PUT', {
user_ids: userIds
});
if (response.success) {
showToast('알림 수신자가 저장되었습니다.', 'success');
closeRecipientModal();
await loadNotificationRecipients();
} else {
throw new Error(response.message || '저장에 실패했습니다.');
}
} catch (error) {
console.error('알림 수신자 저장 오류:', error);
showToast(`저장 중 오류가 발생했습니다: ${error.message}`, 'error');
}
}
window.saveNotificationRecipients = saveNotificationRecipients;
// 초기화 시 알림 수신자 로드
const originalInitializePage = initializePage;
initializePage = async function() {
await originalInitializePage();
await loadNotificationRecipients();
};

View File

@@ -254,6 +254,114 @@
}
});
}
// 알림 버튼 이벤트
const notificationBtn = document.getElementById('notificationBtn');
const notificationDropdown = document.getElementById('notificationDropdown');
const notificationWrapper = document.getElementById('notificationWrapper');
if (notificationBtn && notificationDropdown) {
notificationBtn.addEventListener('click', (e) => {
e.stopPropagation();
notificationDropdown.classList.toggle('show');
});
document.addEventListener('click', (e) => {
if (notificationWrapper && !notificationWrapper.contains(e.target)) {
notificationDropdown.classList.remove('show');
}
});
}
}
// ===== 알림 로드 =====
async function loadNotifications() {
try {
const token = localStorage.getItem('token');
if (!token) return;
const response = await fetch(`${window.API_BASE_URL}/notifications/unread`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) return;
const result = await response.json();
if (result.success) {
const notifications = result.data || [];
updateNotificationBadge(notifications.length);
renderNotificationList(notifications);
}
} catch (error) {
console.warn('알림 로드 오류:', error.message);
}
}
function updateNotificationBadge(count) {
const badge = document.getElementById('notificationBadge');
const btn = document.getElementById('notificationBtn');
if (!badge) return;
if (count > 0) {
badge.textContent = count > 99 ? '99+' : count;
badge.style.display = 'flex';
btn?.classList.add('has-notifications');
} else {
badge.style.display = 'none';
btn?.classList.remove('has-notifications');
}
}
function renderNotificationList(notifications) {
const list = document.getElementById('notificationList');
if (!list) return;
if (notifications.length === 0) {
list.innerHTML = '<div class="notification-empty">새 알림이 없습니다.</div>';
return;
}
const icons = { repair: '🔧', safety: '⚠️', system: '📢', equipment: '🔩', maintenance: '🛠️' };
list.innerHTML = notifications.slice(0, 5).map(n => `
<div class="notification-item ${n.is_read ? '' : 'unread'}" data-id="${n.notification_id}" data-url="${n.link_url || ''}">
<div class="notification-item-icon ${n.type || 'repair'}">${icons[n.type] || '🔔'}</div>
<div class="notification-item-content">
<div class="notification-item-title">${escapeHtml(n.title)}</div>
<div class="notification-item-desc">${escapeHtml(n.message || '')}</div>
</div>
<div class="notification-item-time">${formatTimeAgo(n.created_at)}</div>
</div>
`).join('');
list.querySelectorAll('.notification-item').forEach(item => {
item.addEventListener('click', () => {
const url = item.dataset.url;
// 수리 알림은 클릭해도 읽음 처리 안함 (수리 처리 페이지에서 확인 처리해야 함)
window.location.href = url || '/pages/admin/notifications.html';
});
});
}
function formatTimeAgo(dateString) {
const date = new Date(dateString);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return '방금 전';
if (diffMins < 60) return `${diffMins}분 전`;
if (diffHours < 24) return `${diffHours}시간 전`;
if (diffDays < 7) return `${diffDays}일 전`;
return date.toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' });
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// ===== 날짜/시간 업데이트 =====
@@ -387,6 +495,10 @@
// 8. 날씨 (백그라운드)
setTimeout(updateWeather, 100);
// 9. 알림 로드 (30초마다 갱신)
setTimeout(loadNotifications, 200);
setInterval(loadNotifications, 30000);
console.log('✅ app-init 완료');
}

View File

@@ -2,7 +2,7 @@
import { config } from './config.js';
// 캐시 버전 (컴포넌트 변경 시 증가)
const CACHE_VERSION = 'v1';
const CACHE_VERSION = 'v4';
/**
* 컴포넌트 HTML을 캐시에서 가져오거나 fetch

View File

@@ -269,6 +269,151 @@ async function updateWeather() {
}
}
// ==========================================
// 알림 시스템
// ==========================================
/**
* 알림 관련 이벤트 설정
*/
function setupNotificationEvents() {
const notificationBtn = document.getElementById('notificationBtn');
const notificationDropdown = document.getElementById('notificationDropdown');
const notificationWrapper = document.getElementById('notificationWrapper');
if (notificationBtn) {
notificationBtn.addEventListener('click', (e) => {
e.stopPropagation();
notificationDropdown?.classList.toggle('show');
});
}
// 외부 클릭시 드롭다운 닫기
document.addEventListener('click', (e) => {
if (notificationWrapper && notificationDropdown && !notificationWrapper.contains(e.target)) {
notificationDropdown.classList.remove('show');
}
});
}
/**
* 알림 목록 로드
*/
async function loadNotifications() {
try {
const token = localStorage.getItem('token');
if (!token) return;
const response = await fetch(`${window.API_BASE_URL}/notifications/unread`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) return;
const result = await response.json();
if (result.success) {
const notifications = result.data || [];
updateNotificationBadge(notifications.length);
renderNotificationList(notifications);
}
} catch (error) {
console.warn('알림 로드 오류:', error.message);
}
}
/**
* 배지 업데이트
*/
function updateNotificationBadge(count) {
const badge = document.getElementById('notificationBadge');
const btn = document.getElementById('notificationBtn');
if (!badge) return;
if (count > 0) {
badge.textContent = count > 99 ? '99+' : count;
badge.style.display = 'flex';
btn?.classList.add('has-notifications');
} else {
badge.style.display = 'none';
btn?.classList.remove('has-notifications');
}
}
/**
* 알림 목록 렌더링
*/
function renderNotificationList(notifications) {
const list = document.getElementById('notificationList');
if (!list) return;
if (notifications.length === 0) {
list.innerHTML = '<div class="notification-empty">새 알림이 없습니다.</div>';
return;
}
const NOTIF_ICONS = {
repair: '🔧',
safety: '⚠️',
system: '📢',
equipment: '🔩',
maintenance: '🛠️'
};
list.innerHTML = notifications.slice(0, 5).map(n => `
<div class="notification-item ${n.is_read ? '' : 'unread'}" data-id="${n.notification_id}" data-url="${n.link_url || ''}">
<div class="notification-item-icon ${n.type || 'repair'}">
${NOTIF_ICONS[n.type] || '🔔'}
</div>
<div class="notification-item-content">
<div class="notification-item-title">${escapeHtml(n.title)}</div>
<div class="notification-item-desc">${escapeHtml(n.message || '')}</div>
</div>
<div class="notification-item-time">${formatTimeAgo(n.created_at)}</div>
</div>
`).join('');
// 클릭 이벤트 추가
list.querySelectorAll('.notification-item').forEach(item => {
item.addEventListener('click', () => {
const linkUrl = item.dataset.url;
// 수리 알림은 클릭해도 읽음 처리 안함 (수리 처리 페이지에서 확인 처리)
window.location.href = linkUrl || '/pages/admin/notifications.html';
});
});
}
/**
* 시간 포맷팅
*/
function formatTimeAgo(dateString) {
const date = new Date(dateString);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return '방금 전';
if (diffMins < 60) return `${diffMins}분 전`;
if (diffHours < 24) return `${diffHours}시간 전`;
if (diffDays < 7) return `${diffDays}일 전`;
return date.toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' });
}
/**
* HTML 이스케이프
*/
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 메인 로직: DOMContentLoaded 시 실행
document.addEventListener('DOMContentLoaded', async () => {
if (getUser()) {
@@ -285,5 +430,10 @@ document.addEventListener('DOMContentLoaded', async () => {
// 4. 날씨 정보 로드 (10분마다 갱신)
updateWeather();
setInterval(updateWeather, 10 * 60 * 1000);
// 5. 알림 이벤트 설정 및 로드 (30초마다 갱신)
setupNotificationEvents();
loadNotifications();
setInterval(loadNotifications, 30000);
}
});

View File

@@ -1398,27 +1398,73 @@ function openPanelRepairModal() {
panelRepairCategories.forEach(item => {
select.innerHTML += `<option value="${item.item_id}">${item.item_name}</option>`;
});
// 새로 추가 옵션
select.innerHTML += '<option value="__new__">+ 새로 추가</option>';
document.getElementById('panelRepairDesc').value = '';
document.getElementById('panelRepairPhotoInput').value = '';
document.getElementById('newRepairTypeName').value = '';
document.getElementById('newRepairTypeGroup').style.display = 'none';
panelRepairPhotoBases = [];
document.getElementById('panelRepairModal').style.display = 'flex';
}
function onRepairTypeChange() {
const select = document.getElementById('panelRepairItem');
const newTypeGroup = document.getElementById('newRepairTypeGroup');
if (select.value === '__new__') {
newTypeGroup.style.display = 'block';
document.getElementById('newRepairTypeName').focus();
} else {
newTypeGroup.style.display = 'none';
}
}
function closePanelRepairModal() {
document.getElementById('panelRepairModal').style.display = 'none';
}
async function submitPanelRepair() {
const itemId = document.getElementById('panelRepairItem').value;
const selectValue = document.getElementById('panelRepairItem').value;
const description = document.getElementById('panelRepairDesc').value;
const newTypeName = document.getElementById('newRepairTypeName').value.trim();
if (!description) {
alert('수리 내용을 입력하세요.');
return;
}
let itemId = selectValue;
// 새 유형 추가하는 경우
if (selectValue === '__new__') {
if (!newTypeName) {
alert('새 수리 유형 이름을 입력하세요.');
return;
}
try {
const addResponse = await window.apiCall('/equipments/repair-categories', 'POST', {
item_name: newTypeName
});
if (addResponse && addResponse.success) {
itemId = addResponse.data.item_id;
// 목록에 추가
panelRepairCategories.push({ item_id: itemId, item_name: newTypeName });
} else {
alert('새 유형 추가에 실패했습니다.');
return;
}
} catch (error) {
console.error('새 유형 추가 실패:', error);
alert('새 유형 추가에 실패했습니다.');
return;
}
}
// 사진 처리
const fileInput = document.getElementById('panelRepairPhotoInput');
const photos = [];
@@ -1728,6 +1774,7 @@ window.confirmPanelMove = confirmPanelMove;
window.openPanelRepairModal = openPanelRepairModal;
window.closePanelRepairModal = closePanelRepairModal;
window.submitPanelRepair = submitPanelRepair;
window.onRepairTypeChange = onRepairTypeChange;
window.openPanelExportModal = openPanelExportModal;
window.closePanelExportModal = closePanelExportModal;
window.submitPanelExport = submitPanelExport;

View File

@@ -6,7 +6,7 @@
<title>관리자 설정 | (주)테크니컬코리아</title>
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/common.css?v=1">
<link rel="stylesheet" href="/css/admin-settings.css?v=1">
<link rel="stylesheet" href="/css/admin-settings.css?v=2">
<link rel="icon" type="image/png" href="/img/favicon.png">
</head>
<body>
@@ -69,6 +69,20 @@
</div>
</div>
</div>
<!-- 알림 수신자 설정 섹션 -->
<div class="settings-section" id="notificationRecipientsSection">
<div class="section-header">
<h2 class="section-title">알림 수신자 설정</h2>
<p class="section-description">알림 유형별 수신자를 지정합니다. 지정된 사용자에게만 알림이 전송됩니다.</p>
</div>
<div class="notification-recipients-container">
<div class="notification-type-cards" id="notificationTypeCards">
<!-- 동적으로 생성됨 -->
</div>
</div>
</div>
</div>
</main>
</div>
@@ -233,6 +247,33 @@
</div>
</div>
<!-- 알림 수신자 편집 모달 -->
<div id="notificationRecipientModal" class="modal-overlay" style="display: none;">
<div class="modal-container">
<div class="modal-header">
<h2 id="recipientModalTitle">알림 수신자 설정</h2>
<button class="modal-close-btn" onclick="closeRecipientModal()">×</button>
</div>
<div class="modal-body">
<p class="modal-description" id="recipientModalDesc">이 알림을 받을 사용자를 선택하세요.</p>
<div class="recipient-search-box">
<input type="text" id="recipientSearchInput" placeholder="사용자 검색..." class="form-control">
</div>
<div class="recipient-user-list" id="recipientUserList">
<!-- 사용자 체크박스 목록 -->
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeRecipientModal()">취소</button>
<button type="button" class="btn btn-primary" onclick="saveNotificationRecipients()">저장</button>
</div>
</div>
</div>
<!-- 토스트 알림 -->
<div class="toast-container" id="toastContainer"></div>
@@ -240,6 +281,6 @@
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<script src="/js/admin-settings.js?v=8"></script>
<script src="/js/admin-settings.js?v=9"></script>
</body>
</html>

View File

@@ -0,0 +1,554 @@
<!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"></script>
<script src="/js/app-init.js?v=2" 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>

View File

@@ -0,0 +1,852 @@
<!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"></script>
<script src="/js/app-init.js?v=3" defer></script>
<style>
.repair-page {
max-width: 1400px;
margin: 0 auto;
padding: 1.5rem;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.page-header h1 {
font-size: 1.5rem;
font-weight: 700;
margin: 0;
}
.stats-row {
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-left: 4px solid var(--gray-300);
cursor: pointer;
transition: var(--transition-fast);
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.stat-card.active {
background: var(--primary-50);
}
.stat-card.reported { border-left-color: var(--error-500); }
.stat-card.received { border-left-color: var(--warning-500); }
.stat-card.in_progress { border-left-color: var(--primary-500); }
.stat-card.completed { border-left-color: var(--success-500); }
.stat-value {
font-size: 1.75rem;
font-weight: 700;
}
.stat-label {
font-size: 0.875rem;
color: var(--text-tertiary);
}
.repair-table-container {
background: white;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.repair-table {
width: 100%;
border-collapse: collapse;
}
.repair-table th,
.repair-table td {
padding: 1rem;
text-align: left;
border-bottom: 1px solid var(--border-light);
}
.repair-table th {
background: var(--bg-secondary);
font-weight: 600;
font-size: 0.875rem;
color: var(--text-secondary);
}
.repair-table tr:hover {
background: var(--bg-secondary);
}
.status-badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.75rem;
border-radius: var(--radius-full);
font-size: 0.75rem;
font-weight: 600;
}
.status-badge.reported {
background: var(--error-100);
color: var(--error-700);
}
.status-badge.received {
background: var(--warning-100);
color: var(--warning-700);
}
.status-badge.in_progress {
background: var(--primary-100);
color: var(--primary-700);
}
.status-badge.completed, .status-badge.resolved {
background: var(--success-100);
color: var(--success-700);
}
.action-btns {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
border-radius: var(--radius-md);
border: none;
cursor: pointer;
font-weight: 500;
transition: var(--transition-fast);
white-space: nowrap;
}
.btn-receive {
background: var(--warning-500);
color: white;
}
.btn-receive:hover {
background: var(--warning-600);
}
.btn-start {
background: var(--primary-500);
color: white;
}
.btn-start:hover {
background: var(--primary-600);
}
.btn-complete {
background: var(--success-500);
color: white;
}
.btn-complete:hover {
background: var(--success-600);
}
.btn-view {
background: var(--gray-100);
color: var(--text-secondary);
}
.btn-view:hover {
background: var(--gray-200);
}
.empty-state {
padding: 3rem;
text-align: center;
color: var(--text-tertiary);
}
.repair-desc {
max-width: 200px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.assignee-info {
font-size: 0.75rem;
color: var(--text-tertiary);
}
/* 모달 */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal-overlay.show {
display: flex;
}
.modal-content {
background: white;
border-radius: var(--radius-lg);
width: 90%;
max-width: 500px;
max-height: 80vh;
overflow-y: auto;
}
.modal-header {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-light);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
margin: 0;
font-size: 1.125rem;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-tertiary);
}
.modal-body {
padding: 1.5rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
font-size: 0.875rem;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
font-size: 0.875rem;
}
.form-group textarea {
min-height: 100px;
resize: vertical;
}
.modal-footer {
padding: 1rem 1.5rem;
border-top: 1px solid var(--border-light);
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.btn {
padding: 0.625rem 1.25rem;
border-radius: var(--radius-md);
font-weight: 500;
cursor: pointer;
border: none;
}
.btn-primary {
background: var(--primary-500);
color: white;
}
.btn-secondary {
background: var(--gray-100);
color: var(--text-primary);
}
.detail-row {
display: flex;
margin-bottom: 0.75rem;
font-size: 0.875rem;
}
.detail-label {
width: 80px;
color: var(--text-tertiary);
flex-shrink: 0;
}
.detail-value {
flex: 1;
color: var(--text-primary);
}
.photo-thumbnails {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.5rem;
}
.photo-thumb {
width: 60px;
height: 60px;
object-fit: cover;
border-radius: var(--radius-md);
cursor: pointer;
}
@media (max-width: 768px) {
.stats-row {
flex-wrap: wrap;
}
.stat-card {
flex: 1 1 45%;
}
.repair-table {
font-size: 0.875rem;
}
.repair-table th:nth-child(3),
.repair-table td:nth-child(3),
.repair-table th:nth-child(5),
.repair-table td:nth-child(5) {
display: none;
}
}
</style>
</head>
<body>
<div id="navbar-container"></div>
<div id="sidebar-container"></div>
<main class="main-content">
<div class="repair-page">
<div class="page-header">
<h1>시설설비 관리</h1>
</div>
<div class="stats-row">
<div class="stat-card reported" onclick="filterByStatus('reported')">
<div class="stat-value" id="reportedCount">0</div>
<div class="stat-label">신청</div>
</div>
<div class="stat-card received" onclick="filterByStatus('received')">
<div class="stat-value" id="receivedCount">0</div>
<div class="stat-label">접수</div>
</div>
<div class="stat-card in_progress" onclick="filterByStatus('in_progress')">
<div class="stat-value" id="inProgressCount">0</div>
<div class="stat-label">처리중</div>
</div>
<div class="stat-card completed" onclick="filterByStatus('completed')">
<div class="stat-value" id="completedCount">0</div>
<div class="stat-label">완료</div>
</div>
</div>
<div class="repair-table-container">
<table class="repair-table">
<thead>
<tr>
<th>신청일</th>
<th>작업장</th>
<th>유형</th>
<th>설명</th>
<th>담당자</th>
<th>상태</th>
<th>처리</th>
</tr>
</thead>
<tbody id="repairTableBody">
<tr>
<td colspan="7" class="empty-state">로딩중...</td>
</tr>
</tbody>
</table>
</div>
</div>
</main>
<!-- 접수 모달 -->
<div class="modal-overlay" id="receiveModal">
<div class="modal-content">
<div class="modal-header">
<h3>접수 확인</h3>
<button class="modal-close" onclick="closeModal('receiveModal')">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>담당자 배정 *</label>
<select id="receiveAssignee" required>
<option value="">담당자 선택</option>
</select>
</div>
<div class="form-group">
<label>메모</label>
<textarea id="receiveNotes" placeholder="접수 시 메모 (선택사항)"></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('receiveModal')">취소</button>
<button class="btn btn-primary" onclick="submitReceive()">접수 확인</button>
</div>
</div>
</div>
<!-- 완료 모달 -->
<div class="modal-overlay" id="completeModal">
<div class="modal-content">
<div class="modal-header">
<h3>완료 처리</h3>
<button class="modal-close" onclick="closeModal('completeModal')">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>처리 내용 *</label>
<textarea id="completeNotes" placeholder="처리 내용을 입력하세요..." required></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('completeModal')">취소</button>
<button class="btn btn-primary" onclick="submitComplete()">완료 처리</button>
</div>
</div>
</div>
<!-- 상세 모달 -->
<div class="modal-overlay" id="detailModal">
<div class="modal-content">
<div class="modal-header">
<h3>상세 정보</h3>
<button class="modal-close" onclick="closeModal('detailModal')">&times;</button>
</div>
<div class="modal-body" id="detailContent">
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('detailModal')">닫기</button>
</div>
</div>
</div>
<script>
let currentReportId = null;
let allRepairs = [];
let workers = [];
let currentFilter = null;
document.addEventListener('DOMContentLoaded', function() {
setTimeout(() => {
loadWorkers();
loadRepairRequests();
}, 300);
});
async function loadWorkers() {
try {
const response = await window.apiCall('/workers?status=active');
if (response.success) {
workers = response.data || [];
populateAssigneeDropdown();
}
} catch (error) {
console.error('작업자 목록 로드 오류:', error);
}
}
function populateAssigneeDropdown() {
const select = document.getElementById('receiveAssignee');
select.innerHTML = '<option value="">담당자 선택</option>' +
workers.map(w => `<option value="${w.worker_id}">${w.worker_name} (${w.job_type || ''})</option>`).join('');
}
async function loadRepairRequests() {
try {
const response = await window.apiCall('/work-issues?category_type=nonconformity');
if (response.success) {
allRepairs = response.data || [];
updateStats();
renderTable();
}
} catch (error) {
console.error('수리 목록 로드 오류:', error);
document.getElementById('repairTableBody').innerHTML =
'<tr><td colspan="7" class="empty-state">데이터를 불러올 수 없습니다.</td></tr>';
}
}
function updateStats() {
const counts = {
reported: 0,
received: 0,
in_progress: 0,
completed: 0
};
allRepairs.forEach(r => {
if (counts.hasOwnProperty(r.status)) {
counts[r.status]++;
}
});
document.getElementById('reportedCount').textContent = counts.reported;
document.getElementById('receivedCount').textContent = counts.received;
document.getElementById('inProgressCount').textContent = counts.in_progress;
document.getElementById('completedCount').textContent = counts.completed;
// 활성 필터 표시
document.querySelectorAll('.stat-card').forEach(card => {
card.classList.remove('active');
});
if (currentFilter) {
document.querySelector(`.stat-card.${currentFilter}`)?.classList.add('active');
}
}
function filterByStatus(status) {
if (currentFilter === status) {
currentFilter = null; // 토글 off
} else {
currentFilter = status;
}
updateStats();
renderTable();
}
function renderTable() {
const tbody = document.getElementById('repairTableBody');
let filtered = allRepairs;
if (currentFilter) {
filtered = allRepairs.filter(r => r.status === currentFilter);
}
if (filtered.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="empty-state">수리 신청 내역이 없습니다.</td></tr>';
return;
}
tbody.innerHTML = filtered.map(r => `
<tr>
<td>${formatDate(r.report_date)}</td>
<td>${r.workplace_name || '-'}</td>
<td>${r.issue_item_name || '-'}</td>
<td class="repair-desc" title="${escapeHtml(r.additional_description || '')}">${escapeHtml(r.additional_description || '-')}</td>
<td>
${r.assigned_full_name || r.assigned_user_name || '-'}
${r.assigned_at ? `<div class="assignee-info">${formatDate(r.assigned_at)}</div>` : ''}
</td>
<td><span class="status-badge ${r.status}">${getStatusText(r.status)}</span></td>
<td class="action-btns">
${getActionButtons(r)}
</td>
</tr>
`).join('');
}
function getActionButtons(r) {
let buttons = '';
switch (r.status) {
case 'reported':
buttons = `<button class="btn-sm btn-receive" onclick="openReceiveModal(${r.report_id})">접수</button>`;
break;
case 'received':
buttons = `<button class="btn-sm btn-start" onclick="startProcessing(${r.report_id})">처리시작</button>`;
break;
case 'in_progress':
buttons = `<button class="btn-sm btn-complete" onclick="openCompleteModal(${r.report_id})">완료</button>`;
break;
}
buttons += `<button class="btn-sm btn-view" onclick="viewDetail(${r.report_id})">상세</button>`;
return buttons;
}
function getStatusText(status) {
const texts = {
'reported': '신청',
'received': '접수',
'in_progress': '처리중',
'completed': '완료',
'closed': '종료',
'resolved': '완료'
};
return texts[status] || status;
}
function formatDate(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('ko-KR', {
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 openReceiveModal(reportId) {
currentReportId = reportId;
document.getElementById('receiveNotes').value = '';
document.getElementById('receiveAssignee').value = '';
document.getElementById('receiveModal').classList.add('show');
}
async function submitReceive() {
const assigneeId = document.getElementById('receiveAssignee').value;
if (!assigneeId) {
alert('담당자를 선택해주세요.');
return;
}
try {
// 1. 접수 처리
const receiveRes = await window.apiCall(`/work-issues/${currentReportId}/receive`, {
method: 'PUT'
});
if (!receiveRes.success) {
throw new Error(receiveRes.message || '접수 처리 실패');
}
// 2. 담당자 배정
const assignRes = await window.apiCall(`/work-issues/${currentReportId}/assign`, {
method: 'PUT',
body: JSON.stringify({
assigned_user_id: parseInt(assigneeId)
})
});
// 3. 관련 알림 읽음 처리
await markRelatedNotificationAsRead(currentReportId);
alert('접수 완료되었습니다.');
closeModal('receiveModal');
loadRepairRequests();
} catch (error) {
console.error('접수 오류:', error);
alert('접수 처리 중 오류가 발생했습니다: ' + error.message);
}
}
// 처리 시작
async function startProcessing(reportId) {
if (!confirm('처리를 시작하시겠습니까?')) return;
try {
const response = await window.apiCall(`/work-issues/${reportId}/start`, {
method: 'PUT'
});
if (response.success) {
alert('처리가 시작되었습니다.');
loadRepairRequests();
} else {
throw new Error(response.message);
}
} catch (error) {
console.error('처리 시작 오류:', error);
alert('처리 시작 중 오류가 발생했습니다.');
}
}
// 완료 모달
function openCompleteModal(reportId) {
currentReportId = reportId;
document.getElementById('completeNotes').value = '';
document.getElementById('completeModal').classList.add('show');
}
async function submitComplete() {
const notes = document.getElementById('completeNotes').value.trim();
if (!notes) {
alert('처리 내용을 입력해주세요.');
return;
}
try {
const response = await window.apiCall(`/work-issues/${currentReportId}/complete`, {
method: 'PUT',
body: JSON.stringify({
resolution_notes: notes
})
});
if (response.success) {
alert('완료 처리되었습니다.');
closeModal('completeModal');
loadRepairRequests();
} else {
throw new Error(response.message);
}
} catch (error) {
console.error('완료 처리 오류:', error);
alert('완료 처리 중 오류가 발생했습니다.');
}
}
// 상세 보기
async function viewDetail(reportId) {
try {
const response = await window.apiCall(`/work-issues/${reportId}`);
if (response.success && response.data) {
const r = response.data;
let html = `
<div class="detail-row">
<span class="detail-label">신청일</span>
<span class="detail-value">${formatDate(r.report_date)}</span>
</div>
<div class="detail-row">
<span class="detail-label">신청자</span>
<span class="detail-value">${r.reporter_full_name || r.reporter_name || '-'}</span>
</div>
<div class="detail-row">
<span class="detail-label">작업장</span>
<span class="detail-value">${r.workplace_name || '-'}</span>
</div>
<div class="detail-row">
<span class="detail-label">유형</span>
<span class="detail-value">${r.issue_item_name || '-'}</span>
</div>
<div class="detail-row">
<span class="detail-label">상태</span>
<span class="detail-value"><span class="status-badge ${r.status}">${getStatusText(r.status)}</span></span>
</div>
<div class="detail-row">
<span class="detail-label">설명</span>
<span class="detail-value">${escapeHtml(r.additional_description) || '-'}</span>
</div>
`;
if (r.assigned_full_name || r.assigned_user_name) {
html += `
<div class="detail-row">
<span class="detail-label">담당자</span>
<span class="detail-value">${r.assigned_full_name || r.assigned_user_name}</span>
</div>
`;
}
if (r.resolution_notes) {
html += `
<div class="detail-row">
<span class="detail-label">처리내용</span>
<span class="detail-value">${escapeHtml(r.resolution_notes)}</span>
</div>
`;
}
// 사진
const photos = [r.photo_path1, r.photo_path2, r.photo_path3, r.photo_path4, r.photo_path5].filter(p => p);
if (photos.length > 0) {
html += `
<div class="detail-row">
<span class="detail-label">사진</span>
<span class="detail-value">
<div class="photo-thumbnails">
${photos.map(p => `<img src="${p}" class="photo-thumb" onclick="window.open('${p}', '_blank')">`).join('')}
</div>
</span>
</div>
`;
}
document.getElementById('detailContent').innerHTML = html;
document.getElementById('detailModal').classList.add('show');
}
} catch (error) {
console.error('상세 조회 오류:', error);
alert('상세 정보를 불러올 수 없습니다.');
}
}
function closeModal(modalId) {
document.getElementById(modalId).classList.remove('show');
currentReportId = null;
}
async function markRelatedNotificationAsRead(reportId) {
try {
const response = await window.apiCall('/notifications?limit=100');
if (response.success) {
const notifications = response.data || [];
const related = notifications.find(n =>
n.reference_type === 'work_issue_reports' &&
n.reference_id == reportId
);
if (related) {
await window.apiCall(`/notifications/${related.notification_id}/read`, {
method: 'POST'
});
}
}
} catch (e) {
console.warn('알림 읽음 처리 실패:', e);
}
}
// 모달 외부 클릭시 닫기
document.querySelectorAll('.modal-overlay').forEach(modal => {
modal.addEventListener('click', function(e) {
if (e.target === this) {
this.classList.remove('show');
}
});
});
</script>
</body>
</html>

View File

@@ -419,10 +419,14 @@
<div class="mini-modal-body">
<div class="form-group">
<label>수리 유형</label>
<select id="panelRepairItem" class="form-control">
<select id="panelRepairItem" class="form-control" onchange="onRepairTypeChange()">
<option value="">선택하세요</option>
</select>
</div>
<div class="form-group" id="newRepairTypeGroup" style="display:none;">
<label>새 유형 이름</label>
<input type="text" id="newRepairTypeName" class="form-control" placeholder="새로운 수리 유형 입력">
</div>
<div class="form-group">
<label>상세 내용</label>
<textarea id="panelRepairDesc" class="form-control" rows="3" placeholder="수리 필요 내용"></textarea>