feat: 권한 탭 분리 + 부서 인원 표시 + 다수 시스템 개선

- tkuser: 권한 관리를 별도 탭으로 분리, 부서 클릭 시 소속 인원 목록 표시
- system1: 모바일 UI 개선, nginx 권한 보정, 신고 카테고리 타입 마이그레이션
- system2: 신고 상세/보고서 개선, 내 보고서 페이지 추가
- system3: 이슈 뷰/수신함/관리함 개선
- gateway: 포털 라우팅 수정
- user-management API: 부서별 권한 벌크 설정 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-02-23 14:12:57 +09:00
parent bf4000c4ae
commit 3cc29c03a8
37 changed files with 1751 additions and 233 deletions

View File

@@ -272,6 +272,20 @@ exports.createReport = async (req, res) => {
}
}
// category_type 조회
let categoryType = null;
try {
const catInfo = await new Promise((resolve, reject) => {
workIssueModel.getCategoryById(issue_category_id, (catErr, data) => {
if (catErr) reject(catErr); else resolve(data);
});
});
if (catInfo) categoryType = catInfo.category_type;
} catch (catErr) {
console.error('카테고리 조회 실패:', catErr);
return res.status(500).json({ success: false, error: '카테고리 정보 조회 실패' });
}
const reportData = {
reporter_id,
factory_category_id: factory_category_id || null,
@@ -282,6 +296,7 @@ exports.createReport = async (req, res) => {
visit_request_id: visit_request_id || null,
issue_category_id,
issue_item_id: finalItemId || null,
category_type: categoryType,
additional_description: additional_description || null,
...photoPaths
};
@@ -326,6 +341,26 @@ exports.createReport = async (req, res) => {
const descText = additional_description || categoryInfo.category_name;
// 위치 정보 조회
let locationInfo = custom_location || null;
if (factory_category_id) {
try {
const locationParts = await new Promise((resolve, reject) => {
workIssueModel.getLocationNames(factory_category_id, workplace_id, (locErr, data) => {
if (locErr) reject(locErr); else resolve(data);
});
});
if (locationParts) {
locationInfo = locationParts.factory_name || '';
if (locationParts.workplace_name) {
locationInfo += ` - ${locationParts.workplace_name}`;
}
}
} catch (locErr) {
console.error('위치 정보 조회 실패:', locErr.message);
}
}
// 원래 신고자의 SSO 토큰 추출
const originalToken = (req.headers['authorization'] || '').replace('Bearer ', '');
@@ -335,6 +370,7 @@ exports.createReport = async (req, res) => {
reporter_name: req.user.name || req.user.username,
tk_issue_id: reportId,
project_id: project_id || null,
location_info: locationInfo,
photos: photoBase64List,
ssoToken: originalToken
});
@@ -667,6 +703,30 @@ exports.getStatusLogs = (req, res) => {
});
};
// ==================== 유형 이관 ====================
/**
* 신고 유형 이관
*/
exports.transferReport = (req, res) => {
const { id } = req.params;
const { category_type } = req.body;
// ENUM 유효성 검증
const validTypes = ['nonconformity', 'safety', 'facility'];
if (!category_type || !validTypes.includes(category_type)) {
return res.status(400).json({ success: false, error: '유효하지 않은 유형입니다. (nonconformity, safety, facility)' });
}
workIssueModel.transferCategoryType(id, category_type, req.user.user_id, (err, result) => {
if (err) {
console.error('유형 이관 실패:', err);
return res.status(400).json({ success: false, error: err.message || '유형 이관 실패' });
}
res.json({ success: true, message: '유형이 이관되었습니다.' });
});
};
// ==================== 통계 ====================
/**
@@ -674,6 +734,7 @@ exports.getStatusLogs = (req, res) => {
*/
exports.getStatsSummary = (req, res) => {
const filters = {
category_type: req.query.category_type,
start_date: req.query.start_date,
end_date: req.query.end_date,
factory_category_id: req.query.factory_category_id

View File

@@ -237,6 +237,7 @@ const createReport = async (reportData, callback) => {
visit_request_id = null,
issue_category_id,
issue_item_id = null,
category_type,
additional_description = null,
photo_path1 = null,
photo_path2 = null,
@@ -251,11 +252,11 @@ const createReport = async (reportData, callback) => {
const [result] = await db.query(
`INSERT INTO work_issue_reports
(reporter_id, report_date, factory_category_id, workplace_id, project_id, custom_location,
tbm_session_id, visit_request_id, issue_category_id, issue_item_id,
tbm_session_id, visit_request_id, issue_category_id, issue_item_id, category_type,
additional_description, photo_path1, photo_path2, photo_path3, photo_path4, photo_path5)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[reporter_id, reportDate, factory_category_id, workplace_id, project_id, custom_location,
tbm_session_id, visit_request_id, issue_category_id, issue_item_id,
tbm_session_id, visit_request_id, issue_category_id, issue_item_id, category_type,
additional_description, photo_path1, photo_path2, photo_path3, photo_path4, photo_path5]
);
@@ -291,7 +292,7 @@ const getAllReports = async (filters = {}, callback) => {
u.username as reporter_name, u.name as reporter_full_name,
wc.category_name as factory_name,
w.workplace_name,
irc.category_type, irc.category_name as issue_category_name,
wir.category_type, irc.category_type as original_category_type, irc.category_name as issue_category_name,
iri.item_name as issue_item_name, iri.severity,
assignee.username as assigned_user_name, assignee.name as assigned_full_name
FROM work_issue_reports wir
@@ -313,7 +314,7 @@ const getAllReports = async (filters = {}, callback) => {
}
if (filters.category_type) {
query += ` AND irc.category_type = ?`;
query += ` AND wir.category_type = ?`;
params.push(filters.category_type);
}
@@ -394,7 +395,7 @@ const getReportById = async (reportId, callback) => {
u.username as reporter_name, u.name as reporter_full_name,
wc.category_name as factory_name,
w.workplace_name,
irc.category_type, irc.category_name as issue_category_name,
wir.category_type, irc.category_type as original_category_type, irc.category_name as issue_category_name,
iri.item_name as issue_item_name, iri.severity,
assignee.username as assigned_user_name, assignee.name as assigned_full_name,
assigner.username as assigned_by_name,
@@ -783,25 +784,30 @@ const getStatsSummary = async (filters = {}, callback) => {
let whereClause = '1=1';
const params = [];
if (filters.category_type) {
whereClause += ` AND wir.category_type = ?`;
params.push(filters.category_type);
}
if (filters.start_date && filters.end_date) {
whereClause += ` AND DATE(report_date) BETWEEN ? AND ?`;
whereClause += ` AND DATE(wir.report_date) BETWEEN ? AND ?`;
params.push(filters.start_date, filters.end_date);
}
if (filters.factory_category_id) {
whereClause += ` AND factory_category_id = ?`;
whereClause += ` AND wir.factory_category_id = ?`;
params.push(filters.factory_category_id);
}
const [rows] = await db.query(
`SELECT
COUNT(*) as total,
SUM(CASE WHEN status = 'reported' THEN 1 ELSE 0 END) as reported,
SUM(CASE WHEN status = 'received' THEN 1 ELSE 0 END) as received,
SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) as in_progress,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
SUM(CASE WHEN status = 'closed' THEN 1 ELSE 0 END) as closed
FROM work_issue_reports
SUM(CASE WHEN wir.status = 'reported' THEN 1 ELSE 0 END) as reported,
SUM(CASE WHEN wir.status = 'received' THEN 1 ELSE 0 END) as received,
SUM(CASE WHEN wir.status = 'in_progress' THEN 1 ELSE 0 END) as in_progress,
SUM(CASE WHEN wir.status = 'completed' THEN 1 ELSE 0 END) as completed,
SUM(CASE WHEN wir.status = 'closed' THEN 1 ELSE 0 END) as closed
FROM work_issue_reports wir
WHERE ${whereClause}`,
params
);
@@ -885,6 +891,86 @@ const getStatsByWorkplace = async (filters = {}, callback) => {
}
};
/**
* 유형 이관 (category_type 변경)
*/
const transferCategoryType = async (reportId, newCategoryType, userId, callback) => {
try {
const db = await getDb();
// 기존 데이터 조회
const [existing] = await db.query(
`SELECT report_id, category_type, modification_history FROM work_issue_reports WHERE report_id = ?`,
[reportId]
);
if (existing.length === 0) {
return callback(new Error('신고를 찾을 수 없습니다.'));
}
const current = existing[0];
const oldCategoryType = current.category_type;
if (oldCategoryType === newCategoryType) {
return callback(new Error('현재 유형과 동일합니다.'));
}
// 수정 이력 추가
const existingHistory = current.modification_history ? JSON.parse(current.modification_history) : [];
const now = new Date().toISOString();
existingHistory.push({
field: 'category_type',
old_value: oldCategoryType,
new_value: newCategoryType,
modified_at: now,
modified_by: userId
});
// category_type 업데이트
const [result] = await db.query(
`UPDATE work_issue_reports
SET category_type = ?, modification_history = ?, updated_at = NOW()
WHERE report_id = ?`,
[newCategoryType, JSON.stringify(existingHistory), reportId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 공장/작업장 이름 조회 (System 3 연동용)
*/
const getLocationNames = async (factoryCategoryId, workplaceId, callback) => {
try {
const db = await getDb();
let factoryName = null;
let workplaceName = null;
if (factoryCategoryId) {
const [rows] = await db.query(
`SELECT category_name FROM workplace_categories WHERE category_id = ?`,
[factoryCategoryId]
);
if (rows.length > 0) factoryName = rows[0].category_name;
}
if (workplaceId) {
const [rows] = await db.query(
`SELECT workplace_name FROM workplaces WHERE workplace_id = ?`,
[workplaceId]
);
if (rows.length > 0) workplaceName = rows[0].workplace_name;
}
callback(null, { factory_name: factoryName, workplace_name: workplaceName });
} catch (err) {
callback(err);
}
};
module.exports = {
// 카테고리
getAllCategories,
@@ -910,6 +996,10 @@ module.exports = {
// System 3 연동
updateMProjectId,
getLocationNames,
// 유형 이관
transferCategoryType,
// 상태 관리
receiveReport,

View File

@@ -69,6 +69,11 @@ router.put('/:id', workIssueController.updateReport);
// 신고 삭제
router.delete('/:id', workIssueController.deleteReport);
// ==================== 유형 이관 ====================
// 유형 이관 (인증 필요)
router.put('/:id/transfer', workIssueController.transferReport);
// ==================== 상태 관리 ====================
// 신고 접수 (support_team 이상)

View File

@@ -186,6 +186,7 @@ async function sendToMProject(issueData) {
project_name,
project_id = null,
tk_issue_id,
location_info = null,
photos = [],
ssoToken = null,
} = issueData;
@@ -207,13 +208,9 @@ async function sendToMProject(issueData) {
// 카테고리 매핑
const mProjectCategory = mapCategoryToMProject(category);
// 설명에 TK-FB 정보 추가
// 설명에 신고자 정보 추가
const enhancedDescription = [
description,
'',
'---',
`[TK-FB-Project 연동]`,
`- 원본 이슈 ID: ${tk_issue_id}`,
`- 신고자: ${reporter_name || '미상'}`,
project_name ? `- 프로젝트: ${project_name}` : null,
].filter(Boolean).join('\n');
@@ -229,6 +226,10 @@ async function sendToMProject(issueData) {
project_id: project_id || M_PROJECT_CONFIG.defaultProjectId,
};
if (location_info) {
requestBody.location_info = location_info;
}
// 사진 추가
base64Photos.forEach((photo, index) => {
if (photo) {