refactor: TBM/작업보고 코드 통합 및 API 쿼리 버그 수정

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-03-05 07:51:24 +09:00
parent 22a37ac4d9
commit 4388628788
89 changed files with 5296 additions and 5046 deletions

View File

@@ -0,0 +1,933 @@
/**
* 작업 중 문제 신고 모델
* 부적합/안전 신고 관련 DB 쿼리
*/
const { getDb } = require('../dbPool');
// ==================== 신고 카테고리 관리 ====================
/**
* 모든 신고 카테고리 조회
*/
const getAllCategories = async (callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT category_id, category_type, category_name, description, display_order, is_active, created_at
FROM issue_report_categories
ORDER BY category_type, display_order, category_id`
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 타입별 활성 카테고리 조회 (nonconformity/safety)
*/
const getCategoriesByType = async (categoryType, callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT category_id, category_type, category_name, description, display_order
FROM issue_report_categories
WHERE category_type = ? AND is_active = TRUE
ORDER BY display_order, category_id`,
[categoryType]
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 카테고리 생성
*/
const createCategory = async (categoryData, callback) => {
try {
const db = await getDb();
const { category_type, category_name, description = null, display_order = 0 } = categoryData;
const [result] = await db.query(
`INSERT INTO issue_report_categories (category_type, category_name, description, display_order)
VALUES (?, ?, ?, ?)`,
[category_type, category_name, description, display_order]
);
callback(null, result.insertId);
} catch (err) {
callback(err);
}
};
/**
* 카테고리 수정
*/
const updateCategory = async (categoryId, categoryData, callback) => {
try {
const db = await getDb();
const { category_name, description, display_order, is_active } = categoryData;
const [result] = await db.query(
`UPDATE issue_report_categories
SET category_name = ?, description = ?, display_order = ?, is_active = ?
WHERE category_id = ?`,
[category_name, description, display_order, is_active, categoryId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 카테고리 삭제
*/
const deleteCategory = async (categoryId, callback) => {
try {
const db = await getDb();
const [result] = await db.query(
`DELETE FROM issue_report_categories WHERE category_id = ?`,
[categoryId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
// ==================== 사전 정의 신고 항목 관리 ====================
/**
* 카테고리별 활성 항목 조회
*/
const getItemsByCategory = async (categoryId, callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT item_id, category_id, item_name, description, severity, display_order
FROM issue_report_items
WHERE category_id = ? AND is_active = TRUE
ORDER BY display_order, item_id`,
[categoryId]
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 모든 항목 조회 (관리용)
*/
const getAllItems = async (callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT iri.item_id, iri.category_id, iri.item_name, iri.description,
iri.severity, iri.display_order, iri.is_active, iri.created_at,
irc.category_name, irc.category_type
FROM issue_report_items iri
INNER JOIN issue_report_categories irc ON iri.category_id = irc.category_id
ORDER BY irc.category_type, irc.display_order, iri.display_order, iri.item_id`
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 항목 생성
*/
const createItem = async (itemData, callback) => {
try {
const db = await getDb();
const { category_id, item_name, description = null, severity = 'medium', display_order = 0 } = itemData;
const [result] = await db.query(
`INSERT INTO issue_report_items (category_id, item_name, description, severity, display_order)
VALUES (?, ?, ?, ?, ?)`,
[category_id, item_name, description, severity, display_order]
);
callback(null, result.insertId);
} catch (err) {
callback(err);
}
};
/**
* 항목 수정
*/
const updateItem = async (itemId, itemData, callback) => {
try {
const db = await getDb();
const { item_name, description, severity, display_order, is_active } = itemData;
const [result] = await db.query(
`UPDATE issue_report_items
SET item_name = ?, description = ?, severity = ?, display_order = ?, is_active = ?
WHERE item_id = ?`,
[item_name, description, severity, display_order, is_active, itemId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 항목 삭제
*/
const deleteItem = async (itemId, callback) => {
try {
const db = await getDb();
const [result] = await db.query(
`DELETE FROM issue_report_items WHERE item_id = ?`,
[itemId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 카테고리 ID로 단건 조회
*/
const getCategoryById = async (categoryId, callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT category_id, category_type, category_name, description
FROM issue_report_categories
WHERE category_id = ?`,
[categoryId]
);
callback(null, rows[0] || null);
} catch (err) {
callback(err);
}
};
// ==================== 문제 신고 관리 ====================
// 한국 시간 유틸리티 import
const { getKoreaDatetime } = require('../utils/dateUtils');
/**
* 신고 생성
*/
const createReport = async (reportData, callback) => {
try {
const db = await getDb();
const {
reporter_id,
factory_category_id = null,
workplace_id = null,
project_id = null,
custom_location = null,
tbm_session_id = null,
visit_request_id = null,
issue_category_id,
issue_item_id = null,
additional_description = null,
photo_path1 = null,
photo_path2 = null,
photo_path3 = null,
photo_path4 = null,
photo_path5 = null
} = reportData;
// 한국 시간 기준으로 신고 일시 설정
const reportDate = getKoreaDatetime();
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,
additional_description, photo_path1, photo_path2, photo_path3, photo_path4, photo_path5)
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,
additional_description, photo_path1, photo_path2, photo_path3, photo_path4, photo_path5]
);
// 상태 변경 로그 기록
await db.query(
`INSERT INTO work_issue_status_logs (report_id, previous_status, new_status, changed_by)
VALUES (?, NULL, 'reported', ?)`,
[result.insertId, reporter_id]
);
callback(null, result.insertId);
} catch (err) {
callback(err);
}
};
/**
* 신고 목록 조회 (필터 옵션 포함)
*/
const getAllReports = async (filters = {}, callback) => {
try {
const db = await getDb();
let query = `
SELECT
wir.report_id, wir.reporter_id, wir.report_date,
wir.factory_category_id, wir.workplace_id, wir.custom_location,
wir.tbm_session_id, wir.visit_request_id,
wir.issue_category_id, wir.issue_item_id, wir.additional_description,
wir.photo_path1, wir.photo_path2, wir.photo_path3, wir.photo_path4, wir.photo_path5,
wir.status, wir.assigned_department, wir.assigned_user_id, wir.assigned_at,
wir.resolution_notes, wir.resolved_at,
wir.created_at, wir.updated_at,
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,
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
INNER JOIN users u ON wir.reporter_id = u.user_id
LEFT JOIN workplace_categories wc ON wir.factory_category_id = wc.category_id
LEFT JOIN workplaces w ON wir.workplace_id = w.workplace_id
INNER JOIN issue_report_categories irc ON wir.issue_category_id = irc.category_id
LEFT JOIN issue_report_items iri ON wir.issue_item_id = iri.item_id
LEFT JOIN users assignee ON wir.assigned_user_id = assignee.user_id
WHERE 1=1
`;
const params = [];
// 필터 적용
if (filters.status) {
query += ` AND wir.status = ?`;
params.push(filters.status);
}
if (filters.category_type) {
query += ` AND irc.category_type = ?`;
params.push(filters.category_type);
}
if (filters.issue_category_id) {
query += ` AND wir.issue_category_id = ?`;
params.push(filters.issue_category_id);
}
if (filters.factory_category_id) {
query += ` AND wir.factory_category_id = ?`;
params.push(filters.factory_category_id);
}
if (filters.workplace_id) {
query += ` AND wir.workplace_id = ?`;
params.push(filters.workplace_id);
}
if (filters.reporter_id) {
query += ` AND wir.reporter_id = ?`;
params.push(filters.reporter_id);
}
if (filters.assigned_user_id) {
query += ` AND wir.assigned_user_id = ?`;
params.push(filters.assigned_user_id);
}
if (filters.start_date && filters.end_date) {
query += ` AND DATE(wir.report_date) BETWEEN ? AND ?`;
params.push(filters.start_date, filters.end_date);
}
if (filters.search) {
query += ` AND (wir.additional_description LIKE ? OR iri.item_name LIKE ? OR wir.custom_location LIKE ?)`;
const searchTerm = `%${filters.search}%`;
params.push(searchTerm, searchTerm, searchTerm);
}
query += ` ORDER BY wir.report_date DESC, wir.report_id DESC`;
// 페이지네이션
if (filters.limit) {
query += ` LIMIT ?`;
params.push(parseInt(filters.limit));
if (filters.offset) {
query += ` OFFSET ?`;
params.push(parseInt(filters.offset));
}
}
const [rows] = await db.query(query, params);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 신고 상세 조회
*/
const getReportById = async (reportId, callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT
wir.report_id, wir.reporter_id, wir.report_date,
wir.factory_category_id, wir.workplace_id, wir.custom_location,
wir.tbm_session_id, wir.visit_request_id,
wir.issue_category_id, wir.issue_item_id, wir.additional_description,
wir.photo_path1, wir.photo_path2, wir.photo_path3, wir.photo_path4, wir.photo_path5,
wir.status, wir.assigned_department, wir.assigned_user_id, wir.assigned_at, wir.assigned_by,
wir.resolution_notes, wir.resolution_photo_path1, wir.resolution_photo_path2,
wir.resolved_at, wir.resolved_by,
wir.modification_history,
wir.created_at, wir.updated_at,
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,
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,
resolver.username as resolved_by_name
FROM work_issue_reports wir
INNER JOIN users u ON wir.reporter_id = u.user_id
LEFT JOIN workplace_categories wc ON wir.factory_category_id = wc.category_id
LEFT JOIN workplaces w ON wir.workplace_id = w.workplace_id
INNER JOIN issue_report_categories irc ON wir.issue_category_id = irc.category_id
LEFT JOIN issue_report_items iri ON wir.issue_item_id = iri.item_id
LEFT JOIN users assignee ON wir.assigned_user_id = assignee.user_id
LEFT JOIN users assigner ON wir.assigned_by = assigner.user_id
LEFT JOIN users resolver ON wir.resolved_by = resolver.user_id
WHERE wir.report_id = ?`,
[reportId]
);
callback(null, rows[0]);
} catch (err) {
callback(err);
}
};
/**
* 신고 수정
*/
const updateReport = async (reportId, reportData, userId, callback) => {
try {
const db = await getDb();
// 기존 데이터 조회
const [existing] = await db.query(
`SELECT * FROM work_issue_reports WHERE report_id = ?`,
[reportId]
);
if (existing.length === 0) {
return callback(new Error('신고를 찾을 수 없습니다.'));
}
const current = existing[0];
// 수정 이력 생성
const modifications = [];
const now = new Date().toISOString();
for (const key of Object.keys(reportData)) {
if (current[key] !== reportData[key] && reportData[key] !== undefined) {
modifications.push({
field: key,
old_value: current[key],
new_value: reportData[key],
modified_at: now,
modified_by: userId
});
}
}
// 기존 이력과 병합
const existingHistory = current.modification_history ? JSON.parse(current.modification_history) : [];
const newHistory = [...existingHistory, ...modifications];
const {
factory_category_id,
workplace_id,
custom_location,
issue_category_id,
issue_item_id,
additional_description,
photo_path1,
photo_path2,
photo_path3,
photo_path4,
photo_path5
} = reportData;
const [result] = await db.query(
`UPDATE work_issue_reports
SET factory_category_id = COALESCE(?, factory_category_id),
workplace_id = COALESCE(?, workplace_id),
custom_location = COALESCE(?, custom_location),
issue_category_id = COALESCE(?, issue_category_id),
issue_item_id = COALESCE(?, issue_item_id),
additional_description = COALESCE(?, additional_description),
photo_path1 = COALESCE(?, photo_path1),
photo_path2 = COALESCE(?, photo_path2),
photo_path3 = COALESCE(?, photo_path3),
photo_path4 = COALESCE(?, photo_path4),
photo_path5 = COALESCE(?, photo_path5),
modification_history = ?,
updated_at = NOW()
WHERE report_id = ?`,
[factory_category_id, workplace_id, custom_location,
issue_category_id, issue_item_id, additional_description,
photo_path1, photo_path2, photo_path3, photo_path4, photo_path5,
JSON.stringify(newHistory), reportId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 신고 삭제
*/
const deleteReport = async (reportId, callback) => {
try {
const db = await getDb();
// 먼저 사진 경로 조회 (삭제용)
const [photos] = await db.query(
`SELECT photo_path1, photo_path2, photo_path3, photo_path4, photo_path5,
resolution_photo_path1, resolution_photo_path2
FROM work_issue_reports WHERE report_id = ?`,
[reportId]
);
const [result] = await db.query(
`DELETE FROM work_issue_reports WHERE report_id = ?`,
[reportId]
);
// 삭제할 사진 경로 반환
callback(null, { result, photos: photos[0] });
} catch (err) {
callback(err);
}
};
// ==================== 상태 관리 ====================
/**
* 신고 접수 (reported → received)
*/
const receiveReport = async (reportId, userId, callback) => {
try {
const db = await getDb();
// 현재 상태 확인
const [current] = await db.query(
`SELECT status FROM work_issue_reports WHERE report_id = ?`,
[reportId]
);
if (current.length === 0) {
return callback(new Error('신고를 찾을 수 없습니다.'));
}
if (current[0].status !== 'reported') {
return callback(new Error('접수 대기 상태가 아닙니다.'));
}
const [result] = await db.query(
`UPDATE work_issue_reports
SET status = 'received', updated_at = NOW()
WHERE report_id = ?`,
[reportId]
);
// 상태 변경 로그
await db.query(
`INSERT INTO work_issue_status_logs (report_id, previous_status, new_status, changed_by)
VALUES (?, 'reported', 'received', ?)`,
[reportId, userId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 담당자 배정
*/
const assignReport = async (reportId, assignData, callback) => {
try {
const db = await getDb();
const { assigned_department, assigned_user_id, assigned_by } = assignData;
// 현재 상태 확인
const [current] = await db.query(
`SELECT status FROM work_issue_reports WHERE report_id = ?`,
[reportId]
);
if (current.length === 0) {
return callback(new Error('신고를 찾을 수 없습니다.'));
}
// 접수 상태 이상이어야 배정 가능
const validStatuses = ['received', 'in_progress'];
if (!validStatuses.includes(current[0].status)) {
return callback(new Error('접수된 상태에서만 담당자 배정이 가능합니다.'));
}
const [result] = await db.query(
`UPDATE work_issue_reports
SET assigned_department = ?, assigned_user_id = ?,
assigned_at = NOW(), assigned_by = ?, updated_at = NOW()
WHERE report_id = ?`,
[assigned_department, assigned_user_id, assigned_by, reportId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 처리 시작 (received → in_progress)
*/
const startProcessing = async (reportId, userId, callback) => {
try {
const db = await getDb();
// 현재 상태 확인
const [current] = await db.query(
`SELECT status FROM work_issue_reports WHERE report_id = ?`,
[reportId]
);
if (current.length === 0) {
return callback(new Error('신고를 찾을 수 없습니다.'));
}
if (current[0].status !== 'received') {
return callback(new Error('접수된 상태에서만 처리를 시작할 수 있습니다.'));
}
const [result] = await db.query(
`UPDATE work_issue_reports
SET status = 'in_progress', updated_at = NOW()
WHERE report_id = ?`,
[reportId]
);
// 상태 변경 로그
await db.query(
`INSERT INTO work_issue_status_logs (report_id, previous_status, new_status, changed_by)
VALUES (?, 'received', 'in_progress', ?)`,
[reportId, userId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 처리 완료 (in_progress → completed)
*/
const completeReport = async (reportId, completionData, callback) => {
try {
const db = await getDb();
const { resolution_notes, resolution_photo_path1, resolution_photo_path2, resolved_by } = completionData;
// 현재 상태 확인
const [current] = await db.query(
`SELECT status FROM work_issue_reports WHERE report_id = ?`,
[reportId]
);
if (current.length === 0) {
return callback(new Error('신고를 찾을 수 없습니다.'));
}
if (current[0].status !== 'in_progress') {
return callback(new Error('처리 중 상태에서만 완료할 수 있습니다.'));
}
const [result] = await db.query(
`UPDATE work_issue_reports
SET status = 'completed', resolution_notes = ?,
resolution_photo_path1 = ?, resolution_photo_path2 = ?,
resolved_at = NOW(), resolved_by = ?, updated_at = NOW()
WHERE report_id = ?`,
[resolution_notes, resolution_photo_path1, resolution_photo_path2, resolved_by, reportId]
);
// 상태 변경 로그
await db.query(
`INSERT INTO work_issue_status_logs (report_id, previous_status, new_status, changed_by, change_reason)
VALUES (?, 'in_progress', 'completed', ?, ?)`,
[reportId, resolved_by, resolution_notes]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 신고 종료 (completed → closed)
*/
const closeReport = async (reportId, userId, callback) => {
try {
const db = await getDb();
// 현재 상태 확인
const [current] = await db.query(
`SELECT status FROM work_issue_reports WHERE report_id = ?`,
[reportId]
);
if (current.length === 0) {
return callback(new Error('신고를 찾을 수 없습니다.'));
}
if (current[0].status !== 'completed') {
return callback(new Error('완료된 상태에서만 종료할 수 있습니다.'));
}
const [result] = await db.query(
`UPDATE work_issue_reports
SET status = 'closed', updated_at = NOW()
WHERE report_id = ?`,
[reportId]
);
// 상태 변경 로그
await db.query(
`INSERT INTO work_issue_status_logs (report_id, previous_status, new_status, changed_by)
VALUES (?, 'completed', 'closed', ?)`,
[reportId, userId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 상태 변경 이력 조회
*/
const getStatusLogs = async (reportId, callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT wisl.log_id, wisl.report_id, wisl.previous_status, wisl.new_status,
wisl.changed_by, wisl.change_reason, wisl.changed_at,
u.username as changed_by_name, u.name as changed_by_full_name
FROM work_issue_status_logs wisl
INNER JOIN users u ON wisl.changed_by = u.user_id
WHERE wisl.report_id = ?
ORDER BY wisl.changed_at ASC`,
[reportId]
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* m_project_id 업데이트 (System 3 연동 후)
*/
const updateMProjectId = async (reportId, mProjectId, callback) => {
try {
const db = await getDb();
await db.query(
`UPDATE work_issue_reports SET m_project_id = ? WHERE report_id = ?`,
[mProjectId, reportId]
);
callback(null);
} catch (err) {
callback(err);
}
};
// ==================== 통계 ====================
/**
* 신고 통계 요약
*/
const getStatsSummary = async (filters = {}, callback) => {
try {
const db = await getDb();
let whereClause = '1=1';
const params = [];
let joinClause = '';
if (filters.category_type) {
joinClause = ' INNER JOIN issue_report_categories irc ON wir.issue_category_id = irc.category_id';
whereClause += ` AND irc.category_type = ?`;
params.push(filters.category_type);
}
if (filters.start_date && filters.end_date) {
whereClause += ` AND DATE(wir.report_date) BETWEEN ? AND ?`;
params.push(filters.start_date, filters.end_date);
}
if (filters.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 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${joinClause}
WHERE ${whereClause}`,
params
);
callback(null, rows[0]);
} catch (err) {
callback(err);
}
};
/**
* 카테고리별 통계
*/
const getStatsByCategory = async (filters = {}, callback) => {
try {
const db = await getDb();
let whereClause = '1=1';
const params = [];
if (filters.start_date && filters.end_date) {
whereClause += ` AND DATE(wir.report_date) BETWEEN ? AND ?`;
params.push(filters.start_date, filters.end_date);
}
const [rows] = await db.query(
`SELECT
irc.category_type, irc.category_name,
COUNT(*) as count
FROM work_issue_reports wir
INNER JOIN issue_report_categories irc ON wir.issue_category_id = irc.category_id
WHERE ${whereClause}
GROUP BY irc.category_id
ORDER BY irc.category_type, count DESC`,
params
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 작업장별 통계
*/
const getStatsByWorkplace = async (filters = {}, callback) => {
try {
const db = await getDb();
let whereClause = 'wir.workplace_id IS NOT NULL';
const params = [];
if (filters.start_date && filters.end_date) {
whereClause += ` AND DATE(wir.report_date) BETWEEN ? AND ?`;
params.push(filters.start_date, filters.end_date);
}
if (filters.factory_category_id) {
whereClause += ` AND wir.factory_category_id = ?`;
params.push(filters.factory_category_id);
}
const [rows] = await db.query(
`SELECT
wir.factory_category_id, wc.category_name as factory_name,
wir.workplace_id, w.workplace_name,
COUNT(*) as count
FROM work_issue_reports wir
INNER JOIN workplace_categories wc ON wir.factory_category_id = wc.category_id
INNER JOIN workplaces w ON wir.workplace_id = w.workplace_id
WHERE ${whereClause}
GROUP BY wir.factory_category_id, wir.workplace_id
ORDER BY count DESC`,
params
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
module.exports = {
// 카테고리
getAllCategories,
getCategoriesByType,
getCategoryById,
createCategory,
updateCategory,
deleteCategory,
// 항목
getItemsByCategory,
getAllItems,
createItem,
updateItem,
deleteItem,
// 신고
createReport,
getAllReports,
getReportById,
updateReport,
deleteReport,
// System 3 연동
updateMProjectId,
// 상태 관리
receiveReport,
assignReport,
startProcessing,
completeReport,
closeReport,
getStatusLogs,
// 통계
getStatsSummary,
getStatsByCategory,
getStatsByWorkplace
};

View File

@@ -1,240 +0,0 @@
/**
* 내 신고 현황 페이지 JavaScript
* 전체 유형(안전/시설설비/부적합) 통합 목록
*/
const API_BASE = window.API_BASE_URL || 'http://localhost:30005/api';
// 상태 한글 변환
const STATUS_LABELS = {
reported: '신고',
received: '접수',
in_progress: '처리중',
completed: '완료',
closed: '종료'
};
// 유형 한글 변환
const TYPE_LABELS = {
safety: '안전',
facility: '시설설비',
nonconformity: '부적합'
};
// 유형별 배지 CSS 클래스
const TYPE_BADGE_CLASS = {
safety: 'type-badge-safety',
facility: 'type-badge-facility',
nonconformity: 'type-badge-nonconformity'
};
// DOM 요소
let issueList;
let filterType, filterStatus, filterStartDate, filterEndDate;
// 초기화
document.addEventListener('DOMContentLoaded', async () => {
issueList = document.getElementById('issueList');
filterType = document.getElementById('filterType');
filterStatus = document.getElementById('filterStatus');
filterStartDate = document.getElementById('filterStartDate');
filterEndDate = document.getElementById('filterEndDate');
// 필터 이벤트 리스너
filterType.addEventListener('change', loadIssues);
filterStatus.addEventListener('change', loadIssues);
filterStartDate.addEventListener('change', loadIssues);
filterEndDate.addEventListener('change', loadIssues);
// 데이터 로드
await loadIssues();
});
/**
* 클라이언트 사이드 통계 계산
*/
function computeStats(issues) {
const stats = { reported: 0, received: 0, in_progress: 0, completed: 0 };
issues.forEach(issue => {
if (stats.hasOwnProperty(issue.status)) {
stats[issue.status]++;
}
});
document.getElementById('statReported').textContent = stats.reported;
document.getElementById('statReceived').textContent = stats.received;
document.getElementById('statProgress').textContent = stats.in_progress;
document.getElementById('statCompleted').textContent = stats.completed;
}
/**
* 신고 목록 로드 (전체 유형)
*/
async function loadIssues() {
try {
const params = new URLSearchParams();
// 유형 필터 (선택한 경우만)
if (filterType.value) {
params.append('category_type', filterType.value);
}
if (filterStatus.value) params.append('status', filterStatus.value);
if (filterStartDate.value) params.append('start_date', filterStartDate.value);
if (filterEndDate.value) params.append('end_date', filterEndDate.value);
const response = await fetch(`${API_BASE}/work-issues?${params.toString()}`, {
headers: { 'Authorization': `Bearer ${(window.getSSOToken ? window.getSSOToken() : localStorage.getItem('sso_token'))}` }
});
if (!response.ok) throw new Error('목록 조회 실패');
const data = await response.json();
if (data.success) {
const issues = data.data || [];
computeStats(issues);
renderIssues(issues);
}
} catch (error) {
console.error('신고 목록 로드 실패:', error);
issueList.innerHTML = `
<div class="empty-state">
<div class="empty-state-title">목록을 불러올 수 없습니다</div>
<p>잠시 후 다시 시도해주세요.</p>
</div>
`;
}
}
/**
* 신고 목록 렌더링
*/
function renderIssues(issues) {
if (issues.length === 0) {
issueList.innerHTML = `
<div class="empty-state">
<div class="empty-state-title">등록된 신고가 없습니다</div>
<p>새로운 문제를 신고하려면 '신고하기' 버튼을 클릭하세요.</p>
</div>
`;
return;
}
const baseUrl = (window.API_BASE_URL || 'http://localhost:30005').replace('/api', '');
issueList.innerHTML = issues.map(issue => {
const reportDate = new Date(issue.report_date).toLocaleString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
// 위치 정보 (escaped)
let location = escapeHtml(issue.custom_location || '');
if (issue.factory_name) {
location = escapeHtml(issue.factory_name);
if (issue.workplace_name) {
location += ` - ${escapeHtml(issue.workplace_name)}`;
}
}
// 신고 제목 (항목명 또는 카테고리명)
const title = escapeHtml(issue.issue_item_name || issue.issue_category_name || '신고');
const categoryName = escapeHtml(issue.issue_category_name || '');
// 유형 배지
const typeName = TYPE_LABELS[issue.category_type] || escapeHtml(issue.category_type || '');
const typeBadgeClass = TYPE_BADGE_CLASS[issue.category_type] || 'type-badge-safety';
// 사진 목록
const photos = [
issue.photo_path1,
issue.photo_path2,
issue.photo_path3,
issue.photo_path4,
issue.photo_path5
].filter(Boolean);
// 안전한 값들
const safeReportId = parseInt(issue.report_id) || 0;
const validStatuses = ['reported', 'received', 'in_progress', 'completed', 'closed'];
const safeStatus = validStatuses.includes(issue.status) ? issue.status : 'reported';
const reporterName = escapeHtml(issue.reporter_full_name || issue.reporter_name || '-');
const assignedName = issue.assigned_full_name ? escapeHtml(issue.assigned_full_name) : '';
return `
<div class="issue-card" onclick="viewIssue(${safeReportId})">
<div class="issue-header">
<span class="issue-id">
<span class="issue-category-badge ${typeBadgeClass}">${typeName}</span>
#${safeReportId}
</span>
<span class="issue-status ${safeStatus}">${STATUS_LABELS[issue.status] || escapeHtml(issue.status || '-')}</span>
</div>
<div class="issue-title">
${categoryName ? `<span class="issue-category-badge">${categoryName}</span>` : ''}
${title}
</div>
<div class="issue-meta">
<span class="issue-meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
${reporterName}
</span>
<span class="issue-meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
${reportDate}
</span>
${location ? `
<span class="issue-meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/>
<circle cx="12" cy="10" r="3"/>
</svg>
${location}
</span>
` : ''}
${assignedName ? `
<span class="issue-meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
담당: ${assignedName}
</span>
` : ''}
</div>
${photos.length > 0 ? `
<div class="issue-photos">
${photos.slice(0, 3).map(p => `
<img src="${baseUrl}${encodeURI(p)}" alt="신고 사진" loading="lazy">
`).join('')}
${photos.length > 3 ? `<span style="display: flex; align-items: center; color: var(--gray-500);">+${photos.length - 3}</span>` : ''}
</div>
` : ''}
</div>
`;
}).join('');
}
/**
* 상세 보기
*/
function viewIssue(reportId) {
window.location.href = `/pages/safety/issue-detail.html?id=${reportId}&from=my-reports`;
}

View File

@@ -1,327 +0,0 @@
<!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/common.css?v=2">
<link rel="stylesheet" href="/css/project-management.css?v=3">
<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>
/* 통계 카드 */
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-card {
background: white;
padding: 1.25rem;
border-radius: 0.75rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
text-align: center;
}
.stat-number {
font-size: 2rem;
font-weight: 700;
margin-bottom: 0.25rem;
}
.stat-label {
font-size: 0.875rem;
color: #6b7280;
}
.stat-card.reported .stat-number { color: #3b82f6; }
.stat-card.received .stat-number { color: #f97316; }
.stat-card.in_progress .stat-number { color: #8b5cf6; }
.stat-card.completed .stat-number { color: #10b981; }
/* 필터 바 */
.filter-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.5rem;
padding: 1rem 1.25rem;
background: white;
border-radius: 0.75rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.filter-bar select,
.filter-bar input {
padding: 0.625rem 0.875rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
font-size: 0.875rem;
background: white;
}
.filter-bar select:focus,
.filter-bar input:focus {
outline: none;
border-color: #ef4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
}
.btn-new-report {
margin-left: auto;
padding: 0.625rem 1.25rem;
background: #ef4444;
color: white;
border: none;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.5rem;
transition: background 0.2s;
}
.btn-new-report:hover {
background: #dc2626;
}
/* 신고 목록 */
.issue-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.issue-card {
background: white;
border-radius: 0.75rem;
padding: 1.25rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
cursor: pointer;
transition: all 0.2s;
border: 1px solid transparent;
}
.issue-card:hover {
border-color: #fecaca;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.issue-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.75rem;
}
.issue-id {
font-size: 0.875rem;
color: #9ca3af;
}
.issue-status {
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
}
.issue-status.reported {
background: #dbeafe;
color: #1d4ed8;
}
.issue-status.received {
background: #fed7aa;
color: #c2410c;
}
.issue-status.in_progress {
background: #e9d5ff;
color: #7c3aed;
}
.issue-status.completed {
background: #d1fae5;
color: #047857;
}
.issue-status.closed {
background: #f3f4f6;
color: #4b5563;
}
.issue-title {
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.5rem;
color: #1f2937;
}
.issue-category-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
margin-right: 0.5rem;
background: #fef2f2;
color: #b91c1c;
}
/* 유형별 배지 색상 */
.type-badge-safety {
background: #fef2f2;
color: #b91c1c;
}
.type-badge-facility {
background: #eff6ff;
color: #1d4ed8;
}
.type-badge-nonconformity {
background: #fefce8;
color: #a16207;
}
.issue-meta {
display: flex;
flex-wrap: wrap;
gap: 1rem;
font-size: 0.875rem;
color: #6b7280;
}
.issue-meta-item {
display: flex;
align-items: center;
gap: 0.375rem;
}
.issue-photos {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
}
.issue-photos img {
width: 56px;
height: 56px;
object-fit: cover;
border-radius: 0.375rem;
border: 1px solid #e5e7eb;
}
/* 빈 상태 */
.empty-state {
text-align: center;
padding: 4rem 1.5rem;
color: #6b7280;
background: white;
border-radius: 0.75rem;
}
.empty-state-title {
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 0.5rem;
color: #374151;
}
@media (max-width: 768px) {
.filter-bar {
flex-direction: column;
align-items: stretch;
}
.btn-new-report {
width: 100%;
justify-content: center;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>
</head>
<body>
<div class="work-report-container">
<div id="navbar-container"></div>
<main class="work-report-main">
<div class="dashboard-main">
<div class="page-header">
<div class="page-title-section">
<h1 class="page-title">내 신고 현황</h1>
<p class="page-description">내가 신고한 안전, 시설설비, 부적합 현황을 확인합니다.</p>
</div>
</div>
<!-- 통계 카드 -->
<div class="stats-grid" id="statsGrid">
<div class="stat-card reported">
<div class="stat-number" id="statReported">-</div>
<div class="stat-label">신고</div>
</div>
<div class="stat-card received">
<div class="stat-number" id="statReceived">-</div>
<div class="stat-label">접수</div>
</div>
<div class="stat-card in_progress">
<div class="stat-number" id="statProgress">-</div>
<div class="stat-label">처리중</div>
</div>
<div class="stat-card completed">
<div class="stat-number" id="statCompleted">-</div>
<div class="stat-label">완료</div>
</div>
</div>
<!-- 필터 바 -->
<div class="filter-bar">
<select id="filterType">
<option value="">전체 유형</option>
<option value="safety">안전</option>
<option value="facility">시설설비</option>
<option value="nonconformity">부적합</option>
</select>
<select id="filterStatus">
<option value="">전체 상태</option>
<option value="reported">신고</option>
<option value="received">접수</option>
<option value="in_progress">처리중</option>
<option value="completed">완료</option>
<option value="closed">종료</option>
</select>
<input type="date" id="filterStartDate" title="시작일">
<input type="date" id="filterEndDate" title="종료일">
<a href="/pages/safety/issue-report.html" class="btn-new-report">+ 신고하기</a>
</div>
<!-- 신고 목록 -->
<div class="issue-list" id="issueList">
<div class="empty-state">
<div class="empty-state-title">로딩 중...</div>
</div>
</div>
</div>
</main>
</div>
<script src="/js/my-report-list.js?v=1"></script>
</body>
</html>