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:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -6,3 +6,8 @@ __pycache__/
|
||||
uploads/
|
||||
venv/
|
||||
.DS_Store
|
||||
._*
|
||||
*.DS_Store
|
||||
coverage/
|
||||
db_archive/
|
||||
*.log
|
||||
|
||||
@@ -6,25 +6,7 @@
|
||||
<title>(주)테크니컬코리아 생산팀 포털</title>
|
||||
<link rel="icon" type="image/png" href="img/favicon.png">
|
||||
<script>
|
||||
// SSO 토큰 확인 (쿠키 + localStorage)
|
||||
(function() {
|
||||
function cookieGet(name) {
|
||||
var match = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)'));
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
var token = cookieGet('sso_token') || localStorage.getItem('sso_token');
|
||||
if (token && token !== 'undefined' && token !== 'null') {
|
||||
window.location.replace('/pages/dashboard.html');
|
||||
} else {
|
||||
// 중앙 로그인으로 리다이렉트
|
||||
var hostname = window.location.hostname;
|
||||
if (hostname.includes('technicalkorea.net')) {
|
||||
window.location.replace(window.location.protocol + '//tkfb.technicalkorea.net/login');
|
||||
} else {
|
||||
window.location.replace('/login');
|
||||
}
|
||||
}
|
||||
})();
|
||||
window.location.replace('/pages/dashboard.html');
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -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, () => {});
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
// 사진 추가
|
||||
|
||||
@@ -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(
|
||||
'🛠️', '오늘 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(
|
||||
'📄', '활성 프로젝트', 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">▼</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 += ` · 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;
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
<!-- 신고 목록 -->
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>폐기함 - 부적합 관리</title>
|
||||
<title>폐기함 - 작업보고서</title>
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
@@ -91,39 +91,39 @@
|
||||
|
||||
<!-- 아카이브 통계 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div class="bg-white p-4 rounded-lg border-l-4" style="border-left-color: #16a34a;">
|
||||
<div class="bg-green-50 p-4 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-check-circle text-green-400 text-xl mr-3"></i>
|
||||
<i class="fas fa-check-circle text-green-500 text-xl mr-3"></i>
|
||||
<div>
|
||||
<p class="text-sm text-slate-500">완료</p>
|
||||
<p class="text-2xl font-bold text-slate-800" id="completedCount">0</p>
|
||||
<p class="text-sm text-green-600">완료</p>
|
||||
<p class="text-2xl font-bold text-green-700" id="completedCount">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white p-4 rounded-lg border-l-4" style="border-left-color: #475569;">
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-archive text-slate-400 text-xl mr-3"></i>
|
||||
<i class="fas fa-archive text-gray-500 text-xl mr-3"></i>
|
||||
<div>
|
||||
<p class="text-sm text-slate-500">보관</p>
|
||||
<p class="text-2xl font-bold text-slate-800" id="archivedCount">0</p>
|
||||
<p class="text-sm text-gray-600">보관</p>
|
||||
<p class="text-2xl font-bold text-gray-700" id="archivedCount">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white p-4 rounded-lg border-l-4" style="border-left-color: #dc2626;">
|
||||
<div class="bg-red-50 p-4 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-times-circle text-red-400 text-xl mr-3"></i>
|
||||
<i class="fas fa-times-circle text-red-500 text-xl mr-3"></i>
|
||||
<div>
|
||||
<p class="text-sm text-slate-500">취소</p>
|
||||
<p class="text-2xl font-bold text-slate-800" id="cancelledCount">0</p>
|
||||
<p class="text-sm text-red-600">취소</p>
|
||||
<p class="text-2xl font-bold text-red-700" id="cancelledCount">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white p-4 rounded-lg border-l-4" style="border-left-color: #7c3aed;">
|
||||
<div class="bg-purple-50 p-4 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-calendar-alt text-purple-400 text-xl mr-3"></i>
|
||||
<i class="fas fa-calendar-alt text-purple-500 text-xl mr-3"></i>
|
||||
<div>
|
||||
<p class="text-sm text-slate-500">이번 달</p>
|
||||
<p class="text-2xl font-bold text-slate-800" id="thisMonthCount">0</p>
|
||||
<p class="text-sm text-purple-600">이번 달</p>
|
||||
<p class="text-2xl font-bold text-purple-700" id="thisMonthCount">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,27 +18,29 @@
|
||||
|
||||
/* 대시보드 카드 스타일 */
|
||||
.dashboard-card {
|
||||
transition: all 0.2s ease;
|
||||
background: #ffffff;
|
||||
border-left: 4px solid #64748b;
|
||||
transition: all 0.3s ease;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.dashboard-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* 이슈 카드 스타일 */
|
||||
/* 이슈 카드 스타일 (세련된 모던 스타일) */
|
||||
.issue-card {
|
||||
transition: all 0.2s ease;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border-left: 4px solid transparent;
|
||||
background: #ffffff;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
|
||||
}
|
||||
|
||||
.issue-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-left-color: #475569;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
transform: translateY(-8px) scale(1.02);
|
||||
border-left-color: #3b82f6;
|
||||
box-shadow:
|
||||
0 25px 50px -12px rgba(0, 0, 0, 0.15),
|
||||
0 0 0 1px rgba(59, 130, 246, 0.1),
|
||||
0 0 20px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.issue-card label {
|
||||
@@ -90,7 +92,7 @@
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
background: #475569;
|
||||
background: linear-gradient(90deg, #10b981 0%, #059669 100%);
|
||||
transition: width 0.8s ease;
|
||||
}
|
||||
|
||||
@@ -153,43 +155,55 @@
|
||||
|
||||
<!-- 전체 통계 대시보드 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
<div class="dashboard-card p-6 rounded-xl shadow-sm" style="border-left-color: #475569;">
|
||||
<div class="dashboard-card text-white p-6 rounded-xl">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-slate-500">전체 진행 중</p>
|
||||
<p class="text-3xl font-bold text-slate-800" id="totalInProgress">0</p>
|
||||
<p class="text-blue-100 text-sm flex items-center space-x-1">
|
||||
<span>전체 진행 중</span>
|
||||
<div class="w-1.5 h-1.5 bg-blue-200 rounded-full animate-pulse"></div>
|
||||
</p>
|
||||
<p class="text-3xl font-bold" id="totalInProgress">0</p>
|
||||
</div>
|
||||
<i class="fas fa-tasks text-3xl text-slate-300"></i>
|
||||
<i class="fas fa-tasks text-4xl text-blue-200"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-card p-6 rounded-xl shadow-sm" style="border-left-color: #16a34a;">
|
||||
<div class="bg-gradient-to-br from-green-400 to-green-600 text-white p-6 rounded-xl dashboard-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-slate-500">오늘 신규</p>
|
||||
<p class="text-3xl font-bold text-slate-800" id="todayNew">0</p>
|
||||
<p class="text-green-100 text-sm flex items-center space-x-1">
|
||||
<span>오늘 신규</span>
|
||||
<div class="w-1.5 h-1.5 bg-green-200 rounded-full animate-pulse"></div>
|
||||
</p>
|
||||
<p class="text-3xl font-bold" id="todayNew">0</p>
|
||||
</div>
|
||||
<i class="fas fa-plus-circle text-3xl text-green-300"></i>
|
||||
<i class="fas fa-plus-circle text-4xl text-green-200"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-card p-6 rounded-xl shadow-sm" style="border-left-color: #7c3aed;">
|
||||
<div class="bg-gradient-to-br from-purple-400 to-purple-600 text-white p-6 rounded-xl dashboard-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-slate-500">완료 대기</p>
|
||||
<p class="text-3xl font-bold text-slate-800" id="pendingCompletion">0</p>
|
||||
<p class="text-purple-100 text-sm flex items-center space-x-1">
|
||||
<span>완료 대기</span>
|
||||
<div class="w-1.5 h-1.5 bg-purple-200 rounded-full animate-pulse"></div>
|
||||
</p>
|
||||
<p class="text-3xl font-bold" id="pendingCompletion">0</p>
|
||||
</div>
|
||||
<i class="fas fa-hourglass-half text-3xl text-purple-300"></i>
|
||||
<i class="fas fa-hourglass-half text-4xl text-purple-200"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-card p-6 rounded-xl shadow-sm" style="border-left-color: #dc2626;">
|
||||
<div class="bg-gradient-to-br from-red-400 to-red-600 text-white p-6 rounded-xl dashboard-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-slate-500">지연 중</p>
|
||||
<p class="text-3xl font-bold text-slate-800" id="overdue">0</p>
|
||||
<p class="text-red-100 text-sm flex items-center space-x-1">
|
||||
<span>지연 중</span>
|
||||
<div class="w-1.5 h-1.5 bg-red-200 rounded-full animate-pulse"></div>
|
||||
</p>
|
||||
<p class="text-3xl font-bold" id="overdue">0</p>
|
||||
</div>
|
||||
<i class="fas fa-clock text-3xl text-red-300"></i>
|
||||
<i class="fas fa-clock text-4xl text-red-200"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>수신함 - 부적합 관리</title>
|
||||
<title>수신함 - 작업보고서</title>
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
@@ -200,30 +200,30 @@
|
||||
|
||||
<!-- 통계 카드 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="bg-white p-4 rounded-lg border-l-4" style="border-left-color: #d97706;">
|
||||
<div class="bg-yellow-50 p-4 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-plus-circle text-amber-400 text-xl mr-3"></i>
|
||||
<i class="fas fa-plus-circle text-yellow-500 text-xl mr-3"></i>
|
||||
<div>
|
||||
<p class="text-sm text-slate-500">금일 신규</p>
|
||||
<p class="text-2xl font-bold text-slate-800" id="todayNewCount">0</p>
|
||||
<p class="text-sm text-yellow-600">금일 신규</p>
|
||||
<p class="text-2xl font-bold text-yellow-700" id="todayNewCount">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white p-4 rounded-lg border-l-4" style="border-left-color: #16a34a;">
|
||||
<div class="bg-green-50 p-4 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-check-circle text-green-400 text-xl mr-3"></i>
|
||||
<i class="fas fa-check-circle text-green-500 text-xl mr-3"></i>
|
||||
<div>
|
||||
<p class="text-sm text-slate-500">금일 처리</p>
|
||||
<p class="text-2xl font-bold text-slate-800" id="todayProcessedCount">0</p>
|
||||
<p class="text-sm text-green-600">금일 처리</p>
|
||||
<p class="text-2xl font-bold text-green-700" id="todayProcessedCount">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white p-4 rounded-lg border-l-4" style="border-left-color: #dc2626;">
|
||||
<div class="bg-red-50 p-4 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-triangle text-red-400 text-xl mr-3"></i>
|
||||
<i class="fas fa-exclamation-triangle text-red-500 text-xl mr-3"></i>
|
||||
<div>
|
||||
<p class="text-sm text-slate-500">미해결</p>
|
||||
<p class="text-2xl font-bold text-slate-800" id="unresolvedCount">0</p>
|
||||
<p class="text-sm text-red-600">미해결</p>
|
||||
<p class="text-2xl font-bold text-red-700" id="unresolvedCount">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>관리함 - 부적합 관리</title>
|
||||
<title>관리함 - 작업보고서</title>
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
@@ -273,39 +273,39 @@
|
||||
|
||||
<!-- 프로젝트별 통계 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div class="bg-white p-4 rounded-lg border-l-4" style="border-left-color: #475569;">
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-chart-bar text-slate-400 text-xl mr-3"></i>
|
||||
<i class="fas fa-chart-bar text-gray-500 text-xl mr-3"></i>
|
||||
<div>
|
||||
<p class="text-sm text-slate-500">총 부적합</p>
|
||||
<p class="text-2xl font-bold text-slate-800" id="totalCount">0</p>
|
||||
<p class="text-sm text-gray-600">총 부적합</p>
|
||||
<p class="text-2xl font-bold text-gray-700" id="totalCount">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white p-4 rounded-lg border-l-4" style="border-left-color: #2563eb;">
|
||||
<div class="bg-blue-50 p-4 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-cog text-blue-400 text-xl mr-3"></i>
|
||||
<i class="fas fa-cog text-blue-500 text-xl mr-3"></i>
|
||||
<div>
|
||||
<p class="text-sm text-slate-500">진행 중</p>
|
||||
<p class="text-2xl font-bold text-slate-800" id="inProgressCount">0</p>
|
||||
<p class="text-sm text-blue-600">진행 중</p>
|
||||
<p class="text-2xl font-bold text-blue-700" id="inProgressCount">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white p-4 rounded-lg border-l-4" style="border-left-color: #7c3aed;">
|
||||
<div class="bg-purple-50 p-4 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-hourglass-half text-purple-400 text-xl mr-3"></i>
|
||||
<i class="fas fa-hourglass-half text-purple-500 text-xl mr-3"></i>
|
||||
<div>
|
||||
<p class="text-sm text-slate-500">완료 대기</p>
|
||||
<p class="text-2xl font-bold text-slate-800" id="pendingCompletionCount">0</p>
|
||||
<p class="text-sm text-purple-600">완료 대기</p>
|
||||
<p class="text-2xl font-bold text-purple-700" id="pendingCompletionCount">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white p-4 rounded-lg border-l-4" style="border-left-color: #16a34a;">
|
||||
<div class="bg-green-50 p-4 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-check-circle text-green-400 text-xl mr-3"></i>
|
||||
<i class="fas fa-check-circle text-green-500 text-xl mr-3"></i>
|
||||
<div>
|
||||
<p class="text-sm text-slate-500">완료됨</p>
|
||||
<p class="text-2xl font-bold text-slate-800" id="completedCount">0</p>
|
||||
<p class="text-sm text-green-600">완료됨</p>
|
||||
<p class="text-2xl font-bold text-green-700" id="completedCount">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -54,7 +54,7 @@ class PageManager {
|
||||
async checkAuthentication() {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (!token) {
|
||||
window.location.href = '/issues-dashboard.html';
|
||||
window.location.href = '/index.html';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ class PageManager {
|
||||
console.error('인증 실패:', error);
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('currentUser');
|
||||
window.location.href = '/issues-dashboard.html';
|
||||
window.location.href = '/index.html';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -117,7 +117,7 @@ class PageManager {
|
||||
|
||||
// 권한 시스템이 로드되지 않았으면 기본 페이지만 허용
|
||||
if (!window.canAccessPage) {
|
||||
return ['issues_dashboard', 'issues_inbox'].includes(pageId);
|
||||
return ['issues_create', 'issues_view'].includes(pageId);
|
||||
}
|
||||
|
||||
return window.canAccessPage(pageId);
|
||||
@@ -130,7 +130,11 @@ class PageManager {
|
||||
alert('이 페이지에 접근할 권한이 없습니다.');
|
||||
|
||||
// 기본적으로 접근 가능한 페이지로 이동
|
||||
window.location.href = '/issues-dashboard.html';
|
||||
if (window.canAccessPage && window.canAccessPage('issues_view')) {
|
||||
window.location.href = '/issue-view.html';
|
||||
} else {
|
||||
window.location.href = '/index.html';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -246,7 +250,7 @@ class PageManager {
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||
다시 시도
|
||||
</button>
|
||||
<button onclick="window.location.href='/issues-dashboard.html'"
|
||||
<button onclick="window.location.href='/index.html'"
|
||||
class="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700">
|
||||
홈으로
|
||||
</button>
|
||||
|
||||
@@ -82,12 +82,14 @@ async function getBalancesByYear(year) {
|
||||
const db = getPool();
|
||||
const [rows] = await db.query(
|
||||
`SELECT vbd.*, vt.type_code, vt.type_name, vt.deduct_days, vt.priority,
|
||||
w.worker_name, w.hire_date
|
||||
w.worker_name, w.hire_date, w.department_id,
|
||||
COALESCE(d.department_name, '미배정') AS department_name
|
||||
FROM vacation_balance_details vbd
|
||||
JOIN vacation_types vt ON vbd.vacation_type_id = vt.id
|
||||
JOIN workers w ON vbd.worker_id = w.worker_id
|
||||
LEFT JOIN departments d ON w.department_id = d.department_id
|
||||
WHERE vbd.year = ?
|
||||
ORDER BY w.worker_name ASC, vt.priority ASC`,
|
||||
ORDER BY d.department_name ASC, w.worker_name ASC, vt.priority ASC`,
|
||||
[year]
|
||||
);
|
||||
return rows;
|
||||
|
||||
@@ -64,8 +64,8 @@
|
||||
<button class="tab-btn px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap" onclick="switchTab('departments')">
|
||||
<i class="fas fa-sitemap mr-2"></i>부서
|
||||
</button>
|
||||
<button class="tab-btn px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap" onclick="switchTab('workers')">
|
||||
<i class="fas fa-hard-hat mr-2"></i>작업자
|
||||
<button class="tab-btn px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap" onclick="switchTab('issueTypes')">
|
||||
<i class="fas fa-exclamation-triangle mr-2"></i>이슈 유형
|
||||
</button>
|
||||
<button class="tab-btn px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap" onclick="switchTab('tasks')">
|
||||
<i class="fas fa-tasks mr-2"></i>작업
|
||||
@@ -270,57 +270,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- ============ 작업자 탭 ============ -->
|
||||
<div id="tab-workers" class="hidden">
|
||||
<div class="grid lg:grid-cols-5 gap-6">
|
||||
<div class="lg:col-span-2 bg-white rounded-xl shadow-sm p-5">
|
||||
<h2 class="text-base font-semibold text-gray-800 mb-4"><i class="fas fa-hard-hat text-slate-400 mr-2"></i>작업자 등록</h2>
|
||||
<form id="addWorkerForm" class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">이름 <span class="text-red-400">*</span></label>
|
||||
<input type="text" id="newWorkerName" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" placeholder="작업자 이름" required>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">직종</label>
|
||||
<select id="newJobType" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
|
||||
<option value="">선택</option>
|
||||
<option value="leader">반장</option>
|
||||
<option value="worker">작업자</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">부서</label>
|
||||
<select id="newWorkerDept" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
|
||||
<option value="">선택</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">전화번호</label>
|
||||
<input type="text" id="newWorkerPhone" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" placeholder="010-0000-0000">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">입사일</label>
|
||||
<input type="date" id="newWorkerHireDate" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">비고</label>
|
||||
<input type="text" id="newWorkerNotes" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" placeholder="메모">
|
||||
</div>
|
||||
<button type="submit" class="w-full px-4 py-2 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium">
|
||||
<i class="fas fa-plus mr-1"></i>추가
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="lg:col-span-3 bg-white rounded-xl shadow-sm p-5">
|
||||
<h2 class="text-base font-semibold text-gray-800 mb-4"><i class="fas fa-hard-hat text-slate-400 mr-2"></i>작업자 목록</h2>
|
||||
<div id="workerList" class="space-y-2 max-h-[520px] overflow-y-auto">
|
||||
<div class="text-gray-400 text-center py-8"><i class="fas fa-spinner fa-spin text-2xl"></i><p class="mt-2 text-sm">로딩 중...</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ 부서 탭 ============ -->
|
||||
<div id="tab-departments" class="hidden">
|
||||
<div class="grid lg:grid-cols-5 gap-6">
|
||||
@@ -359,6 +308,102 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ 이슈 유형 탭 ============ -->
|
||||
<div id="tab-issueTypes" class="hidden">
|
||||
<div class="grid lg:grid-cols-5 gap-6">
|
||||
<!-- 좌측: 등록 폼 -->
|
||||
<div class="lg:col-span-2 space-y-4">
|
||||
<!-- 카테고리 등록 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-5">
|
||||
<h2 class="text-base font-semibold text-gray-800 mb-4"><i class="fas fa-layer-group text-slate-400 mr-2"></i>카테고리 등록</h2>
|
||||
<form id="addIssueCategoryForm" class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">유형 <span class="text-red-400">*</span></label>
|
||||
<div class="flex gap-3">
|
||||
<label class="flex items-center gap-1.5 text-sm cursor-pointer">
|
||||
<input type="radio" name="newIssueCatType" value="nonconformity" checked class="accent-slate-700"> 부적합
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 text-sm cursor-pointer">
|
||||
<input type="radio" name="newIssueCatType" value="safety" class="accent-slate-700"> 안전
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 text-sm cursor-pointer">
|
||||
<input type="radio" name="newIssueCatType" value="facility" class="accent-slate-700"> 시설설비
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">카테고리명 <span class="text-red-400">*</span></label>
|
||||
<input type="text" id="newIssueCatName" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" placeholder="카테고리 이름" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">설명</label>
|
||||
<input type="text" id="newIssueCatDesc" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" placeholder="카테고리 설명">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">표시순서</label>
|
||||
<input type="number" id="newIssueCatOrder" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" value="0" min="0">
|
||||
</div>
|
||||
<button type="submit" class="w-full px-4 py-2 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium">
|
||||
<i class="fas fa-plus mr-1"></i>카테고리 추가
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<!-- 아이템 등록 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-5">
|
||||
<h2 class="text-base font-semibold text-gray-800 mb-4"><i class="fas fa-list-ul text-slate-400 mr-2"></i>아이템 등록</h2>
|
||||
<form id="addIssueItemForm" class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">카테고리 <span class="text-red-400">*</span></label>
|
||||
<select id="newIssueItemCategory" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" required>
|
||||
<option value="">선택</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">아이템명 <span class="text-red-400">*</span></label>
|
||||
<input type="text" id="newIssueItemName" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" placeholder="아이템 이름" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">설명</label>
|
||||
<input type="text" id="newIssueItemDesc" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" placeholder="아이템 설명">
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">심각도</label>
|
||||
<select id="newIssueItemSeverity" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
|
||||
<option value="low">낮음</option>
|
||||
<option value="medium" selected>보통</option>
|
||||
<option value="high">높음</option>
|
||||
<option value="critical">심각</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">표시순서</label>
|
||||
<input type="number" id="newIssueItemOrder" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" value="0" min="0">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="w-full px-4 py-2 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium">
|
||||
<i class="fas fa-plus mr-1"></i>아이템 추가
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 우측: 카테고리/아이템 목록 -->
|
||||
<div class="lg:col-span-3 bg-white rounded-xl shadow-sm p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-base font-semibold text-gray-800"><i class="fas fa-exclamation-triangle text-slate-400 mr-2"></i>이슈 유형 목록</h2>
|
||||
<div class="flex gap-1 bg-gray-100 rounded-lg p-0.5">
|
||||
<button id="issueTypeToggleNonconformity" onclick="switchIssueType('nonconformity')" class="px-3 py-1 rounded-md text-xs font-medium bg-slate-700 text-white">부적합</button>
|
||||
<button id="issueTypeToggleSafety" onclick="switchIssueType('safety')" class="px-3 py-1 rounded-md text-xs font-medium text-gray-500 hover:bg-gray-200">안전</button>
|
||||
<button id="issueTypeToggleFacility" onclick="switchIssueType('facility')" class="px-3 py-1 rounded-md text-xs font-medium text-gray-500 hover:bg-gray-200">시설설비</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="issueCategoryList" class="space-y-2 max-h-[600px] overflow-y-auto">
|
||||
<div class="text-gray-400 text-center py-8"><i class="fas fa-spinner fa-spin text-2xl"></i><p class="mt-2 text-sm">로딩 중...</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ 작업 탭 ============ -->
|
||||
<div id="tab-tasks" class="hidden">
|
||||
<div class="flex gap-6" style="height: calc(100vh - 9rem);">
|
||||
@@ -780,71 +825,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업자 편집 모달 -->
|
||||
<div id="editWorkerModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-xl max-w-md w-full p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-base font-semibold text-gray-900">작업자 수정</h3>
|
||||
<button onclick="closeWorkerModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
<form id="editWorkerForm" class="space-y-3">
|
||||
<input type="hidden" id="editWorkerId">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">이름</label>
|
||||
<input type="text" id="editWorkerName" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" required>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">직종</label>
|
||||
<select id="editJobType" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
|
||||
<option value="">선택</option>
|
||||
<option value="leader">반장</option>
|
||||
<option value="worker">작업자</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">부서</label>
|
||||
<select id="editWorkerDept" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
|
||||
<option value="">선택</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">전화번호</label>
|
||||
<input type="text" id="editWorkerPhone" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">입사일</label>
|
||||
<input type="date" id="editWorkerHireDate" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">비고</label>
|
||||
<input type="text" id="editWorkerNotes" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">상태</label>
|
||||
<select id="editWorkerStatus" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
|
||||
<option value="active">재직</option>
|
||||
<option value="inactive">비활성</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">재직상태</label>
|
||||
<select id="editEmploymentStatus" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
|
||||
<option value="employed">재직</option>
|
||||
<option value="resigned">퇴직</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 pt-3">
|
||||
<button type="button" onclick="closeWorkerModal()" class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-sm">취소</button>
|
||||
<button type="submit" class="flex-1 px-4 py-2 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium"><i class="fas fa-save mr-1"></i>저장</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 부서 편집 모달 -->
|
||||
<div id="editDepartmentModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-xl max-w-md w-full p-6">
|
||||
@@ -889,6 +869,97 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 이슈 카테고리 수정 모달 -->
|
||||
<div id="editIssueCategoryModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-xl max-w-md w-full p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-base font-semibold text-gray-900">카테고리 수정</h3>
|
||||
<button onclick="closeIssueCategoryModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
<form id="editIssueCategoryForm" class="space-y-3">
|
||||
<input type="hidden" id="editIssueCatId">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">카테고리명</label>
|
||||
<input type="text" id="editIssueCatName" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">설명</label>
|
||||
<input type="text" id="editIssueCatDesc" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">표시순서</label>
|
||||
<input type="number" id="editIssueCatOrder" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" min="0">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">활성</label>
|
||||
<select id="editIssueCatActive" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
|
||||
<option value="1">활성</option>
|
||||
<option value="0">비활성</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 pt-3">
|
||||
<button type="button" onclick="closeIssueCategoryModal()" class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-sm">취소</button>
|
||||
<button type="submit" class="flex-1 px-4 py-2 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium"><i class="fas fa-save mr-1"></i>저장</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 이슈 아이템 수정 모달 -->
|
||||
<div id="editIssueItemModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-xl max-w-md w-full p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-base font-semibold text-gray-900">아이템 수정</h3>
|
||||
<button onclick="closeIssueItemModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
<form id="editIssueItemForm" class="space-y-3">
|
||||
<input type="hidden" id="editIssueItemId">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">카테고리</label>
|
||||
<select id="editIssueItemCategory" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" required>
|
||||
<option value="">선택</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">아이템명</label>
|
||||
<input type="text" id="editIssueItemName" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">설명</label>
|
||||
<input type="text" id="editIssueItemDesc" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">심각도</label>
|
||||
<select id="editIssueItemSeverity" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
|
||||
<option value="low">낮음</option>
|
||||
<option value="medium">보통</option>
|
||||
<option value="high">높음</option>
|
||||
<option value="critical">심각</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">표시순서</label>
|
||||
<input type="number" id="editIssueItemOrder" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" min="0">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">활성</label>
|
||||
<select id="editIssueItemActive" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
|
||||
<option value="1">활성</option>
|
||||
<option value="0">비활성</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex gap-3 pt-3">
|
||||
<button type="button" onclick="closeIssueItemModal()" class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-sm">취소</button>
|
||||
<button type="submit" class="flex-1 px-4 py-2 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium"><i class="fas fa-save mr-1"></i>저장</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업장 편집 모달 -->
|
||||
<div id="editWorkplaceModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-xl max-w-md w-full p-6">
|
||||
@@ -1211,8 +1282,8 @@
|
||||
[mainEl, navInner, headerInner].forEach(el => { el.classList.remove(wideClass); el.classList.add(defaultClass); });
|
||||
}
|
||||
if (name === 'projects' && !projectsLoaded) loadProjects();
|
||||
if (name === 'workers' && !workersLoaded) loadWorkers();
|
||||
if (name === 'departments' && !departmentsLoaded) loadDepartments();
|
||||
if (name === 'issueTypes' && !issueTypesLoaded) loadIssueTypes();
|
||||
if (name === 'workplaces' && !workplacesLoaded) loadWorkplaces();
|
||||
if (name === 'tasks' && !tasksLoaded) loadTasksTab();
|
||||
if (name === 'vacations' && !vacationsLoaded) loadVacationsTab();
|
||||
@@ -2087,122 +2158,6 @@
|
||||
} catch(e) { showToast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
/* ===== Workers CRUD ===== */
|
||||
let workers = [], workersLoaded = false, departmentsForSelect = [];
|
||||
|
||||
const JOB_TYPE = { leader: '반장', worker: '작업자' };
|
||||
function jobTypeBadge(t) {
|
||||
if (t === 'leader') return '<span class="px-1.5 py-0.5 rounded text-xs bg-amber-50 text-amber-600">반장</span>';
|
||||
if (t === 'worker') return '<span class="px-1.5 py-0.5 rounded text-xs bg-blue-50 text-blue-600">작업자</span>';
|
||||
return t ? `<span class="px-1.5 py-0.5 rounded text-xs bg-gray-50 text-gray-500">${t}</span>` : '';
|
||||
}
|
||||
function workerStatusBadge(s) {
|
||||
if (s === 'inactive') return '<span class="px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-400">비활성</span>';
|
||||
return '<span class="px-1.5 py-0.5 rounded text-xs bg-emerald-50 text-emerald-600">재직</span>';
|
||||
}
|
||||
|
||||
async function loadDepartmentsForSelect() {
|
||||
try {
|
||||
const r = await api('/departments'); departmentsForSelect = (r.data || r).filter(d => d.is_active !== 0 && d.is_active !== false);
|
||||
populateDeptSelects();
|
||||
} catch(e) { console.warn('부서 로드 실패:', e); }
|
||||
}
|
||||
function populateDeptSelects() {
|
||||
['newWorkerDept','editWorkerDept'].forEach(id => {
|
||||
const sel = document.getElementById(id); if (!sel) return;
|
||||
const val = sel.value;
|
||||
sel.innerHTML = '<option value="">선택</option>';
|
||||
departmentsForSelect.forEach(d => { const o = document.createElement('option'); o.value = d.department_id; o.textContent = d.department_name; sel.appendChild(o); });
|
||||
sel.value = val;
|
||||
});
|
||||
}
|
||||
|
||||
async function loadWorkers() {
|
||||
await loadDepartmentsForSelect();
|
||||
try {
|
||||
const r = await api('/workers'); workers = r.data || r;
|
||||
workersLoaded = true;
|
||||
displayWorkers();
|
||||
} catch (err) {
|
||||
document.getElementById('workerList').innerHTML = `<div class="text-red-500 text-center py-6"><i class="fas fa-exclamation-triangle text-xl"></i><p class="text-sm mt-2">${err.message}</p></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function displayWorkers() {
|
||||
const c = document.getElementById('workerList');
|
||||
if (!workers.length) { c.innerHTML = '<p class="text-gray-400 text-center py-4 text-sm">등록된 작업자가 없습니다.</p>'; return; }
|
||||
c.innerHTML = workers.map(w => `
|
||||
<div class="flex items-center justify-between p-2.5 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium text-gray-800 truncate"><i class="fas fa-hard-hat mr-1.5 text-gray-400 text-xs"></i>${w.worker_name}</div>
|
||||
<div class="text-xs text-gray-500 flex items-center gap-1.5 mt-0.5 flex-wrap">
|
||||
${jobTypeBadge(w.job_type)}
|
||||
${w.department_name ? `<span class="px-1.5 py-0.5 rounded bg-green-50 text-green-600">${w.department_name}</span>` : ''}
|
||||
${workerStatusBadge(w.status)}
|
||||
${w.phone_number ? `<span class="text-gray-400">${w.phone_number}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-1 ml-2 flex-shrink-0">
|
||||
<button onclick="editWorker(${w.worker_id})" class="p-1.5 text-slate-500 hover:text-slate-700 hover:bg-slate-200 rounded" title="편집"><i class="fas fa-pen-to-square text-xs"></i></button>
|
||||
${w.status !== 'inactive' ? `<button onclick="deactivateWorker(${w.worker_id},'${(w.worker_name||'').replace(/'/g,"\\'")}')" class="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-100 rounded" title="비활성화"><i class="fas fa-ban text-xs"></i></button>` : ''}
|
||||
</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
document.getElementById('addWorkerForm').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api('/workers', { method: 'POST', body: JSON.stringify({
|
||||
worker_name: document.getElementById('newWorkerName').value.trim(),
|
||||
job_type: document.getElementById('newJobType').value || null,
|
||||
department_id: document.getElementById('newWorkerDept').value ? parseInt(document.getElementById('newWorkerDept').value) : null,
|
||||
phone_number: document.getElementById('newWorkerPhone').value.trim() || null,
|
||||
hire_date: document.getElementById('newWorkerHireDate').value || null,
|
||||
notes: document.getElementById('newWorkerNotes').value.trim() || null
|
||||
})});
|
||||
showToast('작업자가 추가되었습니다.'); document.getElementById('addWorkerForm').reset(); await loadWorkers();
|
||||
} catch(e) { showToast(e.message, 'error'); }
|
||||
});
|
||||
|
||||
function editWorker(id) {
|
||||
const w = workers.find(x => x.worker_id === id); if (!w) return;
|
||||
document.getElementById('editWorkerId').value = w.worker_id;
|
||||
document.getElementById('editWorkerName').value = w.worker_name;
|
||||
document.getElementById('editJobType').value = w.job_type || '';
|
||||
document.getElementById('editWorkerDept').value = w.department_id || '';
|
||||
document.getElementById('editWorkerPhone').value = w.phone_number || '';
|
||||
document.getElementById('editWorkerHireDate').value = formatDate(w.hire_date);
|
||||
document.getElementById('editWorkerNotes').value = w.notes || '';
|
||||
document.getElementById('editWorkerStatus').value = w.status || 'active';
|
||||
document.getElementById('editEmploymentStatus').value = w.employment_status || 'employed';
|
||||
populateDeptSelects();
|
||||
document.getElementById('editWorkerDept').value = w.department_id || '';
|
||||
document.getElementById('editWorkerModal').classList.remove('hidden');
|
||||
}
|
||||
function closeWorkerModal() { document.getElementById('editWorkerModal').classList.add('hidden'); }
|
||||
|
||||
document.getElementById('editWorkerForm').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api(`/workers/${document.getElementById('editWorkerId').value}`, { method: 'PUT', body: JSON.stringify({
|
||||
worker_name: document.getElementById('editWorkerName').value.trim(),
|
||||
job_type: document.getElementById('editJobType').value || null,
|
||||
department_id: document.getElementById('editWorkerDept').value ? parseInt(document.getElementById('editWorkerDept').value) : null,
|
||||
phone_number: document.getElementById('editWorkerPhone').value.trim() || null,
|
||||
hire_date: document.getElementById('editWorkerHireDate').value || null,
|
||||
notes: document.getElementById('editWorkerNotes').value.trim() || null,
|
||||
status: document.getElementById('editWorkerStatus').value,
|
||||
employment_status: document.getElementById('editEmploymentStatus').value
|
||||
})});
|
||||
showToast('수정되었습니다.'); closeWorkerModal(); await loadWorkers();
|
||||
} catch(e) { showToast(e.message, 'error'); }
|
||||
});
|
||||
|
||||
async function deactivateWorker(id, name) {
|
||||
if (!confirm(`"${name}" 작업자를 비활성화?`)) return;
|
||||
try { await api(`/workers/${id}`, { method: 'DELETE' }); showToast('작업자 비활성화 완료'); await loadWorkers(); } catch(e) { showToast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
/* ===== Departments CRUD ===== */
|
||||
let departments = [], departmentsLoaded = false;
|
||||
|
||||
@@ -2295,6 +2250,194 @@
|
||||
try { await api(`/departments/${id}`, { method: 'DELETE' }); showToast('부서 비활성화 완료'); await loadDepartments(); } catch(e) { showToast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
/* ===== Issue Types CRUD ===== */
|
||||
let issueCategories = [], issueItems = [], issueTypesLoaded = false;
|
||||
let currentIssueType = 'nonconformity';
|
||||
|
||||
function severityBadge(severity) {
|
||||
const colors = { low: 'bg-gray-100 text-gray-600', medium: 'bg-yellow-100 text-yellow-700', high: 'bg-orange-100 text-orange-700', critical: 'bg-red-100 text-red-700' };
|
||||
const labels = { low: '낮음', medium: '보통', high: '높음', critical: '심각' };
|
||||
return `<span class="px-1.5 py-0.5 rounded text-xs ${colors[severity]||''}">${labels[severity]||severity}</span>`;
|
||||
}
|
||||
|
||||
async function loadIssueTypes() {
|
||||
try {
|
||||
const [catRes, itemRes] = await Promise.all([
|
||||
api('/work-issues/categories'),
|
||||
api('/work-issues/items')
|
||||
]);
|
||||
issueCategories = catRes.data || catRes;
|
||||
issueItems = itemRes.data || itemRes;
|
||||
issueTypesLoaded = true;
|
||||
populateIssueCategorySelect();
|
||||
displayIssueCategories();
|
||||
} catch (err) {
|
||||
document.getElementById('issueCategoryList').innerHTML = `<div class="text-red-500 text-center py-6"><i class="fas fa-exclamation-triangle text-xl"></i><p class="text-sm mt-2">${err.message}</p></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function switchIssueType(type) {
|
||||
currentIssueType = type;
|
||||
['nonconformity','safety','facility'].forEach(t => {
|
||||
const btn = document.getElementById('issueTypeToggle' + t.charAt(0).toUpperCase() + t.slice(1));
|
||||
if (btn) btn.className = 'px-3 py-1 rounded-md text-xs font-medium ' + (type === t ? 'bg-slate-700 text-white' : 'text-gray-500 hover:bg-gray-200');
|
||||
});
|
||||
displayIssueCategories();
|
||||
}
|
||||
|
||||
function populateIssueCategorySelect() {
|
||||
['newIssueItemCategory', 'editIssueItemCategory'].forEach(id => {
|
||||
const sel = document.getElementById(id); if (!sel) return;
|
||||
const val = sel.value;
|
||||
sel.innerHTML = '<option value="">선택</option>';
|
||||
issueCategories.filter(c => c.is_active !== 0 && c.is_active !== false).sort((a,b) => (a.display_order||0) - (b.display_order||0)).forEach(c => {
|
||||
const typeLabel = c.category_type === 'nonconformity' ? '[부적합]' : c.category_type === 'safety' ? '[안전]' : '[시설설비]';
|
||||
const o = document.createElement('option'); o.value = c.category_id; o.textContent = `${typeLabel} ${c.category_name}`; sel.appendChild(o);
|
||||
});
|
||||
sel.value = val;
|
||||
});
|
||||
}
|
||||
|
||||
function displayIssueCategories() {
|
||||
const c = document.getElementById('issueCategoryList');
|
||||
const filtered = issueCategories.filter(cat => cat.category_type === currentIssueType).sort((a,b) => (a.display_order||0) - (b.display_order||0));
|
||||
if (!filtered.length) { c.innerHTML = '<p class="text-gray-400 text-center py-4 text-sm">등록된 카테고리가 없습니다.</p>'; return; }
|
||||
c.innerHTML = filtered.map(cat => {
|
||||
const items = issueItems.filter(it => it.category_id === cat.category_id).sort((a,b) => (a.display_order||0) - (b.display_order||0));
|
||||
const isInactive = cat.is_active === 0 || cat.is_active === false;
|
||||
return `
|
||||
<div class="border rounded-lg ${isInactive ? 'opacity-60' : ''}">
|
||||
<div class="group-header flex items-center justify-between p-3 rounded-t-lg" onclick="this.nextElementSibling.classList.toggle('hidden'); this.querySelector('.chevron').classList.toggle('fa-chevron-down'); this.querySelector('.chevron').classList.toggle('fa-chevron-right');">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-chevron-down chevron text-gray-400 text-xs w-3"></i>
|
||||
<span class="text-sm font-medium text-gray-800">${cat.category_name}</span>
|
||||
<span class="px-1.5 py-0.5 rounded text-xs ${items.length > 0 ? 'bg-blue-50 text-blue-600' : 'bg-gray-50 text-gray-400'}">${items.length}개</span>
|
||||
${isInactive ? '<span class="px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-400">비활성</span>' : ''}
|
||||
</div>
|
||||
<div class="flex gap-1" onclick="event.stopPropagation()">
|
||||
<button onclick="editIssueCategory(${cat.category_id})" class="p-1.5 text-slate-500 hover:text-slate-700 hover:bg-slate-200 rounded" title="편집"><i class="fas fa-pen-to-square text-xs"></i></button>
|
||||
<button onclick="deleteIssueCategory(${cat.category_id},'${(cat.category_name||'').replace(/'/g,"\\'")}')" class="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-100 rounded" title="삭제"><i class="fas fa-trash text-xs"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t">
|
||||
${items.length ? items.map(it => {
|
||||
const itemInactive = it.is_active === 0 || it.is_active === false;
|
||||
return `
|
||||
<div class="flex items-center justify-between px-4 py-2 hover:bg-gray-50 ${itemInactive ? 'opacity-60' : ''}">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-gray-300 text-xs">├</span>
|
||||
<span class="text-sm text-gray-700 truncate">${it.item_name}</span>
|
||||
${severityBadge(it.severity)}
|
||||
${itemInactive ? '<span class="px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-400">비활성</span>' : ''}
|
||||
</div>
|
||||
<div class="flex gap-1 flex-shrink-0">
|
||||
<button onclick="editIssueItem(${it.item_id})" class="p-1 text-slate-500 hover:text-slate-700 hover:bg-slate-200 rounded" title="편집"><i class="fas fa-pen-to-square text-xs"></i></button>
|
||||
<button onclick="deleteIssueItem(${it.item_id},'${(it.item_name||'').replace(/'/g,"\\'")}')" class="p-1 text-red-400 hover:text-red-600 hover:bg-red-100 rounded" title="삭제"><i class="fas fa-trash text-xs"></i></button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('') : '<p class="text-gray-400 text-center py-3 text-xs">아이템 없음</p>'}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
document.getElementById('addIssueCategoryForm').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const type = document.querySelector('input[name="newIssueCatType"]:checked').value;
|
||||
try {
|
||||
await api('/work-issues/categories', { method: 'POST', body: JSON.stringify({
|
||||
category_name: document.getElementById('newIssueCatName').value.trim(),
|
||||
category_type: type,
|
||||
description: document.getElementById('newIssueCatDesc').value.trim() || null,
|
||||
display_order: parseInt(document.getElementById('newIssueCatOrder').value) || 0
|
||||
})});
|
||||
showToast('카테고리가 추가되었습니다.');
|
||||
document.getElementById('addIssueCategoryForm').reset();
|
||||
await loadIssueTypes();
|
||||
} catch(e) { showToast(e.message, 'error'); }
|
||||
});
|
||||
|
||||
document.getElementById('addIssueItemForm').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api('/work-issues/items', { method: 'POST', body: JSON.stringify({
|
||||
category_id: parseInt(document.getElementById('newIssueItemCategory').value),
|
||||
item_name: document.getElementById('newIssueItemName').value.trim(),
|
||||
description: document.getElementById('newIssueItemDesc').value.trim() || null,
|
||||
severity: document.getElementById('newIssueItemSeverity').value,
|
||||
display_order: parseInt(document.getElementById('newIssueItemOrder').value) || 0
|
||||
})});
|
||||
showToast('아이템이 추가되었습니다.');
|
||||
document.getElementById('addIssueItemForm').reset();
|
||||
await loadIssueTypes();
|
||||
} catch(e) { showToast(e.message, 'error'); }
|
||||
});
|
||||
|
||||
function editIssueCategory(id) {
|
||||
const cat = issueCategories.find(c => c.category_id === id); if (!cat) return;
|
||||
document.getElementById('editIssueCatId').value = cat.category_id;
|
||||
document.getElementById('editIssueCatName').value = cat.category_name;
|
||||
document.getElementById('editIssueCatDesc').value = cat.description || '';
|
||||
document.getElementById('editIssueCatOrder').value = cat.display_order || 0;
|
||||
document.getElementById('editIssueCatActive').value = (cat.is_active === 0 || cat.is_active === false) ? '0' : '1';
|
||||
document.getElementById('editIssueCategoryModal').classList.remove('hidden');
|
||||
}
|
||||
function closeIssueCategoryModal() { document.getElementById('editIssueCategoryModal').classList.add('hidden'); }
|
||||
|
||||
document.getElementById('editIssueCategoryForm').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api(`/work-issues/categories/${document.getElementById('editIssueCatId').value}`, { method: 'PUT', body: JSON.stringify({
|
||||
category_name: document.getElementById('editIssueCatName').value.trim(),
|
||||
description: document.getElementById('editIssueCatDesc').value.trim() || null,
|
||||
display_order: parseInt(document.getElementById('editIssueCatOrder').value) || 0,
|
||||
is_active: document.getElementById('editIssueCatActive').value === '1'
|
||||
})});
|
||||
showToast('카테고리가 수정되었습니다.'); closeIssueCategoryModal(); await loadIssueTypes();
|
||||
} catch(e) { showToast(e.message, 'error'); }
|
||||
});
|
||||
|
||||
async function deleteIssueCategory(id, name) {
|
||||
const items = issueItems.filter(it => it.category_id === id);
|
||||
if (items.length > 0) { showToast(`"${name}" 카테고리에 ${items.length}개 아이템이 있어 삭제할 수 없습니다. 아이템을 먼저 삭제하세요.`, 'error'); return; }
|
||||
if (!confirm(`"${name}" 카테고리를 삭제하시겠습니까?`)) return;
|
||||
try { await api(`/work-issues/categories/${id}`, { method: 'DELETE' }); showToast('카테고리가 삭제되었습니다.'); await loadIssueTypes(); } catch(e) { showToast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
function editIssueItem(id) {
|
||||
const it = issueItems.find(x => x.item_id === id); if (!it) return;
|
||||
populateIssueCategorySelect();
|
||||
document.getElementById('editIssueItemId').value = it.item_id;
|
||||
document.getElementById('editIssueItemCategory').value = it.category_id;
|
||||
document.getElementById('editIssueItemName').value = it.item_name;
|
||||
document.getElementById('editIssueItemDesc').value = it.description || '';
|
||||
document.getElementById('editIssueItemSeverity').value = it.severity || 'medium';
|
||||
document.getElementById('editIssueItemOrder').value = it.display_order || 0;
|
||||
document.getElementById('editIssueItemActive').value = (it.is_active === 0 || it.is_active === false) ? '0' : '1';
|
||||
document.getElementById('editIssueItemModal').classList.remove('hidden');
|
||||
}
|
||||
function closeIssueItemModal() { document.getElementById('editIssueItemModal').classList.add('hidden'); }
|
||||
|
||||
document.getElementById('editIssueItemForm').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api(`/work-issues/items/${document.getElementById('editIssueItemId').value}`, { method: 'PUT', body: JSON.stringify({
|
||||
category_id: parseInt(document.getElementById('editIssueItemCategory').value),
|
||||
item_name: document.getElementById('editIssueItemName').value.trim(),
|
||||
description: document.getElementById('editIssueItemDesc').value.trim() || null,
|
||||
severity: document.getElementById('editIssueItemSeverity').value,
|
||||
display_order: parseInt(document.getElementById('editIssueItemOrder').value) || 0,
|
||||
is_active: document.getElementById('editIssueItemActive').value === '1'
|
||||
})});
|
||||
showToast('아이템이 수정되었습니다.'); closeIssueItemModal(); await loadIssueTypes();
|
||||
} catch(e) { showToast(e.message, 'error'); }
|
||||
});
|
||||
|
||||
async function deleteIssueItem(id, name) {
|
||||
if (!confirm(`"${name}" 아이템을 삭제하시겠습니까?`)) return;
|
||||
try { await api(`/work-issues/items/${id}`, { method: 'DELETE' }); showToast('아이템이 삭제되었습니다.'); await loadIssueTypes(); } catch(e) { showToast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
/* ===== Workplaces CRUD ===== */
|
||||
let workplaces = [], workplacesLoaded = false, workplaceCategories = [];
|
||||
let selectedWorkplaceId = null, selectedWorkplaceName = '';
|
||||
|
||||
@@ -18,6 +18,16 @@ server {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# work-issues API 프록시 → system2-api
|
||||
location /api/work-issues/ {
|
||||
proxy_pass http://tk-system2-api:3005;
|
||||
proxy_http_version 1.1;
|
||||
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;
|
||||
}
|
||||
|
||||
# API 프록시 → tkuser-api
|
||||
location /api/ {
|
||||
proxy_pass http://tkuser-api:3000;
|
||||
|
||||
Reference in New Issue
Block a user