feat: 모바일 신고 시스템 구축 + tkqc 연동 + tkuser 이슈유형 관리

- tkreport 모바일 신고 페이지 (5단계 위자드: 유형→위치→프로젝트→항목→사진)
- 프로젝트 DB 연동 (아코디언 UI: TBM등록/활성프로젝트/모름)
- 클라이언트 이미지 리사이징 (1280px, JPEG 80%)
- nginx client_max_body_size 50m, /api/projects/ 프록시 추가
- 부적합 신고 → tkqc 자동 연동 (사진 base64 전달, SSO 토큰 유지)
- work_issue_reports에 project_id 컬럼 추가
- imageUploadService 경로 수정 (public/uploads → uploads, Docker 볼륨 일치)
- tkuser 이슈유형 탭, 휴가관리, nginx 프록시 업데이트
- tkqc 대시보드/수신함/관리함/폐기함 UI 업데이트
- system1 랜딩페이지 업데이트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-02-12 15:52:45 +09:00
parent 733bb0cb35
commit 234a6252c0
18 changed files with 1308 additions and 1208 deletions

View File

@@ -208,6 +208,7 @@ exports.createReport = async (req, res) => {
issue_category_id,
issue_item_id,
custom_item_name, // 직접 입력한 항목명
project_id,
additional_description,
photos = []
} = req.body;
@@ -275,6 +276,7 @@ exports.createReport = async (req, res) => {
reporter_id,
factory_category_id: factory_category_id || null,
workplace_id: workplace_id || null,
project_id: project_id || null,
custom_location: custom_location || null,
tbm_session_id: tbm_session_id || null,
visit_request_id: visit_request_id || null,
@@ -306,23 +308,35 @@ exports.createReport = async (req, res) => {
});
if (categoryInfo && categoryInfo.category_type === 'nonconformity') {
// 사진은 System 2에만 저장, URL 참조만 전달
const baseUrl = process.env.SYSTEM2_PUBLIC_URL || 'https://tkreport.technicalkorea.net';
const photoUrls = Object.values(photoPaths).filter(Boolean)
.map(p => `${baseUrl}/api/uploads/${p}`);
const descParts = [additional_description || categoryInfo.category_name];
if (photoUrls.length > 0) {
descParts.push('', '[첨부 사진]');
photoUrls.forEach((url, i) => descParts.push(`${i + 1}. ${url}`));
// 저장된 사진 파일을 base64로 읽어서 System 3에 전달
const fs = require('fs').promises;
const path = require('path');
const photoBase64List = [];
for (const p of Object.values(photoPaths)) {
if (!p) continue;
try {
const filePath = path.join(__dirname, '..', p);
const buf = await fs.readFile(filePath);
const b64 = `data:image/jpeg;base64,${buf.toString('base64')}`;
photoBase64List.push(b64);
} catch (readErr) {
console.error('사진 파일 읽기 실패:', p, readErr.message);
}
}
const descText = additional_description || categoryInfo.category_name;
// 원래 신고자의 SSO 토큰 추출
const originalToken = (req.headers['authorization'] || '').replace('Bearer ', '');
const result = await mProjectService.sendToMProject({
category: categoryInfo.category_name,
description: descParts.join('\n'),
description: descText,
reporter_name: req.user.name || req.user.username,
tk_issue_id: reportId,
photos: [] // 사진 복사 안 함 (URL 참조만)
project_id: project_id || null,
photos: photoBase64List,
ssoToken: originalToken
});
if (result.success && result.mProjectId) {
workIssueModel.updateMProjectId(reportId, result.mProjectId, () => {});

View File

@@ -231,6 +231,7 @@ const createReport = async (reportData, callback) => {
reporter_id,
factory_category_id = null,
workplace_id = null,
project_id = null,
custom_location = null,
tbm_session_id = null,
visit_request_id = null,
@@ -249,11 +250,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, custom_location,
(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, custom_location,
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]
);

View File

@@ -21,8 +21,8 @@ try {
// 업로드 디렉토리 설정
const UPLOAD_DIRS = {
issues: path.join(__dirname, '../public/uploads/issues'),
equipments: path.join(__dirname, '../public/uploads/equipments')
issues: path.join(__dirname, '../uploads/issues'),
equipments: path.join(__dirname, '../uploads/equipments')
};
const UPLOAD_DIR = UPLOAD_DIRS.issues; // 기존 호환성 유지
const MAX_SIZE = { width: 1920, height: 1920 };

View File

@@ -184,14 +184,21 @@ async function sendToMProject(issueData) {
description,
reporter_name,
project_name,
project_id = null,
tk_issue_id,
photos = [],
ssoToken = null,
} = issueData;
logger.info('M-Project 연동 시작', { tk_issue_id, category });
// 인증 토큰 획득
const token = await getAuthToken();
// SSO 토큰이 있으면 원래 사용자로 전송, 없으면 api_service 토큰
let token;
if (ssoToken) {
token = ssoToken;
} else {
token = await getAuthToken();
}
if (!token) {
return { success: false, error: 'M-Project 인증 실패' };
}
@@ -219,7 +226,7 @@ async function sendToMProject(issueData) {
const requestBody = {
category: mProjectCategory,
description: enhancedDescription,
project_id: M_PROJECT_CONFIG.defaultProjectId,
project_id: project_id || M_PROJECT_CONFIG.defaultProjectId,
};
// 사진 추가

View File

@@ -1,23 +1,26 @@
/**
* 신고 등록 페이지 JavaScript
* URL 파라미터 ?type=nonconformity 또는 ?type=safety로 유형 사전 선택 지원
* 흐름: 유형(1) → 위치(2) → 프로젝트(3) → 카테고리/항목(4) → 사진/상세(5) → 제출
* URL 파라미터 ?type=nonconformity|safety|facility 로 유형 사전 선택 지원
*/
// API 설정
const API_BASE = window.API_BASE_URL || 'http://localhost:30005/api';
const API_BASE = window.API_BASE_URL || 'http://localhost:30105/api';
// 상태 변수
let selectedFactoryId = null;
let selectedWorkplaceId = null;
let selectedWorkplaceName = null;
let selectedType = null; // 'nonconformity' | 'safety'
let selectedType = null; // 'nonconformity' | 'safety' | 'facility'
let selectedCategoryId = null;
let selectedCategoryName = null;
let selectedItemId = null;
let selectedProjectId = null; // 선택된 프로젝트 ID
let selectedTbmSessionId = null;
let selectedVisitRequestId = null;
let projectSelected = false; // 프로젝트 선택 완료 여부
let photos = [null, null, null, null, null];
let customItemName = null; // 직접 입력한 항목명
let customItemName = null;
// 지도 관련 변수
let canvas, ctx, canvasImage;
@@ -25,6 +28,9 @@ let mapRegions = [];
let todayWorkers = [];
let todayVisitors = [];
// 프로젝트 관련 변수
let allProjects = []; // 전체 활성 프로젝트 목록
// DOM 요소
let factorySelect, issueMapCanvas;
let photoInput, currentPhotoIndex;
@@ -38,16 +44,16 @@ document.addEventListener('DOMContentLoaded', async () => {
canvas = issueMapCanvas;
ctx = canvas.getContext('2d');
// 이벤트 리스너 설정
setupEventListeners();
// 공장 목록 로드
// 프로젝트 먼저 로드 후 공장 로드 (공장 로드 시 renderProjectList 호출하므로)
await loadProjects();
await loadFactories();
// URL 파라미터에서 유형 확인 및 자동 선택
const urlParams = new URLSearchParams(window.location.search);
const preselectedType = urlParams.get('type');
if (preselectedType === 'nonconformity' || preselectedType === 'safety') {
if (['nonconformity', 'safety', 'facility'].includes(preselectedType)) {
onTypeSelect(preselectedType);
}
});
@@ -56,24 +62,20 @@ document.addEventListener('DOMContentLoaded', async () => {
* 이벤트 리스너 설정
*/
function setupEventListeners() {
// 공장 선택
factorySelect.addEventListener('change', onFactoryChange);
// 지도 클릭
canvas.addEventListener('click', onMapClick);
// 기타 위치 토글
document.getElementById('useCustomLocation').addEventListener('change', (e) => {
const customInput = document.getElementById('customLocationInput');
customInput.classList.toggle('visible', e.target.checked);
if (e.target.checked) {
// 지도 선택 초기화
selectedWorkplaceId = null;
selectedWorkplaceName = null;
selectedTbmSessionId = null;
selectedVisitRequestId = null;
updateLocationInfo();
renderProjectList(); // 기타 위치에서도 프로젝트 목록 표시
}
});
@@ -105,6 +107,27 @@ function setupEventListeners() {
photoInput.addEventListener('change', onPhotoSelect);
}
/**
* 활성 프로젝트 목록 로드
*/
async function loadProjects() {
try {
const response = await fetch(`${API_BASE}/projects/active/list`, {
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 && data.data) {
allProjects = data.data;
}
} catch (error) {
console.error('프로젝트 목록 로드 실패:', error);
allProjects = [];
}
}
/**
* 공장 목록 로드
*/
@@ -125,7 +148,6 @@ async function loadFactories() {
factorySelect.appendChild(option);
});
// 첫 번째 공장 자동 선택
if (data.data.length > 0) {
factorySelect.value = data.data[0].category_id;
onFactoryChange();
@@ -143,14 +165,14 @@ async function onFactoryChange() {
selectedFactoryId = factorySelect.value;
if (!selectedFactoryId) return;
// 위치 선택 초기화
selectedWorkplaceId = null;
selectedWorkplaceName = null;
selectedTbmSessionId = null;
selectedVisitRequestId = null;
selectedProjectId = null;
projectSelected = false;
updateLocationInfo();
// 지도 데이터 로드
await Promise.all([
loadMapImage(),
loadMapRegions(),
@@ -158,6 +180,8 @@ async function onFactoryChange() {
]);
renderMap();
renderProjectList();
updateStepStatus();
}
/**
@@ -175,7 +199,7 @@ async function loadMapImage() {
if (data.success && data.data) {
const selectedCategory = data.data.find(c => c.category_id == selectedFactoryId);
if (selectedCategory && selectedCategory.layout_image) {
const baseUrl = (window.API_BASE_URL || 'http://localhost:30005').replace('/api', '');
const baseUrl = (window.API_BASE_URL || 'http://localhost:30105').replace('/api', '');
const fullImageUrl = selectedCategory.layout_image.startsWith('http')
? selectedCategory.layout_image
: `${baseUrl}${selectedCategory.layout_image}`;
@@ -214,17 +238,13 @@ async function loadMapRegions() {
* 오늘 TBM/출입신청 데이터 로드
*/
async function loadTodayData() {
// 로컬 시간대 기준으로 오늘 날짜 구하기 (UTC가 아닌 한국 시간 기준)
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const today = `${year}-${month}-${day}`;
console.log('[신고페이지] 조회 날짜 (로컬):', today);
try {
// TBM 세션 로드
const tbmResponse = await fetch(`${API_BASE}/tbm/sessions/date/${today}`, {
headers: { 'Authorization': `Bearer ${(window.getSSOToken ? window.getSSOToken() : localStorage.getItem('sso_token'))}` }
});
@@ -232,21 +252,13 @@ async function loadTodayData() {
if (tbmResponse.ok) {
const tbmData = await tbmResponse.json();
const sessions = tbmData.data || [];
// TBM 세션 데이터를 가공하여 member_count 계산
todayWorkers = sessions.map(session => {
const memberCount = session.team_member_count || 0;
const leaderCount = session.leader_id ? 1 : 0;
return {
...session,
member_count: memberCount + leaderCount
};
return { ...session, member_count: memberCount + leaderCount };
});
console.log('[신고페이지] 로드된 TBM 작업:', todayWorkers.length, '건');
}
// 출입 신청 로드
const visitResponse = await fetch(`${API_BASE}/workplace-visits/requests`, {
headers: { 'Authorization': `Bearer ${(window.getSSOToken ? window.getSSOToken() : localStorage.getItem('sso_token'))}` }
});
@@ -254,18 +266,14 @@ async function loadTodayData() {
if (visitResponse.ok) {
const visitData = await visitResponse.json();
todayVisitors = (visitData.data || []).filter(v => {
// 로컬 날짜로 비교
const visitDateObj = new Date(v.visit_date);
const visitYear = visitDateObj.getFullYear();
const visitMonth = String(visitDateObj.getMonth() + 1).padStart(2, '0');
const visitDay = String(visitDateObj.getDate()).padStart(2, '0');
const visitDate = `${visitYear}-${visitMonth}-${visitDay}`;
return visitDate === today &&
(v.status === 'approved' || v.status === 'training_completed');
});
console.log('[신고페이지] 로드된 방문자:', todayVisitors.length, '건');
}
} catch (error) {
console.error('오늘 데이터 로드 실패:', error);
@@ -273,7 +281,7 @@ async function loadTodayData() {
}
/**
* 둥근 모서리 사각형 그리기 (Canvas roundRect 폴리필)
* 둥근 모서리 사각형 그리기
*/
function drawRoundRect(ctx, x, y, width, height, radius) {
ctx.beginPath();
@@ -295,48 +303,34 @@ function drawRoundRect(ctx, x, y, width, height, radius) {
function renderMap() {
if (!canvas || !ctx) return;
// 컨테이너 너비 가져오기
const container = canvas.parentElement;
const containerWidth = container.clientWidth - 2; // border 고려
const containerWidth = container.clientWidth - 2;
const maxWidth = Math.min(containerWidth, 800);
// 이미지가 로드된 경우 이미지 비율에 맞춰 캔버스 크기 설정
if (canvasImage && canvasImage.complete && canvasImage.naturalWidth > 0) {
const imgWidth = canvasImage.naturalWidth;
const imgHeight = canvasImage.naturalHeight;
// 스케일 계산 (maxWidth에 맞춤)
const scale = imgWidth > maxWidth ? maxWidth / imgWidth : 1;
canvas.width = imgWidth * scale;
canvas.height = imgHeight * scale;
// 이미지 그리기
ctx.drawImage(canvasImage, 0, 0, canvas.width, canvas.height);
} else {
// 이미지가 없는 경우 기본 크기
canvas.width = maxWidth;
canvas.height = 400;
// 배경 그리기
canvas.height = 300;
ctx.fillStyle = '#f3f4f6';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 이미지 없음 안내
ctx.fillStyle = '#9ca3af';
ctx.font = '14px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('배치도 이미지가 없습니다', canvas.width / 2, canvas.height / 2);
}
// 작업장 영역 그리기 (퍼센트 좌표 사용)
mapRegions.forEach(region => {
const workers = todayWorkers.filter(w => w.workplace_id === region.workplace_id);
const visitors = todayVisitors.filter(v => v.workplace_id === region.workplace_id);
const workerCount = workers.reduce((sum, w) => sum + (w.member_count || 0), 0);
const visitorCount = visitors.reduce((sum, v) => sum + (v.visitor_count || 0), 0);
drawWorkplaceRegion(region, workerCount, visitorCount);
});
}
@@ -351,30 +345,27 @@ function drawWorkplaceRegion(region, workerCount, visitorCount) {
const y2 = (region.y_end / 100) * canvas.height;
const width = x2 - x1;
const height = y2 - y1;
// 선택된 작업장 하이라이트
const isSelected = region.workplace_id === selectedWorkplaceId;
// 색상 결정 (더 진하게 조정)
let fillColor, strokeColor, textColor;
if (isSelected) {
fillColor = 'rgba(34, 197, 94, 0.5)'; // 초록색 (선택됨)
fillColor = 'rgba(34, 197, 94, 0.5)';
strokeColor = 'rgb(22, 163, 74)';
textColor = '#15803d';
} else if (workerCount > 0 && visitorCount > 0) {
fillColor = 'rgba(34, 197, 94, 0.4)'; // 초록색 (작업+방문)
fillColor = 'rgba(34, 197, 94, 0.4)';
strokeColor = 'rgb(22, 163, 74)';
textColor = '#166534';
} else if (workerCount > 0) {
fillColor = 'rgba(59, 130, 246, 0.4)'; // 파란색 (작업만)
fillColor = 'rgba(59, 130, 246, 0.4)';
strokeColor = 'rgb(37, 99, 235)';
textColor = '#1e40af';
} else if (visitorCount > 0) {
fillColor = 'rgba(168, 85, 247, 0.4)'; // 보라색 (방문만)
fillColor = 'rgba(168, 85, 247, 0.4)';
strokeColor = 'rgb(147, 51, 234)';
textColor = '#7c3aed';
} else {
fillColor = 'rgba(107, 114, 128, 0.35)'; // 회색 (없음) - 더 진하게
fillColor = 'rgba(107, 114, 128, 0.35)';
strokeColor = 'rgb(75, 85, 99)';
textColor = '#374151';
}
@@ -382,17 +373,14 @@ function drawWorkplaceRegion(region, workerCount, visitorCount) {
ctx.fillStyle = fillColor;
ctx.strokeStyle = strokeColor;
ctx.lineWidth = isSelected ? 4 : 2.5;
ctx.beginPath();
ctx.rect(x1, y1, width, height);
ctx.fill();
ctx.stroke();
// 작업장명 표시 (배경 추가로 가독성 향상)
const centerX = x1 + width / 2;
const centerY = y1 + height / 2;
// 텍스트 배경
ctx.font = 'bold 13px sans-serif';
const textMetrics = ctx.measureText(region.workplace_name);
const textWidth = textMetrics.width + 12;
@@ -402,26 +390,21 @@ function drawWorkplaceRegion(region, workerCount, visitorCount) {
drawRoundRect(ctx, centerX - textWidth / 2, centerY - textHeight / 2, textWidth, textHeight, 4);
ctx.fill();
// 텍스트
ctx.fillStyle = textColor;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(region.workplace_name, centerX, centerY);
// 인원수 표시
const total = workerCount + visitorCount;
if (total > 0) {
// 인원수 배경
ctx.font = 'bold 12px sans-serif';
const countText = `${total}`;
const countMetrics = ctx.measureText(countText);
const countWidth = countMetrics.width + 10;
const countHeight = 18;
ctx.fillStyle = strokeColor;
drawRoundRect(ctx, centerX - countWidth / 2, centerY + 12, countWidth, countHeight, 4);
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.fillText(countText, centerX, centerY + 21);
}
@@ -432,10 +415,11 @@ function drawWorkplaceRegion(region, workerCount, visitorCount) {
*/
function onMapClick(e) {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
// 클릭된 영역 찾기
for (const region of mapRegions) {
const x1 = (region.x_start / 100) * canvas.width;
const y1 = (region.y_start / 100) * canvas.height;
@@ -453,87 +437,177 @@ function onMapClick(e) {
* 작업장 선택
*/
function selectWorkplace(region) {
// 기타 위치 체크박스 해제
document.getElementById('useCustomLocation').checked = false;
document.getElementById('customLocationInput').classList.remove('visible');
selectedWorkplaceId = region.workplace_id;
selectedWorkplaceName = region.workplace_name;
// 해당 작업장의 TBM/출입신청 확인
const workers = todayWorkers.filter(w => w.workplace_id === region.workplace_id);
const visitors = todayVisitors.filter(v => v.workplace_id === region.workplace_id);
if (workers.length > 0 || visitors.length > 0) {
// 작업 선택 모달 표시
showWorkSelectionModal(workers, visitors);
} else {
selectedTbmSessionId = null;
selectedVisitRequestId = null;
}
selectedTbmSessionId = null;
selectedVisitRequestId = null;
selectedProjectId = null;
projectSelected = false;
updateLocationInfo();
renderMap();
renderProjectList();
updateStepStatus();
}
/**
* 작업 선택 모달 표시
* 프로젝트 목록 렌더링 (아코디언 방식)
* 범주 1: 오늘 TBM 등록 작업 (해당 위치)
* 범주 2: 활성 프로젝트 (DB)
* 범주 3: 프로젝트 모름 (하단)
*/
function showWorkSelectionModal(workers, visitors) {
const modal = document.getElementById('workSelectionModal');
const optionsList = document.getElementById('workOptionsList');
function renderProjectList() {
const list = document.getElementById('projectList');
list.innerHTML = '';
optionsList.innerHTML = '';
// 해당 위치의 TBM에서 프로젝트 정보 추출
const workplaceWorkers = selectedWorkplaceId
? todayWorkers.filter(w => w.workplace_id === selectedWorkplaceId)
: todayWorkers;
const registeredProjectIds = new Set(
workplaceWorkers.map(w => w.project_id).filter(Boolean)
);
// TBM 작업 옵션
workers.forEach(w => {
const option = document.createElement('div');
option.className = 'work-option';
const safeTaskName = escapeHtml(w.task_name || '작업');
const safeProjectName = escapeHtml(w.project_name || '');
const memberCount = parseInt(w.member_count) || 0;
option.innerHTML = `
<div class="work-option-title">TBM: ${safeTaskName}</div>
<div class="work-option-desc">${safeProjectName} - ${memberCount}명</div>
`;
option.onclick = () => {
selectedTbmSessionId = w.session_id;
selectedVisitRequestId = null;
closeWorkModal();
updateLocationInfo();
};
optionsList.appendChild(option);
});
// TBM 등록 프로젝트
const tbmProjects = allProjects.filter(p => registeredProjectIds.has(p.project_id));
// 나머지 활성 프로젝트
const activeProjects = allProjects.filter(p => !registeredProjectIds.has(p.project_id));
// 출입신청 옵션
visitors.forEach(v => {
const option = document.createElement('div');
option.className = 'work-option';
const safeCompany = escapeHtml(v.visitor_company || '-');
const safePurpose = escapeHtml(v.purpose_name || '방문');
const visitorCount = parseInt(v.visitor_count) || 0;
option.innerHTML = `
<div class="work-option-title">출입: ${safeCompany}</div>
<div class="work-option-desc">${safePurpose} - ${visitorCount}명</div>
`;
option.onclick = () => {
selectedVisitRequestId = v.request_id;
selectedTbmSessionId = null;
closeWorkModal();
updateLocationInfo();
};
optionsList.appendChild(option);
});
// ─── 범주 1: TBM 등록 작업 ───
if (tbmProjects.length > 0) {
const tbmGroup = createProjectGroup(
'&#128736;&#65039;', '오늘 TBM 등록 작업', tbmProjects.length, 'tbm-group', true
);
const body = tbmGroup.querySelector('.project-group-body');
tbmProjects.forEach(p => {
const tbm = workplaceWorkers.find(w => w.project_id === p.project_id);
const card = createProjectCard(p, tbm);
body.appendChild(card);
});
list.appendChild(tbmGroup);
}
modal.classList.add('visible');
// ─── 범주 2: 활성 프로젝트 ───
if (activeProjects.length > 0) {
const activeGroup = createProjectGroup(
'&#128196;', '활성 프로젝트', activeProjects.length, '', tbmProjects.length === 0
);
const body = activeGroup.querySelector('.project-group-body');
activeProjects.forEach(p => {
const card = createProjectCard(p, null);
body.appendChild(card);
});
list.appendChild(activeGroup);
}
// 프로젝트가 아예 없을 때
if (tbmProjects.length === 0 && activeProjects.length === 0) {
const emptyMsg = document.createElement('div');
emptyMsg.className = 'project-empty';
emptyMsg.textContent = '등록된 프로젝트가 없습니다';
list.appendChild(emptyMsg);
}
// ─── 범주 3: 프로젝트 모름 ───
const unknownOption = document.createElement('div');
unknownOption.className = 'project-skip';
unknownOption.textContent = '프로젝트 여부 모름 (건너뛰기)';
unknownOption.onclick = () => selectProject(null, null);
list.appendChild(unknownOption);
}
/**
* 작업 선택 모달 닫기
* 아코디언 그룹 생성
*/
function closeWorkModal() {
document.getElementById('workSelectionModal').classList.remove('visible');
function createProjectGroup(icon, title, count, extraClass, openByDefault) {
const group = document.createElement('div');
group.className = 'project-group' + (extraClass ? ' ' + extraClass : '');
if (openByDefault) group.classList.add('open');
group.innerHTML = `
<div class="project-group-header">
<div class="group-left">
<span class="group-icon">${icon}</span>
<span class="group-title">${title}</span>
<span class="group-count">${count}건</span>
</div>
<span class="group-arrow">&#9660;</span>
</div>
<div class="project-group-body"></div>
`;
const header = group.querySelector('.project-group-header');
header.onclick = () => group.classList.toggle('open');
return group;
}
/**
* 프로젝트 카드 생성
*/
function createProjectCard(project, tbmSession) {
const card = document.createElement('div');
card.className = 'project-card';
const safeName = escapeHtml(project.project_name || '');
const safeJobNo = escapeHtml(project.job_no || '');
const safePm = escapeHtml(project.pm || '');
let html = `<div class="project-card-title">${safeName}</div>`;
html += '<div class="project-card-desc">';
if (safeJobNo) html += safeJobNo;
if (safePm) html += ` &middot; PM: ${safePm}`;
html += '</div>';
if (tbmSession) {
const safeTask = escapeHtml(tbmSession.task_name || '');
const count = parseInt(tbmSession.member_count) || 0;
html += `<div class="tbm-info">TBM: ${safeTask} (${count}명)</div>`;
}
card.innerHTML = html;
card._projectId = project.project_id;
card.onclick = (e) => {
e.stopPropagation();
selectProject(project.project_id, tbmSession);
};
return card;
}
/**
* 프로젝트 선택 처리
*/
function selectProject(projectId, tbmSession) {
selectedProjectId = projectId;
projectSelected = true;
if (tbmSession) {
selectedTbmSessionId = tbmSession.session_id;
} else {
selectedTbmSessionId = null;
}
selectedVisitRequestId = null;
// 모든 선택 해제 → 현재 선택 표시
const list = document.getElementById('projectList');
list.querySelectorAll('.project-card, .project-skip').forEach(el => el.classList.remove('selected'));
if (projectId === null) {
// 프로젝트 모름
list.querySelector('.project-skip').classList.add('selected');
} else {
// 카드에서 해당 프로젝트 찾아 선택
list.querySelectorAll('.project-card').forEach(card => {
if (card._projectId === projectId) card.classList.add('selected');
});
}
updateLocationInfo();
updateStepStatus();
}
/**
@@ -551,15 +625,17 @@ function updateLocationInfo() {
infoBox.classList.remove('empty');
let html = `<strong>선택된 위치:</strong> ${escapeHtml(selectedWorkplaceName)}`;
if (selectedProjectId) {
const proj = allProjects.find(p => p.project_id === selectedProjectId);
if (proj) {
html += `<br><span style="color:#059669;">프로젝트: ${escapeHtml(proj.project_name)}</span>`;
}
}
if (selectedTbmSessionId) {
const worker = todayWorkers.find(w => w.session_id === selectedTbmSessionId);
if (worker) {
html += `<br><span style="color: var(--primary-600);">연결 작업: ${escapeHtml(worker.task_name || '-')} (TBM)</span>`;
}
} else if (selectedVisitRequestId) {
const visitor = todayVisitors.find(v => v.request_id === selectedVisitRequestId);
if (visitor) {
html += `<br><span style="color: var(--primary-600);">연결 작업: ${escapeHtml(visitor.visitor_company || '-')} (출입)</span>`;
html += `<br><span style="color:#2563eb;">TBM: ${escapeHtml(worker.task_name || '-')}</span>`;
}
}
@@ -578,13 +654,12 @@ function onTypeSelect(type) {
selectedCategoryId = null;
selectedCategoryName = null;
selectedItemId = null;
customItemName = null;
// 버튼 상태 업데이트
document.querySelectorAll('.type-btn').forEach(btn => {
btn.classList.toggle('selected', btn.dataset.type === type);
});
// 카테고리 로드
loadCategories(type);
updateStepStatus();
}
@@ -615,7 +690,6 @@ async function loadCategories(type) {
function renderCategories(categories) {
const container = document.getElementById('categoryContainer');
const grid = document.getElementById('categoryGrid');
grid.innerHTML = '';
categories.forEach(cat => {
@@ -637,13 +711,12 @@ function onCategorySelect(category) {
selectedCategoryId = category.category_id;
selectedCategoryName = category.category_name;
selectedItemId = null;
customItemName = null;
// 버튼 상태 업데이트
document.querySelectorAll('.category-btn').forEach(btn => {
btn.classList.toggle('selected', btn.textContent === category.category_name);
});
// 항목 로드
loadItems(category.category_id);
updateStepStatus();
}
@@ -675,7 +748,6 @@ function renderItems(items) {
const grid = document.getElementById('itemGrid');
grid.innerHTML = '';
// 기존 항목들 렌더링
items.forEach(item => {
const btn = document.createElement('button');
btn.type = 'button';
@@ -686,7 +758,6 @@ function renderItems(items) {
grid.appendChild(btn);
});
// 직접 입력 버튼 추가
const customBtn = document.createElement('button');
customBtn.type = 'button';
customBtn.className = 'item-btn custom-input-btn';
@@ -694,7 +765,6 @@ function renderItems(items) {
customBtn.onclick = () => showCustomItemInput();
grid.appendChild(customBtn);
// 직접 입력 영역 숨기기
document.getElementById('customItemInput').style.display = 'none';
document.getElementById('customItemName').value = '';
customItemName = null;
@@ -704,12 +774,10 @@ function renderItems(items) {
* 항목 선택
*/
function onItemSelect(item, btn) {
// 단일 선택 (기존 선택 해제)
document.querySelectorAll('.item-btn').forEach(b => b.classList.remove('selected'));
btn.classList.add('selected');
selectedItemId = item.item_id;
customItemName = null; // 기존 항목 선택 시 직접 입력 초기화
customItemName = null;
document.getElementById('customItemInput').style.display = 'none';
updateStepStatus();
}
@@ -718,13 +786,9 @@ function onItemSelect(item, btn) {
* 직접 입력 영역 표시
*/
function showCustomItemInput() {
// 기존 선택 해제
document.querySelectorAll('.item-btn').forEach(b => b.classList.remove('selected'));
document.querySelector('.custom-input-btn').classList.add('selected');
selectedItemId = null;
// 입력 영역 표시
document.getElementById('customItemInput').style.display = 'flex';
document.getElementById('customItemName').focus();
}
@@ -735,21 +799,16 @@ function showCustomItemInput() {
function confirmCustomItem() {
const input = document.getElementById('customItemName');
const value = input.value.trim();
if (!value) {
alert('항목명을 입력해주세요.');
input.focus();
return;
}
customItemName = value;
selectedItemId = null; // 커스텀 항목이므로 ID는 null
// 입력 완료 표시
selectedItemId = null;
const customBtn = document.querySelector('.custom-input-btn');
customBtn.textContent = ` ${value}`;
customBtn.textContent = `\u2713 ${value}`;
customBtn.classList.add('selected');
updateStepStatus();
}
@@ -760,34 +819,63 @@ function cancelCustomItem() {
document.getElementById('customItemInput').style.display = 'none';
document.getElementById('customItemName').value = '';
customItemName = null;
// 직접 입력 버튼 원상복구
const customBtn = document.querySelector('.custom-input-btn');
customBtn.textContent = '+ 직접 입력';
customBtn.classList.remove('selected');
updateStepStatus();
}
/**
* 사진 선택
* 사진 선택 (클라이언트에서 리사이징 후 저장)
*/
function onPhotoSelect(e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
photos[currentPhotoIndex] = event.target.result;
resizeImage(file, 1280, 0.8).then(dataUrl => {
photos[currentPhotoIndex] = dataUrl;
updatePhotoSlot(currentPhotoIndex);
updateStepStatus(); // 제출 버튼 상태 업데이트
};
reader.readAsDataURL(file);
// 입력 초기화
updateStepStatus();
}).catch(() => {
// 리사이징 실패 시 원본 사용
const reader = new FileReader();
reader.onload = (event) => {
photos[currentPhotoIndex] = event.target.result;
updatePhotoSlot(currentPhotoIndex);
updateStepStatus();
};
reader.readAsDataURL(file);
});
e.target.value = '';
}
/**
* 이미지 리사이징 (Canvas 이용)
* @param {File} file - 원본 파일
* @param {number} maxSize - 최대 가로/세로 픽셀
* @param {number} quality - JPEG 품질 (0~1)
* @returns {Promise<string>} base64 data URL
*/
function resizeImage(file, maxSize, quality) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
let w = img.width, h = img.height;
if (w > maxSize || h > maxSize) {
if (w > h) { h = Math.round(h * maxSize / w); w = maxSize; }
else { w = Math.round(w * maxSize / h); h = maxSize; }
}
const cvs = document.createElement('canvas');
cvs.width = w;
cvs.height = h;
cvs.getContext('2d').drawImage(img, 0, 0, w, h);
resolve(cvs.toDataURL('image/jpeg', quality));
};
img.onerror = reject;
img.src = URL.createObjectURL(file);
});
}
/**
* 사진 슬롯 업데이트
*/
@@ -815,36 +903,35 @@ function updatePhotoSlot(index) {
function removePhoto(index) {
photos[index] = null;
updatePhotoSlot(index);
updateStepStatus(); // 제출 버튼 상태 업데이트
updateStepStatus();
}
/**
* 단계 상태 업데이트
* 5단계: 유형(1) → 위치(2) → 프로젝트(3) → 항목(4) → 사진(5)
*/
function updateStepStatus() {
const steps = document.querySelectorAll('.step');
const customLocation = document.getElementById('customLocation').value;
const useCustom = document.getElementById('useCustomLocation').checked;
// Step 1: 위치
const step1Complete = (useCustom && customLocation) || selectedWorkplaceId;
steps[0].classList.toggle('completed', step1Complete);
steps[1].classList.toggle('active', step1Complete);
// Step 2: 유형
const step2Complete = selectedType && selectedCategoryId;
steps[1].classList.toggle('completed', step2Complete);
steps[2].classList.toggle('active', step2Complete);
// Step 3: 항목 (기존 항목 선택 또는 직접 입력)
const step3Complete = selectedItemId || customItemName;
steps[2].classList.toggle('completed', step3Complete);
steps[3].classList.toggle('active', step3Complete);
// 제출 버튼 활성화
const submitBtn = document.getElementById('submitBtn');
const step1Complete = !!selectedType;
const step2Complete = (useCustom && customLocation) || !!selectedWorkplaceId;
const step3Complete = projectSelected;
const step4Complete = !!selectedCategoryId && (!!selectedItemId || !!customItemName);
const hasPhoto = photos.some(p => p !== null);
submitBtn.disabled = !(step1Complete && step2Complete && step3Complete && hasPhoto);
// Reset all
steps.forEach(s => { s.classList.remove('active', 'completed'); });
if (step1Complete) { steps[0].classList.add('completed'); } else { steps[0].classList.add('active'); }
if (step1Complete && step2Complete) { steps[1].classList.add('completed'); } else if (step1Complete) { steps[1].classList.add('active'); }
if (step1Complete && step2Complete && step3Complete) { steps[2].classList.add('completed'); } else if (step1Complete && step2Complete) { steps[2].classList.add('active'); }
if (step4Complete) { steps[3].classList.add('completed'); } else if (step1Complete && step2Complete && step3Complete) { steps[3].classList.add('active'); }
if (hasPhoto) { steps[4].classList.add('completed'); } else if (step4Complete) { steps[4].classList.add('active'); }
const submitBtn = document.getElementById('submitBtn');
submitBtn.disabled = !(step1Complete && step2Complete && step3Complete && step4Complete && hasPhoto);
}
/**
@@ -864,11 +951,12 @@ async function submitReport() {
factory_category_id: useCustom ? null : selectedFactoryId,
workplace_id: useCustom ? null : selectedWorkplaceId,
custom_location: useCustom ? customLocation : null,
project_id: selectedProjectId,
tbm_session_id: selectedTbmSessionId,
visit_request_id: selectedVisitRequestId,
issue_category_id: selectedCategoryId,
issue_item_id: selectedItemId,
custom_item_name: customItemName, // 직접 입력한 항목명
custom_item_name: customItemName,
additional_description: additionalDescription || null,
photos: photos.filter(p => p !== null)
};
@@ -886,15 +974,7 @@ async function submitReport() {
if (data.success) {
alert('신고가 등록되었습니다.');
// 유형에 따라 다른 페이지로 리다이렉트
if (selectedType === 'nonconformity') {
window.location.href = '/pages/work/nonconformity.html';
} else if (selectedType === 'safety') {
window.location.href = '/pages/safety/report-status.html';
} else {
// 기본: 뒤로가기
history.back();
}
window.location.href = '/pages/safety/report-status.html';
} else {
throw new Error(data.error || '신고 등록 실패');
}
@@ -918,8 +998,7 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
// 전역 함수 노출 (HTML onclick에서 호출용)
window.closeWorkModal = closeWorkModal;
// 전역 함수 노출
window.submitReport = submitReport;
window.showCustomItemInput = showCustomItemInput;
window.confirmCustomItem = confirmCustomItem;

View File

@@ -5,6 +5,9 @@ server {
root /usr/share/nginx/html;
index pages/safety/issue-report.html;
# 사진 업로드를 위한 body 크기 제한 (base64 인코딩 시 원본 대비 ~33% 증가)
client_max_body_size 50m;
# 정적 파일 캐시
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1h;
@@ -17,7 +20,7 @@ server {
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
# System 1 API 프록시 (공장/작업장, TBM, 출입관리)
# System 1 API 프록시 (공장/작업장, TBM, 출입관리, 프로젝트)
location /api/workplaces/ {
proxy_pass http://system1-api:3005;
proxy_set_header Host $host;
@@ -26,6 +29,14 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/projects/ {
proxy_pass http://system1-api:3005;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/tbm/ {
proxy_pass http://system1-api:3005;
proxy_set_header Host $host;
@@ -52,7 +63,6 @@ server {
}
# System 2 API uploads (신고 사진 등)
# ^~ + 더 긴 prefix → /api/ 보다 우선 매칭
location ^~ /api/uploads/ {
proxy_pass http://system2-api:3005/uploads/;
proxy_set_header Host $host;
@@ -62,7 +72,6 @@ server {
}
# System 1 uploads 프록시 (작업장 레이아웃 이미지 등)
# ^~ : 정적파일 캐시 regex보다 우선 매칭
location ^~ /uploads/ {
proxy_pass http://system1-api:3005/uploads/;
proxy_set_header Host $host;

File diff suppressed because it is too large Load Diff

View File

@@ -286,9 +286,7 @@
<input type="date" id="filterStartDate" title="시작일">
<input type="date" id="filterEndDate" title="종료일">
<a href="/pages/safety/issue-report.html?type=safety" class="btn-new-report">
+ 안전 신고
</a>
<a href="/pages/safety/issue-report.html" class="btn-new-report">+ 신고하기</a>
</div>
<!-- 신고 목록 -->