security: 보안 강제 시스템 구축 + 하드코딩 비밀번호 제거

보안 감사 결과 CRITICAL 2건, HIGH 5건 발견 → 수정 완료 + 자동화 구축.

[보안 수정]
- issue-view.js: 하드코딩 비밀번호 → crypto.getRandomValues() 랜덤 생성
- pushSubscriptionController.js: ntfy 비밀번호 → process.env.NTFY_SUB_PASSWORD
- DEPLOY-GUIDE.md/PROGRESS.md/migration SQL: 평문 비밀번호 → placeholder
- docker-compose.yml/.env.example: NTFY_SUB_PASSWORD 환경변수 추가

[보안 강제 시스템 - 신규]
- scripts/security-scan.sh: 8개 규칙 (CRITICAL 2, HIGH 4, MEDIUM 2)
  3모드(staged/all/diff), severity, .securityignore, MEDIUM 임계값
- .githooks/pre-commit: 로컬 빠른 피드백
- .githooks/pre-receive-server.sh: Gitea 서버 최종 차단
  bypass 거버넌스([SECURITY-BYPASS: 사유] + 사용자 제한 + 로그)
- SECURITY-CHECKLIST.md: 10개 카테고리 자동/수동 구분
- docs/SECURITY-GUIDE.md: 운영자 가이드 (워크플로우, bypass, FAQ)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-10 09:44:21 +09:00
parent bbffa47a9d
commit ba9ef32808
257 changed files with 786 additions and 18 deletions

View File

@@ -1,248 +0,0 @@
// /js/api-base.js
// API 기본 설정 및 보안 유틸리티 (비모듈 - 빠른 로딩용)
// ==================== SW 캐시 강제 해제 (PWA 홈화면 추가 대응) ====================
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations().then(function(regs) {
regs.forEach(function(reg) { reg.unregister(); });
});
}
if ('caches' in window) {
caches.keys().then(function(keys) {
keys.forEach(function(key) { caches.delete(key); });
});
}
(function() {
'use strict';
// ==================== SSO 쿠키 유틸리티 ====================
function cookieGet(name) {
var match = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)'));
return match ? decodeURIComponent(match[1]) : null;
}
function cookieRemove(name) {
var cookie = name + '=; path=/; max-age=0';
if (window.location.hostname.includes('technicalkorea.net')) {
cookie += '; domain=.technicalkorea.net';
}
document.cookie = cookie;
}
/**
* SSO 토큰 가져오기 (쿠키 우선, localStorage 폴백)
* sso_token이 없으면 기존 token도 확인 (하위 호환)
*/
window.getSSOToken = function() {
return cookieGet('sso_token') || localStorage.getItem('sso_token');
};
/**
* SSO 사용자 정보 가져오기 (쿠키 우선, localStorage 폴백)
*/
window.getSSOUser = function() {
var raw = cookieGet('sso_user') || localStorage.getItem('sso_user');
try { return raw ? JSON.parse(raw) : null; } catch(e) { return null; }
};
/**
* 중앙 로그인 URL 반환
*/
window.getLoginUrl = function() {
var hostname = window.location.hostname;
if (hostname.includes('technicalkorea.net')) {
return window.location.protocol + '//tkfb.technicalkorea.net/dashboard?redirect=' + encodeURIComponent(window.location.href);
}
// 개발 환경: tkds 포트 (30780)
return window.location.protocol + '//' + hostname + ':30780/dashboard?redirect=' + encodeURIComponent(window.location.href);
};
/**
* SSO 토큰 및 사용자 정보 삭제
*/
window.clearSSOAuth = function() {
cookieRemove('sso_token');
cookieRemove('sso_user');
cookieRemove('sso_refresh_token');
['sso_token','sso_user','sso_refresh_token','token','user','access_token','currentUser','current_user','userInfo','userPageAccess'].forEach(function(k) {
localStorage.removeItem(k);
});
};
// ==================== 보안 유틸리티 (XSS 방지) ====================
/**
* HTML 특수문자 이스케이프 (XSS 방지)
*/
window.escapeHtml = function(str) {
if (str === null || str === undefined) return '';
if (typeof str !== 'string') str = String(str);
var htmlEntities = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
'/': '&#x2F;',
'`': '&#x60;',
'=': '&#x3D;'
};
return str.replace(/[&<>"'`=\/]/g, function(char) {
return htmlEntities[char];
});
};
/**
* URL 파라미터 이스케이프
*/
window.escapeUrl = function(str) {
if (str === null || str === undefined) return '';
return encodeURIComponent(String(str));
};
// ==================== API 설정 ====================
var API_PORT = 30005;
var API_PATH = '/api';
function getApiBaseUrl() {
var hostname = window.location.hostname;
var protocol = window.location.protocol;
// 프로덕션 환경 (technicalkorea.net 도메인) - 같은 도메인의 /api 경로
if (hostname.includes('technicalkorea.net')) {
return protocol + '//' + hostname + API_PATH;
}
// 개발 환경 (localhost 또는 IP)
return protocol + '//' + hostname + ':' + API_PORT + API_PATH;
}
// 전역 API 설정
var apiUrl = getApiBaseUrl();
window.API_BASE_URL = apiUrl;
window.API = apiUrl; // 이전 호환성
// 인증 헤더 생성 (쿠키/localStorage에서 토큰 읽기)
window.getAuthHeaders = function() {
var token = window.getSSOToken();
return {
'Content-Type': 'application/json',
'Authorization': token ? 'Bearer ' + token : ''
};
};
// API 호출 헬퍼
window.apiCall = async function(endpoint, method, data) {
method = method || 'GET';
var url = window.API_BASE_URL + endpoint;
var config = {
method: method,
headers: window.getAuthHeaders(),
cache: 'no-store'
};
if (data && (method === 'POST' || method === 'PUT' || method === 'PATCH' || method === 'DELETE')) {
config.body = JSON.stringify(data);
}
var response = await fetch(url, config);
// 401 Unauthorized 처리
if (response.status === 401) {
window.clearSSOAuth();
window.location.href = window.getLoginUrl() + '&logout=1';
throw new Error('인증이 만료되었습니다.');
}
return response.json();
};
// ==================== 공통 유틸리티 ====================
/**
* Toast 알림 표시
*/
window.showToast = function(message, type, duration) {
type = type || 'info';
duration = duration || 3000;
var container = document.getElementById('toastContainer');
if (!container) {
container = document.createElement('div');
container.id = 'toastContainer';
container.style.cssText = 'position:fixed;top:20px;right:20px;z-index:9999;display:flex;flex-direction:column;gap:10px;';
document.body.appendChild(container);
}
if (!document.getElementById('toastStyles')) {
var style = document.createElement('style');
style.id = 'toastStyles';
style.textContent =
'.toast{display:flex;align-items:center;gap:12px;padding:12px 20px;background:#fff;border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,.15);opacity:0;transform:translateX(100px);transition:all .3s ease;min-width:250px;max-width:400px}' +
'.toast.show{opacity:1;transform:translateX(0)}' +
'.toast-success{border-left:4px solid #10b981}.toast-error{border-left:4px solid #ef4444}' +
'.toast-warning{border-left:4px solid #f59e0b}.toast-info{border-left:4px solid #3b82f6}' +
'.toast-icon{font-size:20px}.toast-message{font-size:14px;color:#374151}';
document.head.appendChild(style);
}
var iconMap = { success: '\u2705', error: '\u274C', warning: '\u26A0\uFE0F', info: '\u2139\uFE0F' };
var toast = document.createElement('div');
toast.className = 'toast toast-' + type;
toast.innerHTML = '<span class="toast-icon">' + (iconMap[type] || '\u2139\uFE0F') + '</span><span class="toast-message">' + escapeHtml(message) + '</span>';
container.appendChild(toast);
setTimeout(function() { toast.classList.add('show'); }, 10);
setTimeout(function() {
toast.classList.remove('show');
setTimeout(function() { toast.remove(); }, 300);
}, duration);
};
/**
* 날짜를 YYYY-MM-DD 형식으로 변환
*/
window.formatDate = function(dateString) {
if (!dateString) return '';
if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) return dateString;
var d = new Date(dateString);
if (isNaN(d.getTime())) return '';
var y = d.getFullYear();
var m = String(d.getMonth() + 1).padStart(2, '0');
var day = String(d.getDate()).padStart(2, '0');
return y + '-' + m + '-' + day;
};
/**
* apiCall이 로드될 때까지 대기
*/
window.waitForApi = function(timeout) {
timeout = timeout || 5000;
return new Promise(function(resolve, reject) {
if (window.apiCall) return resolve();
var elapsed = 0;
var iv = setInterval(function() {
elapsed += 50;
if (window.apiCall) { clearInterval(iv); resolve(); }
else if (elapsed >= timeout) { clearInterval(iv); reject(new Error('apiCall timeout')); }
}, 50);
});
};
/**
* UUID v4 생성
*/
window.generateUUID = function() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
var r = Math.random() * 16 | 0;
var v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
};
console.log('API 설정 완료:', window.API_BASE_URL);
})();

View File

@@ -1,205 +0,0 @@
// api-config.js - nginx 프록시 대응 API 설정
import { config } from './config.js';
import { redirectToLogin } from './navigation.js';
function getApiBaseUrl() {
const hostname = window.location.hostname;
const protocol = window.location.protocol;
const port = window.location.port;
// 🔗 외부 도메인 (Cloudflare Tunnel) - Gateway nginx가 /api/를 프록시
if (hostname.includes('technicalkorea.net')) {
const baseUrl = `${protocol}//${hostname}${config.api.path}`;
return baseUrl;
}
// 🔗 로컬/내부 네트워크 - API 포트 직접 접근
if (hostname.startsWith('192.168.') || hostname.startsWith('10.') || hostname.startsWith('172.') ||
hostname === 'localhost' || hostname === '127.0.0.1' ||
hostname.includes('.local') || hostname.includes('hyungi')) {
const baseUrl = `${protocol}//${hostname}:${config.api.port}${config.api.path}`;
return baseUrl;
}
// 🚨 기타: 포트 없이 상대 경로
const baseUrl = `${protocol}//${hostname}${config.api.path}`;
return baseUrl;
}
// API 설정
const API_URL = getApiBaseUrl();
// 전역 변수로 설정 (api-base.js가 이미 설정한 경우 유지)
if (!window.API) window.API = API_URL;
if (!window.API_BASE_URL) window.API_BASE_URL = API_URL;
function ensureAuthenticated() {
const token = localStorage.getItem('sso_token');
if (!token || token === 'undefined' || token === 'null') {
clearAuthData(); // 만약을 위해 한번 더 정리
redirectToLogin();
return false; // 이후 코드 실행 방지
}
// 토큰 만료 확인
if (isTokenExpired(token)) {
clearAuthData();
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
redirectToLogin();
return false;
}
return token;
}
// 토큰 만료 확인 함수
function isTokenExpired(token) {
try {
const b = atob(token.split('.')[1].replace(/-/g,'+').replace(/_/g,'/'));
const payload = JSON.parse(new TextDecoder().decode(Uint8Array.from(b, c => c.charCodeAt(0))));
const currentTime = Math.floor(Date.now() / 1000);
return payload.exp < currentTime;
} catch (error) {
console.error('토큰 파싱 오류:', error);
return true; // 파싱 실패 시 만료된 것으로 간주
}
}
// 인증 데이터 정리 함수
function clearAuthData() {
localStorage.removeItem('sso_token');
localStorage.removeItem('sso_user');
// SSO 쿠키도 삭제 (로그인 페이지 자동 리다이렉트 방지)
var cookieDomain = window.location.hostname.includes('technicalkorea.net')
? '; domain=.technicalkorea.net' : '';
document.cookie = 'sso_token=; path=/; max-age=0' + cookieDomain;
document.cookie = 'sso_user=; path=/; max-age=0' + cookieDomain;
document.cookie = 'sso_refresh_token=; path=/; max-age=0' + cookieDomain;
}
function getAuthHeaders() {
const token = localStorage.getItem('sso_token');
return {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
};
}
// 🔧 개선된 API 호출 함수 (에러 처리 강화)
async function apiCall(url, method = 'GET', data = null) {
// 상대 경로를 절대 경로로 변환
const fullUrl = url.startsWith('http') ? url : `${API}${url}`;
const options = {
method: method,
headers: {
'Content-Type': 'application/json',
...getAuthHeaders()
}
};
// POST/PUT 요청시 데이터 추가
if (data && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
options.body = JSON.stringify(data);
}
try {
const response = await fetch(fullUrl, options);
// 인증 만료 처리
if (response.status === 401) {
clearAuthData();
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
redirectToLogin();
throw new Error('인증에 실패했습니다.');
}
// 응답 실패 처리
if (!response.ok) {
let errorMessage = `HTTP ${response.status}`;
try {
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
const errorData = await response.json();
// 에러 메시지 추출 (여러 형식 지원)
if (typeof errorData === 'string') {
errorMessage = errorData;
} else if (errorData.error) {
errorMessage = typeof errorData.error === 'string'
? errorData.error
: JSON.stringify(errorData.error);
} else if (errorData.message) {
errorMessage = errorData.message;
} else if (errorData.details) {
errorMessage = errorData.details;
} else {
errorMessage = `HTTP ${response.status}: ${JSON.stringify(errorData)}`;
}
} else {
const errorText = await response.text();
errorMessage = errorText || errorMessage;
}
} catch (e) {
// 파싱 실패해도 HTTP 상태 코드는 전달
}
throw new Error(errorMessage);
}
const result = await response.json();
return result;
} catch (error) {
// 네트워크 오류 vs 서버 오류 구분
if (error.name === 'TypeError' && error.message.includes('fetch')) {
throw new Error('네트워크 연결 오류입니다. 인터넷 연결을 확인해주세요.');
}
throw error;
}
}
// API 헬퍼 함수들
async function apiGet(url) {
return apiCall(url, 'GET');
}
async function apiPost(url, data) {
return apiCall(url, 'POST', data);
}
async function apiPut(url, data) {
return apiCall(url, 'PUT', data);
}
async function apiDelete(url) {
return apiCall(url, 'DELETE');
}
// 전역 함수로 설정 (api-base.js가 이미 등록한 것은 덮어쓰지 않음)
window.ensureAuthenticated = ensureAuthenticated;
if (!window.getAuthHeaders) window.getAuthHeaders = getAuthHeaders;
if (!window.apiCall) window.apiCall = apiCall;
window.apiGet = apiGet;
window.apiPost = apiPost;
window.apiPut = apiPut;
window.apiDelete = apiDelete;
window.isTokenExpired = isTokenExpired;
if (!window.clearAuthData) window.clearAuthData = clearAuthData;
// 주기적으로 토큰 만료 확인 (5분마다)
setInterval(() => {
const token = localStorage.getItem('sso_token');
if (token && isTokenExpired(token)) {
clearAuthData();
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
redirectToLogin();
}
}, config.app.tokenRefreshInterval);
// ES6 모듈 export
export { API_URL as API_BASE_URL, API_URL as API, apiCall, getAuthHeaders };

File diff suppressed because it is too large Load Diff

View File

@@ -1,176 +0,0 @@
import { API, getAuthHeaders } from '/js/api-config.js';
const yearSel = document.getElementById('year');
const monthSel = document.getElementById('month');
const container = document.getElementById('attendanceTableContainer');
const holidays = [
'2025-01-01','2025-01-27','2025-01-28','2025-01-29','2025-01-30','2025-01-31',
'2025-03-01','2025-03-03','2025-05-01','2025-05-05','2025-05-06',
'2025-06-03','2025-06-06','2025-08-15','2025-10-03','2025-10-09','2025-12-25'
];
const leaveDefaults = {
'김두수':16,'임영규':16,'반치원':16,'황인용':16,'표영진':15,
'김윤섭':16,'이창호':16,'최광욱':16,'박현수':14,'조윤호':0
};
let workers = [];
// ✅ 셀렉트 박스 옵션 + 기본 선택 추가
function fillSelectOptions() {
const currentY = new Date().getFullYear();
const currentM = String(new Date().getMonth() + 1).padStart(2, '0');
for (let y = currentY; y <= currentY + 5; y++) {
const selected = y === currentY ? 'selected' : '';
yearSel.insertAdjacentHTML('beforeend', `<option value="${y}" ${selected}>${y}</option>`);
}
for (let m = 1; m <= 12; m++) {
const mm = String(m).padStart(2, '0');
const selected = mm === currentM ? 'selected' : '';
monthSel.insertAdjacentHTML('beforeend', `<option value="${mm}" ${selected}>${m}월</option>`);
}
}
// ✅ 작업자 목록 불러오기
async function fetchWorkers() {
try {
const res = await fetch(`${API}/workers`, { headers: getAuthHeaders() });
const allWorkers = await res.json();
// 활성화된 작업자만 필터링
workers = allWorkers.filter(worker => {
return worker.status === 'active' || worker.is_active === 1 || worker.is_active === true;
});
workers.sort((a, b) => a.user_id - b.user_id);
} catch (err) {
alert('작업자 불러오기 실패');
}
}
// ✅ 출근부 불러오기 (해당 연도 전체)
async function loadAttendance() {
const year = yearSel.value;
const month = monthSel.value;
if (!year || !month) return alert('연도와 월을 선택하세요');
const lastDay = new Date(+year, +month, 0).getDate();
const start = `${year}-01-01`;
const end = `${year}-12-31`;
try {
const res = await fetch(`${API}/workreports?start=${start}&end=${end}`, {
headers: getAuthHeaders()
});
const data = await res.json();
renderTable(data, year, month, lastDay);
} catch (err) {
alert('출근부 로딩 실패');
}
}
// ✅ 테이블 렌더링
function renderTable(data, year, month, lastDay) {
container.innerHTML = '';
const weekdays = ['일','월','화','수','목','금','토'];
const tbl = document.createElement('table');
// ⬆️ 헤더 구성
let thead = `<thead><tr><th rowspan="2">작업자</th>`;
for (let d = 1; d <= lastDay; d++) thead += `<th>${d}</th>`;
thead += `<th class="divider" rowspan="2">잔업합계</th><th rowspan="2">사용연차</th><th rowspan="2">잔여연차</th></tr><tr>`;
for (let d = 1; d <= lastDay; d++) {
const dow = new Date(+year, +month - 1, d).getDay();
thead += `<th>${weekdays[dow]}</th>`;
}
thead += '</tr></thead>';
tbl.innerHTML = thead;
// ⬇️ 본문
workers.forEach(w => {
// ✅ 월간 데이터 (표에 표시용)
const recsThisMonth = data.filter(r =>
r.user_id === w.user_id &&
new Date(r.date).getFullYear() === +year &&
new Date(r.date).getMonth() + 1 === +month
);
// ✅ 연간 데이터 (연차 계산용)
const recsThisYear = data.filter(r =>
r.user_id === w.user_id &&
new Date(r.date).getFullYear() === +year
);
let otSum = 0;
let row = `<tr><td>${w.worker_name}</td>`;
for (let d = 1; d <= lastDay; d++) {
const dd = String(d).padStart(2, '0');
const date = `${year}-${month}-${dd}`;
const rec = recsThisMonth.find(r => {
const rDate = new Date(r.date);
const yyyy = rDate.getFullYear();
const mm = String(rDate.getMonth() + 1).padStart(2, '0');
const dd = String(rDate.getDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}` === date;
});
const dow = new Date(+year, +month - 1, d).getDay();
const isWe = dow === 0 || dow === 6;
const isHo = holidays.includes(date);
let txt = '', cls = '';
if (rec) {
const ot = +rec.overtime_hours || 0;
if (ot > 0) {
txt = ot; cls = 'overtime-cell'; otSum += ot;
} else if (rec.work_details) {
const d = rec.work_details;
if (['연차','반차','반반차','조퇴'].includes(d)) {
txt = d; cls = 'leave';
} else if (d === '유급') {
txt = d; cls = 'paid-leave';
} else if (d === '휴무') {
txt = d; cls = 'holiday';
} else {
txt = d;
}
}
} else {
txt = (isWe || isHo) ? '휴무' : '';
cls = (isWe || isHo) ? 'holiday' : 'no-data';
}
row += `<td class="${cls}">${txt}</td>`;
}
const usedTot = recsThisYear
.filter(r => ['연차','반차','반반차','조퇴'].includes(r.work_details))
.reduce((s, r) => s + (
r.work_details === '연차' ? 1 :
r.work_details === '반차' ? 0.5 :
r.work_details === '반반차' ? 0.25 : 0.75
), 0);
const remain = (leaveDefaults[w.worker_name] || 0) - usedTot;
row += `<td class="divider overtime-sum">${otSum.toFixed(1)}</td>`;
row += `<td>${usedTot.toFixed(2)}</td><td>${remain.toFixed(2)}</td></tr>`;
row += `<tr class="separator"><td colspan="${lastDay + 4}"></td></tr>`;
tbl.insertAdjacentHTML('beforeend', row);
});
container.appendChild(tbl);
}
// ✅ 초기 로딩
fillSelectOptions();
fetchWorkers().then(() => {
loadAttendance(); // 자동 조회
});
document.getElementById('loadAttendance').addEventListener('click', loadAttendance);

View File

@@ -1,55 +0,0 @@
// js/auth.js
/**
* JWT 토큰을 디코딩하여 페이로드(내용)를 반환합니다.
* @param {string} token - JWT 토큰
* @returns {object|null} - 디코딩된 페이로드 객체 또는 파싱 실패 시 null
*/
export function parseJwt(token) {
try {
const b = atob(token.split('.')[1].replace(/-/g,'+').replace(/_/g,'/'));
return JSON.parse(new TextDecoder().decode(Uint8Array.from(b, c => c.charCodeAt(0))));
} catch (e) {
console.error("잘못된 토큰입니다.", e);
return null;
}
}
/**
* 인증 토큰을 가져옵니다 (쿠키 → localStorage 폴백).
*/
export function getToken() {
if (window.getSSOToken) return window.getSSOToken();
return null;
}
/**
* 사용자 정보를 가져옵니다 (쿠키 → localStorage 폴백).
*/
export function getUser() {
if (window.getSSOUser) return window.getSSOUser();
return null;
}
/**
* 로그인 성공 후 토큰과 사용자 정보를 저장합니다.
* sso_token/sso_user 키로 저장합니다.
*/
export function saveAuthData(token, user) {
// 쿠키 기반 인증 — localStorage 저장 불필요 (gateway에서 쿠키 설정)
}
/**
* 로그아웃 시 인증 정보를 제거합니다.
*/
export function clearAuthData() {
if (window.clearSSOAuth) { window.clearSSOAuth(); return; }
}
/**
* 현재 사용자가 로그인 상태인지 확인합니다.
*/
export function isLoggedIn() {
const token = getToken();
return !!token && token !== 'undefined' && token !== 'null';
}

View File

@@ -1,59 +0,0 @@
// ✅ /js/calendar.js
export function renderCalendar(containerId, onDateSelect) {
const container = document.getElementById(containerId);
if (!container) return;
let currentDate = new Date();
let selectedDateStr = '';
function drawCalendar(date) {
container.innerHTML = '';
const year = date.getFullYear();
const month = date.getMonth();
const firstDay = new Date(year, month, 1).getDay();
const lastDate = new Date(year, month + 1, 0).getDate();
const nav = document.createElement('div');
nav.className = 'nav';
const prev = document.createElement('button');
prev.textContent = '◀';
prev.addEventListener('click', () => {
currentDate = new Date(year, month - 1, 1);
drawCalendar(currentDate);
});
const title = document.createElement('div');
title.innerHTML = `<strong>${year}${month + 1}월</strong>`;
const next = document.createElement('button');
next.textContent = '▶';
next.addEventListener('click', () => {
currentDate = new Date(year, month + 1, 1);
drawCalendar(currentDate);
});
nav.append(prev, title, next);
container.appendChild(nav);
['일','월','화','수','목','금','토'].forEach(day => {
const el = document.createElement('div');
el.innerHTML = `<strong>${day}</strong>`;
container.appendChild(el);
});
for (let i = 0; i < firstDay; i++) container.appendChild(document.createElement('div'));
for (let i = 1; i <= lastDate; i++) {
const btn = document.createElement('button');
const ymd = `${year}-${String(month + 1).padStart(2, '0')}-${String(i).padStart(2, '0')}`;
btn.textContent = i;
btn.className = (ymd === selectedDateStr) ? 'selected-date' : '';
btn.addEventListener('click', () => {
selectedDateStr = ymd;
drawCalendar(currentDate);
onDateSelect(ymd);
});
container.appendChild(btn);
}
}
drawCalendar(currentDate);
}

View File

@@ -1,130 +0,0 @@
// js/change-password.js — 비밀번호 변경 (일반 스크립트, tkfb-core.js 전역 함수 사용)
(function() {
var form = document.getElementById('changePasswordForm');
var messageArea = document.getElementById('message-area');
var submitBtn = document.getElementById('submitBtn');
var resetBtn = document.getElementById('resetBtn');
if (!form) return;
// 비밀번호 토글
document.querySelectorAll('.password-toggle').forEach(function(button) {
button.addEventListener('click', function() {
var input = document.getElementById(this.getAttribute('data-target'));
if (input) {
var isPassword = input.type === 'password';
input.type = isPassword ? 'text' : 'password';
this.textContent = isPassword ? '숨기기' : '보기';
}
});
});
// 초기화
if (resetBtn) resetBtn.addEventListener('click', function() {
form.reset();
messageArea.innerHTML = '';
var s = document.getElementById('passwordStrength');
if (s) s.innerHTML = '';
});
function showMessage(type, msg) {
messageArea.innerHTML = '<div class="message-box ' + type + '">' +
(type === 'error' ? '&#10060; ' : '&#9989; ') + msg + '</div>';
if (type === 'error') setTimeout(function() { messageArea.innerHTML = ''; }, 5000);
}
// 비밀번호 강도 체크
var strengthTimer;
var newPwInput = document.getElementById('newPassword');
if (newPwInput) newPwInput.addEventListener('input', function() {
clearTimeout(strengthTimer);
var pw = this.value;
strengthTimer = setTimeout(function() {
if (!pw) { document.getElementById('passwordStrength').innerHTML = ''; return; }
var token = (window.getSSOToken && window.getSSOToken()) || localStorage.getItem('sso_token') || '';
fetch('/api/auth/check-password-strength', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ password: pw })
}).then(function(r) { return r.json(); }).then(function(result) {
if (!result.success) return;
var d = result.data;
var colors = { weak: '#f44336', medium: '#ffc107', strong: '#4caf50' };
var labels = { weak: '약함', medium: '보통', strong: '강함' };
var pct = (d.score / 5) * 100;
document.getElementById('passwordStrength').innerHTML =
'<div style="margin-top:10px"><div style="display:flex;justify-content:space-between;margin-bottom:4px">' +
'<span style="font-size:0.85rem;color:' + (colors[d.level]||'#ccc') + ';font-weight:500">' + (labels[d.level]||'') + '</span>' +
'<span style="font-size:0.8rem;color:#666">' + d.score + '/5</span></div>' +
'<div style="height:6px;background:#e0e0e0;border-radius:3px;overflow:hidden">' +
'<div style="width:' + pct + '%;height:100%;background:' + (colors[d.level]||'#ccc') + ';transition:all 0.3s"></div></div></div>';
}).catch(function() {});
}, 300);
});
// 폼 제출
form.addEventListener('submit', function(e) {
e.preventDefault();
messageArea.innerHTML = '';
var currentPassword = document.getElementById('currentPassword').value;
var newPassword = document.getElementById('newPassword').value;
var confirmPassword = document.getElementById('confirmPassword').value;
if (!currentPassword || !newPassword || !confirmPassword) {
showMessage('error', '모든 필드를 입력해주세요.');
return;
}
if (newPassword !== confirmPassword) {
showMessage('error', '새 비밀번호가 일치하지 않습니다.');
return;
}
if (newPassword.length < 6) {
showMessage('error', '비밀번호는 최소 6자 이상이어야 합니다.');
return;
}
if (currentPassword === newPassword) {
showMessage('error', '새 비밀번호는 현재 비밀번호와 달라야 합니다.');
return;
}
var originalText = submitBtn.innerHTML;
submitBtn.disabled = true;
submitBtn.innerHTML = '처리 중...';
var token = (window.getSSOToken && window.getSSOToken()) || localStorage.getItem('sso_token') || '';
fetch('/api/auth/change-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ currentPassword: currentPassword, newPassword: newPassword })
}).then(function(res) {
return res.json().then(function(data) { return { ok: res.ok, data: data }; });
}).then(function(result) {
if (result.ok && result.data.success) {
showMessage('success', '비밀번호가 변경되었습니다.');
form.reset();
var s = document.getElementById('passwordStrength');
if (s) s.innerHTML = '';
var countdown = 3;
var interval = setInterval(function() {
showMessage('success', '비밀번호가 변경되었습니다. ' + countdown + '초 후 로그인 페이지로 이동합니다.');
countdown--;
if (countdown < 0) {
clearInterval(interval);
// 쿠키 + localStorage 전부 삭제 (doLogout 로직 재사용)
_cookieRemove('sso_token'); _cookieRemove('sso_user'); _cookieRemove('sso_refresh_token');
['sso_token','sso_user','sso_refresh_token','token','user','access_token','currentUser','current_user','userInfo','userPageAccess'].forEach(function(k) { localStorage.removeItem(k); });
window.location.href = (window.getLoginUrl ? window.getLoginUrl() : '/login') + '&logout=1';
}
}, 1000);
} else {
showMessage('error', result.data.message || result.data.error || '비밀번호 변경에 실패했습니다.');
}
}).catch(function() {
showMessage('error', '서버와의 연결에 실패했습니다. 잠시 후 다시 시도해주세요.');
}).finally(function() {
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
});
});
})();

View File

@@ -1,56 +0,0 @@
/**
* BaseState - 상태 관리 베이스 클래스
* TbmState / DailyWorkReportState 공통 패턴
*/
class BaseState {
constructor() {
this.listeners = new Map();
}
/**
* 상태 업데이트 + 리스너 알림
*/
update(key, value) {
const prevValue = this[key];
this[key] = value;
this.notifyListeners(key, value, prevValue);
}
/**
* 리스너 등록
*/
subscribe(key, callback) {
if (!this.listeners.has(key)) {
this.listeners.set(key, []);
}
this.listeners.get(key).push(callback);
}
/**
* 리스너 알림
*/
notifyListeners(key, newValue, prevValue) {
const keyListeners = this.listeners.get(key) || [];
keyListeners.forEach(callback => {
try {
callback(newValue, prevValue);
} catch (error) {
console.error(`[${this.constructor.name}] 리스너 오류 (${key}):`, error);
}
});
}
/**
* 현재 사용자 정보 (localStorage)
*/
getUser() {
const userInfo = localStorage.getItem('sso_user');
return userInfo ? JSON.parse(userInfo) : null;
}
}
// 전역 노출
window.BaseState = BaseState;
console.log('[Module] common/base-state.js 로드 완료');

View File

@@ -1,144 +0,0 @@
/**
* Common Utilities
* TBM/작업보고 공통 유틸리티 함수
*/
class CommonUtils {
/**
* 서울 시간대(Asia/Seoul, UTC+9) 기준 오늘 날짜를 YYYY-MM-DD 형식으로 반환
*/
getTodayKST() {
const now = new Date();
const kstOffset = 9 * 60;
const utc = now.getTime() + (now.getTimezoneOffset() * 60000);
const kstTime = new Date(utc + (kstOffset * 60000));
const year = kstTime.getFullYear();
const month = String(kstTime.getMonth() + 1).padStart(2, '0');
const day = String(kstTime.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* 날짜를 YYYY-MM-DD 형식으로 변환 (문자열 또는 Date 객체)
*/
formatDate(date) {
if (!date) return '';
// 이미 YYYY-MM-DD 형식이면 그대로 반환
if (typeof date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(date)) {
return date;
}
const dateObj = date instanceof Date ? date : new Date(date);
const year = dateObj.getFullYear();
const month = String(dateObj.getMonth() + 1).padStart(2, '0');
const day = String(dateObj.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* 요일 반환 (일/월/화/수/목/금/토)
*/
getDayOfWeek(date) {
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
const dateObj = date instanceof Date ? date : new Date(date instanceof String || typeof date === 'string' ? date + 'T00:00:00' : date);
return dayNames[dateObj.getDay()];
}
/**
* 오늘인지 확인
*/
isToday(date) {
return this.formatDate(date) === this.getTodayKST();
}
/**
* UUID v4 생성
*/
generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
/**
* HTML 이스케이프
*/
escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* 디바운스
*/
debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
/**
* 쓰로틀
*/
throttle(func, limit) {
let inThrottle;
return function(...args) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
}
/**
* 객체 깊은 복사
*/
deepClone(obj) {
return JSON.parse(JSON.stringify(obj));
}
/**
* 빈 값 확인
*/
isEmpty(value) {
if (value === null || value === undefined) return true;
if (typeof value === 'string') return value.trim() === '';
if (Array.isArray(value)) return value.length === 0;
if (typeof value === 'object') return Object.keys(value).length === 0;
return false;
}
/**
* 배열 그룹화
*/
groupBy(array, key) {
return array.reduce((result, item) => {
const groupKey = typeof key === 'function' ? key(item) : item[key];
if (!result[groupKey]) {
result[groupKey] = [];
}
result[groupKey].push(item);
return result;
}, {});
}
}
// 전역 인스턴스 생성
window.CommonUtils = new CommonUtils();
console.log('[Module] common/utils.js 로드 완료');

View File

@@ -1,42 +0,0 @@
// /js/config.js
// ES6 모듈을 사용하여 설정을 내보냅니다.
// 이 파일을 통해 프로젝트의 모든 하드코딩된 값을 관리합니다.
export const config = {
// API 관련 설정
api: {
// 로컬 개발 및 Docker 환경에서 사용하는 API 서버 포트
port: 30005,
// API의 기본 경로
path: '/api',
},
// 페이지 경로 설정
paths: {
// 로그인 페이지 경로
loginPage: '/login',
// 메인 대시보드 경로 (모든 사용자 공통)
dashboard: '/pages/dashboard.html',
// 하위 호환성을 위한 별칭들
defaultDashboard: '/pages/dashboard.html',
systemDashboard: '/pages/dashboard.html',
groupLeaderDashboard: '/pages/dashboard.html',
},
// 공용 컴포넌트 경로 설정
components: {
// 사이드바 HTML 파일 경로 (구버전)
sidebar: '/components/sidebar.html',
// 새 사이드바 네비게이션 (카테고리별)
'sidebar-nav': '/components/sidebar-nav.html',
// 네비게이션 바 HTML 파일 경로
navbar: '/components/navbar.html',
},
// 애플리케이션 관련 기타 설정
app: {
// 토큰 만료 확인 주기 (밀리초 단위, 예: 5분)
tokenRefreshInterval: 5 * 60 * 1000,
}
};

View File

@@ -1,71 +0,0 @@
// /js/daily-issue-api.js
import { apiGet, apiPost } from './api-helper.js';
/**
* 이슈 보고서 작성을 위해 필요한 초기 데이터(프로젝트, 이슈 유형)를 가져옵니다.
* @returns {Promise<{projects: Array, issueTypes: Array}>}
*/
export async function getInitialData() {
try {
const [projects, issueTypes] = await Promise.all([
apiGet('/projects'),
apiGet('/issue-types')
]);
return { projects, issueTypes };
} catch (error) {
console.error('이슈 보고서 초기 데이터 로딩 실패:', error);
throw error;
}
}
/**
* 특정 날짜에 근무한 작업자 목록을 가져옵니다.
* @param {string} date - 조회할 날짜 (YYYY-MM-DD)
* @returns {Promise<Array>} - 작업자 목록
*/
export async function getWorkersByDate(date) {
try {
// 백엔드에 해당 날짜의 작업자 목록을 요청하는 API가 있다고 가정합니다.
// (예: /api/workers?work_date=YYYY-MM-DD)
// 현재는 기존 로직을 최대한 활용하여 구현합니다.
let workers = [];
const reports = await apiGet(`/daily-work-reports?date=${date}`);
if (reports && reports.length > 0) {
const workerMap = new Map();
reports.forEach(r => {
if (!workerMap.has(r.user_id)) {
workerMap.set(r.user_id, { user_id: r.user_id, worker_name: r.worker_name });
}
});
workers = Array.from(workerMap.values());
} else {
// 보고서가 없으면 전체 작업자 목록을 가져옵니다.
const allWorkers = await apiGet('/workers');
// 활성화된 작업자만 필터링
workers = allWorkers.filter(worker => {
return worker.status === 'active' || worker.is_active === 1 || worker.is_active === true;
});
}
return workers.sort((a, b) => a.worker_name.localeCompare(b.worker_name));
} catch (error) {
console.error(`${date}의 작업자 목록 로딩 실패:`, error);
throw error;
}
}
/**
* 작성된 이슈 보고서 데이터를 서버에 전송합니다.
* @param {object} issueData - 전송할 이슈 데이터
* @returns {Promise<object>} - 서버 응답 결과
*/
export async function createIssueReport(issueData) {
try {
const result = await apiPost('/issue-reports', issueData);
return result;
} catch (error) {
console.error('이슈 보고서 생성 요청 실패:', error);
throw error;
}
}

View File

@@ -1,103 +0,0 @@
// /js/daily-issue-ui.js
const DOM = {
dateSelect: document.getElementById('dateSelect'),
projectSelect: document.getElementById('projectSelect'),
issueTypeSelect: document.getElementById('issueTypeSelect'),
timeStart: document.getElementById('timeStart'),
timeEnd: document.getElementById('timeEnd'),
workerList: document.getElementById('workerList'),
form: document.getElementById('issueForm'),
submitBtn: document.getElementById('submitBtn'),
};
function createOption(value, text) {
const option = document.createElement('option');
option.value = value;
option.textContent = text;
return option;
}
export function populateProjects(projects) {
DOM.projectSelect.innerHTML = '<option value="">-- 프로젝트 선택 --</option>';
if (Array.isArray(projects)) {
projects.forEach(p => DOM.projectSelect.appendChild(createOption(p.project_id, p.project_name)));
}
}
export function populateIssueTypes(issueTypes) {
DOM.issueTypeSelect.innerHTML = '<option value="">-- 이슈 유형 선택 --</option>';
if (Array.isArray(issueTypes)) {
issueTypes.forEach(t => DOM.issueTypeSelect.appendChild(createOption(t.issue_type_id, `${t.category}:${t.subcategory}`)));
}
}
export function populateTimeOptions() {
for (let h = 0; h < 24; h++) {
for (let m of [0, 30]) {
const time = `${String(h).padStart(2, '0')}:${m === 0 ? '00' : '30'}`;
DOM.timeStart.appendChild(createOption(time, time));
DOM.timeEnd.appendChild(createOption(time, time.replace('00:00', '24:00')));
}
}
DOM.timeEnd.value = "24:00"; // 기본값 설정
}
export function renderWorkerList(workers) {
DOM.workerList.innerHTML = '';
if (!Array.isArray(workers) || workers.length === 0) {
DOM.workerList.textContent = '선택 가능한 작업자가 없습니다.';
return;
}
workers.forEach(worker => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn';
btn.textContent = worker.worker_name;
btn.dataset.id = worker.user_id;
btn.addEventListener('click', () => btn.classList.toggle('selected'));
DOM.workerList.appendChild(btn);
});
}
export function getFormData() {
const selectedWorkers = [...DOM.workerList.querySelectorAll('.btn.selected')].map(b => b.dataset.id);
if (selectedWorkers.length === 0) {
alert('작업자를 한 명 이상 선택해주세요.');
return null;
}
if (DOM.timeEnd.value <= DOM.timeStart.value) {
alert('종료 시간은 시작 시간보다 이후여야 합니다.');
return null;
}
const formData = new FormData(DOM.form);
const data = {
date: formData.get('dateSelect'), // input name 속성이 없어 직접 가져옴
project_id: DOM.projectSelect.value,
issue_type_id: DOM.issueTypeSelect.value,
start_time: DOM.timeStart.value,
end_time: DOM.timeEnd.value,
user_ids: selectedWorkers, // user_id -> user_ids 로 명확하게 변경
};
for (const key in data) {
if (!data[key] || (Array.isArray(data[key]) && data[key].length === 0)) {
alert('모든 필수 항목을 입력해주세요.');
return null;
}
}
return data;
}
export function setSubmitButtonState(isLoading) {
if (isLoading) {
DOM.submitBtn.disabled = true;
DOM.submitBtn.textContent = '등록 중...';
} else {
DOM.submitBtn.disabled = false;
DOM.submitBtn.textContent = '등록';
}
}

View File

@@ -1,89 +0,0 @@
// /js/daily-issue.js
import { getInitialData, getWorkersByDate, createIssueReport } from './daily-issue-api.js';
import {
populateProjects,
populateIssueTypes,
populateTimeOptions,
renderWorkerList,
getFormData,
setSubmitButtonState
} from './daily-issue-ui.js';
const dateSelect = document.getElementById('dateSelect');
const form = document.getElementById('issueForm');
/**
* 날짜가 변경될 때마다 해당 날짜의 작업자 목록을 다시 불러옵니다.
*/
async function handleDateChange() {
const selectedDate = dateSelect.value;
if (!selectedDate) {
document.getElementById('workerList').textContent = '날짜를 먼저 선택하세요.';
return;
}
document.getElementById('workerList').textContent = '작업자 목록을 불러오는 중...';
try {
const workers = await getWorkersByDate(selectedDate);
renderWorkerList(workers);
} catch (error) {
document.getElementById('workerList').textContent = '작업자 목록 로딩에 실패했습니다.';
}
}
/**
* 폼 제출 이벤트를 처리합니다.
*/
async function handleSubmit(event) {
event.preventDefault();
const issueData = getFormData();
if (!issueData) return; // 유효성 검사 실패
setSubmitButtonState(true);
try {
const result = await createIssueReport(issueData);
if (result.success) {
alert('✅ 이슈가 성공적으로 등록되었습니다.');
form.reset(); // 폼 초기화
dateSelect.value = new Date().toISOString().split('T')[0]; // 날짜 오늘로 리셋
handleDateChange(); // 작업자 목록 새로고침
} else {
throw new Error(result.error || '알 수 없는 오류가 발생했습니다.');
}
} catch (error) {
alert(`🚨 등록 실패: ${error.message}`);
} finally {
setSubmitButtonState(false);
}
}
/**
* 페이지 초기화 함수
*/
async function initializePage() {
// 오늘 날짜 기본 설정
dateSelect.value = new Date().toISOString().split('T')[0];
populateTimeOptions();
// 프로젝트, 이슈유형, 작업자 목록을 병렬로 로드
try {
const [initialData] = await Promise.all([
getInitialData(),
handleDateChange() // 초기 작업자 목록 로드
]);
populateProjects(initialData.projects);
populateIssueTypes(initialData.issueTypes);
} catch (error) {
alert('페이지 초기화 중 오류가 발생했습니다. 새로고침 해주세요.');
}
// 이벤트 리스너 설정
dateSelect.addEventListener('change', handleDateChange);
form.addEventListener('submit', handleSubmit);
}
// DOM이 로드되면 페이지 초기화를 시작합니다.
document.addEventListener('DOMContentLoaded', initializePage);

File diff suppressed because it is too large Load Diff

View File

@@ -1,300 +0,0 @@
/**
* daily-status.js — 일별 TBM/작업보고서 입력 현황 대시보드
* Sprint 002 Section B
*/
// ===== Mock 설정 =====
const MOCK_ENABLED = false;
const MOCK_DATA = {
success: true,
data: {
date: '2026-03-30',
summary: {
total_active_workers: 45, tbm_completed: 38, tbm_missing: 7,
report_completed: 35, report_missing: 10, both_completed: 33, both_missing: 5
},
workers: [
{ user_id: 15, worker_name: '김철수', job_type: '용접', department_name: '생산1팀', has_tbm: false, has_report: false, tbm_session_id: null, total_report_hours: 0, status: 'both_missing', proxy_history: null },
{ user_id: 22, worker_name: '이영희', job_type: '배관', department_name: '생산2팀', has_tbm: true, has_report: false, tbm_session_id: 140, total_report_hours: 0, status: 'tbm_only', proxy_history: null },
{ user_id: 30, worker_name: '박민수', job_type: '전기', department_name: '생산1팀', has_tbm: true, has_report: true, tbm_session_id: 141, total_report_hours: 8, status: 'complete', proxy_history: { proxy_by: '관리자', proxy_at: '2026-03-30T14:30:00' } },
{ user_id: 35, worker_name: '정대호', job_type: '도장', department_name: '생산2팀', has_tbm: false, has_report: true, tbm_session_id: null, total_report_hours: 8, status: 'report_only', proxy_history: null },
{ user_id: 40, worker_name: '최윤서', job_type: '용접', department_name: '생산1팀', has_tbm: true, has_report: true, tbm_session_id: 142, total_report_hours: 9, status: 'complete', proxy_history: null },
{ user_id: 41, worker_name: '한지민', job_type: '사상', department_name: '생산2팀', has_tbm: false, has_report: false, tbm_session_id: null, total_report_hours: 0, status: 'both_missing', proxy_history: null },
{ user_id: 42, worker_name: '송민호', job_type: '절단', department_name: '생산1팀', has_tbm: true, has_report: true, tbm_session_id: 143, total_report_hours: 8, status: 'complete', proxy_history: null },
]
}
};
const MOCK_DETAIL = {
success: true,
data: {
worker: { user_id: 15, worker_name: '김철수', job_type: '용접', department_name: '생산1팀' },
tbm_sessions: [],
work_reports: [],
proxy_history: []
}
};
// ===== State =====
let currentDate = new Date();
let workers = [];
let currentFilter = 'all';
let selectedWorkerId = null;
const DAYS_KR = ['일요일', '월요일', '화요일', '수요일', '목요일', '금요일', '토요일'];
const ALLOWED_ROLES = ['support_team', 'admin', 'system'];
// ===== Init =====
document.addEventListener('DOMContentLoaded', async () => {
// URL 파라미터에서 날짜 가져오기
const urlDate = new URLSearchParams(location.search).get('date');
if (urlDate) currentDate = new Date(urlDate + 'T00:00:00');
// 권한 체크 (initAuth 완료 후)
setTimeout(() => {
const user = window.currentUser;
if (user && !ALLOWED_ROLES.includes(user.role)) {
document.getElementById('workerList').classList.add('hidden');
document.getElementById('bottomAction').classList.add('hidden');
document.getElementById('noPermission').classList.remove('hidden');
return;
}
loadStatus();
}, 500);
});
// ===== Date Navigation =====
function formatDateStr(d) {
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
}
function updateDateDisplay() {
const str = formatDateStr(currentDate);
document.getElementById('dateText').textContent = str;
document.getElementById('dayText').textContent = DAYS_KR[currentDate.getDay()];
// 미래 날짜 비활성
const today = new Date();
today.setHours(0, 0, 0, 0);
const nextBtn = document.getElementById('nextDate');
nextBtn.disabled = currentDate >= today;
}
function changeDate(delta) {
currentDate.setDate(currentDate.getDate() + delta);
updateDateDisplay();
loadStatus();
}
function openDatePicker() {
const picker = document.getElementById('datePicker');
picker.value = formatDateStr(currentDate);
picker.max = formatDateStr(new Date());
picker.showPicker ? picker.showPicker() : picker.click();
}
function onDatePicked(val) {
if (!val) return;
currentDate = new Date(val + 'T00:00:00');
updateDateDisplay();
loadStatus();
}
// ===== Data Loading =====
async function loadStatus() {
const listEl = document.getElementById('workerList');
listEl.innerHTML = '<div class="ds-skeleton"></div><div class="ds-skeleton"></div><div class="ds-skeleton"></div>';
document.getElementById('emptyState').classList.add('hidden');
updateDateDisplay();
try {
let res;
if (MOCK_ENABLED) {
res = MOCK_DATA;
} else {
res = await window.apiCall('/proxy-input/daily-status?date=' + formatDateStr(currentDate));
}
if (!res || !res.success) {
listEl.innerHTML = '<div class="ds-empty"><p>데이터를 불러올 수 없습니다</p></div>';
return;
}
workers = res.data.workers || [];
updateSummary(res.data.summary || {});
updateFilterCounts();
renderWorkerList();
} catch (e) {
listEl.innerHTML = '<div class="ds-empty"><i class="fas fa-exclamation-triangle text-2xl text-red-300"></i><p>네트워크 오류. 다시 시도해주세요.</p></div>';
}
}
function updateSummary(s) {
document.getElementById('totalCount').textContent = s.total_active_workers || 0;
document.getElementById('doneCount').textContent = s.both_completed || 0;
document.getElementById('missingCount').textContent = s.both_missing || 0;
const total = s.total_active_workers || 1;
document.getElementById('donePct').textContent = Math.round((s.both_completed || 0) / total * 100) + '%';
document.getElementById('missingPct').textContent = Math.round((s.both_missing || 0) / total * 100) + '%';
// 하단 버튼 카운트
const missingWorkers = workers.filter(w => w.status !== 'complete').length;
document.getElementById('proxyCount').textContent = missingWorkers;
document.getElementById('proxyBtn').disabled = missingWorkers === 0;
}
function updateFilterCounts() {
document.getElementById('filterAll').textContent = workers.length;
document.getElementById('filterComplete').textContent = workers.filter(w => w.status === 'complete').length;
document.getElementById('filterMissing').textContent = workers.filter(w => w.status === 'both_missing').length;
document.getElementById('filterPartial').textContent = workers.filter(w => w.status === 'tbm_only' || w.status === 'report_only').length;
}
// ===== Filter =====
function setFilter(f) {
currentFilter = f;
document.querySelectorAll('.ds-tab').forEach(tab => {
tab.classList.toggle('active', tab.dataset.filter === f);
});
renderWorkerList();
}
// ===== Render =====
function renderWorkerList() {
const listEl = document.getElementById('workerList');
const emptyEl = document.getElementById('emptyState');
let filtered = workers;
if (currentFilter === 'complete') filtered = workers.filter(w => w.status === 'complete');
else if (currentFilter === 'both_missing') filtered = workers.filter(w => w.status === 'both_missing');
else if (currentFilter === 'partial') filtered = workers.filter(w => w.status === 'tbm_only' || w.status === 'report_only');
if (filtered.length === 0) {
listEl.innerHTML = '';
emptyEl.classList.remove('hidden');
return;
}
emptyEl.classList.add('hidden');
listEl.innerHTML = filtered.map(w => {
const tbmBadge = w.has_tbm
? '<span class="ds-badge-ok">TBM ✓</span>'
: '<span class="ds-badge-no">TBM ✗</span>';
const reportBadge = w.has_report
? `<span class="ds-badge-ok">보고서 ✓${w.total_report_hours ? ' ' + w.total_report_hours + 'h' : ''}</span>`
: '<span class="ds-badge-no">보고서 ✗</span>';
const isProxy = w.tbm_sessions?.some(t => t.is_proxy_input) || false;
const proxyBadge = isProxy
? '<span class="ds-badge-proxy">대리입력</span>'
: '';
return `
<div class="ds-worker-row" onclick="openSheet(${w.user_id})">
<div class="ds-status-dot ${w.status}"></div>
<div class="ds-worker-info">
<div class="ds-worker-name">${escHtml(w.worker_name)}</div>
<div class="ds-worker-dept">${escHtml(w.job_type)} · ${escHtml(w.department_name)}</div>
</div>
<div class="ds-worker-status">${tbmBadge}${reportBadge}${proxyBadge}</div>
</div>`;
}).join('');
}
function escHtml(s) { return (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
// ===== Bottom Sheet =====
function openSheet(userId) {
selectedWorkerId = userId;
const w = workers.find(x => x.user_id === userId);
if (!w) return;
document.getElementById('sheetWorkerName').textContent = w.worker_name;
document.getElementById('sheetWorkerInfo').textContent = `${w.job_type} · ${w.department_name}`;
document.getElementById('sheetBody').innerHTML = '<div class="ds-sheet-loading"><i class="fas fa-spinner fa-spin"></i> 로딩 중...</div>';
document.getElementById('sheetOverlay').classList.remove('hidden');
document.getElementById('detailSheet').classList.remove('hidden');
setTimeout(() => document.getElementById('detailSheet').classList.add('open'), 10);
// 상세 데이터 로드
loadDetail(userId, w);
}
async function loadDetail(userId, workerBasic) {
const bodyEl = document.getElementById('sheetBody');
try {
let res;
if (MOCK_ENABLED) {
res = JSON.parse(JSON.stringify(MOCK_DETAIL));
res.data.worker = workerBasic;
// mock: complete 상태면 TBM/보고서 데이터 채우기
if (workerBasic.has_tbm) {
res.data.tbm_sessions = [{ session_id: workerBasic.tbm_session_id, session_date: formatDateStr(currentDate), status: 'completed', leader_name: '반장' }];
}
if (workerBasic.has_report) {
res.data.work_reports = [{ report_date: formatDateStr(currentDate), project_name: '프로젝트A', work_type_name: workerBasic.job_type, work_hours: workerBasic.total_report_hours }];
}
if (workerBasic.proxy_history) {
res.data.proxy_history = [workerBasic.proxy_history];
}
} else {
res = await window.apiCall('/proxy-input/daily-status/detail?date=' + formatDateStr(currentDate) + '&user_id=' + userId);
}
if (!res || !res.success) { bodyEl.innerHTML = '<div class="ds-sheet-card empty">상세 정보를 불러올 수 없습니다</div>'; return; }
const d = res.data;
let html = '';
// TBM 섹션
html += '<div class="ds-sheet-section"><div class="ds-sheet-section-title"><i class="fas fa-clipboard-check"></i> TBM</div>';
if (d.tbm_sessions && d.tbm_sessions.length > 0) {
html += d.tbm_sessions.map(s => {
const proxyTag = s.is_proxy_input ? ` · <span class="ds-badge-proxy">대리입력(${escHtml(s.proxy_input_by_name || '-')})</span>` : '';
return `<div class="ds-sheet-card">세션 #${s.session_id} · ${s.status === 'completed' ? '완료' : '진행중'} · 리더: ${escHtml(s.leader_name || '-')}${proxyTag}</div>`;
}).join('');
} else {
html += '<div class="ds-sheet-card empty">세션 없음</div>';
}
html += '</div>';
// 작업보고서 섹션
html += '<div class="ds-sheet-section"><div class="ds-sheet-section-title"><i class="fas fa-file-alt"></i> 작업보고서</div>';
if (d.work_reports && d.work_reports.length > 0) {
html += d.work_reports.map(r => `<div class="ds-sheet-card">${escHtml(r.project_name || '-')} · ${escHtml(r.work_type_name || '-')} · ${r.work_hours || 0}시간</div>`).join('');
} else {
html += '<div class="ds-sheet-card empty">보고서 없음</div>';
}
html += '</div>';
bodyEl.innerHTML = html;
// 완료 상태면 대리입력 버튼 숨김
const btn = document.getElementById('sheetProxyBtn');
btn.style.display = workerBasic.status === 'complete' ? 'none' : 'block';
} catch (e) {
bodyEl.innerHTML = '<div class="ds-sheet-card empty">네트워크 오류</div>';
}
}
function closeSheet() {
document.getElementById('detailSheet').classList.remove('open');
setTimeout(() => {
document.getElementById('sheetOverlay').classList.add('hidden');
document.getElementById('detailSheet').classList.add('hidden');
}, 300);
}
// ===== Navigation =====
function goProxyInput() {
location.href = '/pages/work/proxy-input.html?date=' + formatDateStr(currentDate);
}
function goProxyInputSingle() {
if (selectedWorkerId) {
location.href = '/pages/work/proxy-input.html?date=' + formatDateStr(currentDate) + '&user_id=' + selectedWorkerId;
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,386 +0,0 @@
/**
* Daily Work Report - API Client
* 작업보고서 관련 모든 API 호출을 관리
*/
class DailyWorkReportAPI {
constructor() {
this.state = window.DailyWorkReportState;
console.log('[API] DailyWorkReportAPI 초기화');
}
/**
* 작업자 로드 (생산팀 소속)
*/
async loadWorkers() {
try {
console.log('[API] Workers 로딩 중...');
const data = await window.apiCall('/workers?limit=1000&department_id=1');
const allWorkers = Array.isArray(data) ? data : (data.data || data.workers || []);
// 퇴사자만 제외
const filtered = allWorkers.filter(worker => worker.employment_status !== 'resigned');
this.state.workers = filtered;
console.log(`[API] Workers 로드 완료: ${filtered.length}`);
return filtered;
} catch (error) {
console.error('[API] 작업자 로딩 오류:', error);
throw error;
}
}
/**
* 프로젝트 로드 (활성 프로젝트만)
*/
async loadProjects() {
try {
console.log('[API] Projects 로딩 중...');
const data = await window.apiCall('/projects/active/list');
const projects = Array.isArray(data) ? data : (data.data || data.projects || []);
this.state.projects = projects;
console.log(`[API] Projects 로드 완료: ${projects.length}`);
return projects;
} catch (error) {
console.error('[API] 프로젝트 로딩 오류:', error);
throw error;
}
}
/**
* 작업 유형 로드
*/
async loadWorkTypes() {
try {
const data = await window.apiCall('/daily-work-reports/work-types');
if (Array.isArray(data) && data.length > 0) {
this.state.workTypes = data;
console.log('[API] 작업 유형 로드 완료:', data.length);
return data;
}
throw new Error('API 실패');
} catch (error) {
console.log('[API] 작업 유형 API 사용 불가, 기본값 사용');
this.state.workTypes = [
{ id: 1, name: 'Base' },
{ id: 2, name: 'Vessel' },
{ id: 3, name: 'Piping' }
];
return this.state.workTypes;
}
}
/**
* 업무 상태 유형 로드
*/
async loadWorkStatusTypes() {
try {
const data = await window.apiCall('/daily-work-reports/work-status-types');
if (Array.isArray(data) && data.length > 0) {
this.state.workStatusTypes = data;
console.log('[API] 업무 상태 유형 로드 완료:', data.length);
return data;
}
throw new Error('API 실패');
} catch (error) {
console.log('[API] 업무 상태 유형 API 사용 불가, 기본값 사용');
this.state.workStatusTypes = [
{ id: 1, name: '정상', is_error: false },
{ id: 2, name: '부적합', is_error: true }
];
return this.state.workStatusTypes;
}
}
/**
* 오류 유형 로드 (신고 카테고리/아이템)
*/
async loadErrorTypes() {
try {
// 1. 신고 카테고리 (nonconformity만)
const categoriesResponse = await window.apiCall('/work-issues/categories');
if (categoriesResponse.success && categoriesResponse.data) {
this.state.issueCategories = categoriesResponse.data.filter(
c => c.category_type === 'nonconformity'
);
console.log('[API] 신고 카테고리 로드:', this.state.issueCategories.length);
}
// 2. 신고 아이템 전체
const itemsResponse = await window.apiCall('/work-issues/items');
if (itemsResponse.success && itemsResponse.data) {
// nonconformity 카테고리의 아이템만 필터링
const nonconfCatIds = this.state.issueCategories.map(c => c.category_id);
this.state.issueItems = itemsResponse.data.filter(
item => nonconfCatIds.includes(item.category_id)
);
console.log('[API] 신고 아이템 로드:', this.state.issueItems.length);
}
// 레거시 호환: errorTypes에 카테고리 매핑
this.state.errorTypes = this.state.issueCategories.map(cat => ({
id: cat.category_id,
name: cat.category_name
}));
} catch (error) {
console.error('[API] 오류 유형 로딩 오류:', error);
// 기본값 설정
this.state.errorTypes = [
{ id: 1, name: '자재 부적합' },
{ id: 2, name: '도면 오류' },
{ id: 3, name: '장비 고장' }
];
}
}
/**
* 미완료 TBM 세션 로드
*/
async loadIncompleteTbms() {
try {
const response = await window.apiCall('/tbm/sessions/incomplete-reports');
if (!response.success) {
throw new Error(response.message || '미완료 TBM 조회 실패');
}
let data = response.data || [];
// 사용자 권한 확인 및 필터링
const user = this.state.getUser();
if (user && user.role !== 'Admin' && user.access_level !== 'system') {
const userId = user.user_id;
data = data.filter(tbm => tbm.created_by === userId);
}
this.state.incompleteTbms = data;
console.log('[API] 미완료 TBM 로드 완료:', data.length);
return data;
} catch (error) {
console.error('[API] 미완료 TBM 로드 오류:', error);
throw error;
}
}
/**
* TBM 세션별 당일 신고 로드
*/
async loadDailyIssuesForTbms() {
const tbms = this.state.incompleteTbms;
if (!tbms || tbms.length === 0) {
console.log('[API] 미완료 TBM 없음, 신고 조회 건너뜀');
return;
}
// 고유한 날짜 수집
const uniqueDates = [...new Set(tbms.map(tbm => {
return window.DailyWorkReportUtils?.formatDateForApi(tbm.session_date) ||
this.formatDateForApi(tbm.session_date);
}).filter(Boolean))];
console.log('[API] 조회할 날짜들:', uniqueDates);
for (const dateStr of uniqueDates) {
if (this.state.dailyIssuesCache[dateStr]) {
console.log(`[API] 캐시 사용 (${dateStr})`);
continue;
}
try {
const response = await window.apiCall(`/work-issues?start_date=${dateStr}&end_date=${dateStr}`);
if (response.success) {
this.state.setDailyIssuesCache(dateStr, response.data || []);
console.log(`[API] 신고 로드 완료 (${dateStr}):`, this.state.dailyIssuesCache[dateStr].length);
} else {
this.state.setDailyIssuesCache(dateStr, []);
}
} catch (error) {
console.error(`[API] 신고 조회 오류 (${dateStr}):`, error);
this.state.setDailyIssuesCache(dateStr, []);
}
}
}
/**
* 완료된 작업보고서 조회
*/
async loadCompletedReports(date) {
try {
const response = await window.apiCall(`/daily-work-reports/v2/reports?date=${date}`);
if (response.success) {
console.log(`[API] 완료 보고서 로드 (${date}):`, response.data?.length || 0);
return response.data || [];
}
throw new Error(response.message || '조회 실패');
} catch (error) {
console.error('[API] 완료 보고서 로드 오류:', error);
throw error;
}
}
/**
* TBM 작업보고서 제출
*/
async submitTbmWorkReport(reportData) {
try {
const response = await window.apiCall('/daily-work-reports/from-tbm', 'POST', reportData);
if (!response.success) {
throw new Error(response.message || '제출 실패');
}
console.log('[API] TBM 작업보고서 제출 완료:', response);
return response;
} catch (error) {
console.error('[API] TBM 작업보고서 제출 오류:', error);
throw error;
}
}
/**
* 수동 작업보고서 제출
*/
async submitManualWorkReport(reportData) {
try {
const response = await window.apiCall('/daily-work-reports/v2/reports', 'POST', reportData);
if (!response.success) {
throw new Error(response.message || '제출 실패');
}
console.log('[API] 수동 작업보고서 제출 완료:', response);
return response;
} catch (error) {
console.error('[API] 수동 작업보고서 제출 오류:', error);
throw error;
}
}
/**
* 작업보고서 삭제
*/
async deleteWorkReport(reportId) {
try {
const response = await window.apiCall(`/daily-work-reports/v2/reports/${reportId}`, 'DELETE');
if (!response.success) {
throw new Error(response.message || '삭제 실패');
}
console.log('[API] 작업보고서 삭제 완료:', reportId);
return response;
} catch (error) {
console.error('[API] 작업보고서 삭제 오류:', error);
throw error;
}
}
/**
* 작업보고서 수정
*/
async updateWorkReport(reportId, updateData) {
try {
const response = await window.apiCall(`/daily-work-reports/v2/reports/${reportId}`, 'PUT', updateData);
if (!response.success) {
throw new Error(response.message || '수정 실패');
}
console.log('[API] 작업보고서 수정 완료:', reportId);
return response;
} catch (error) {
console.error('[API] 작업보고서 수정 오류:', error);
throw error;
}
}
/**
* 신고 카테고리 추가
*/
async addIssueCategory(categoryData) {
try {
const response = await window.apiCall('/work-issues/categories', 'POST', categoryData);
if (response.success) {
await this.loadErrorTypes(); // 목록 새로고침
}
return response;
} catch (error) {
console.error('[API] 카테고리 추가 오류:', error);
throw error;
}
}
/**
* 신고 아이템 추가
*/
async addIssueItem(itemData) {
try {
const response = await window.apiCall('/work-issues/items', 'POST', itemData);
if (response.success) {
await this.loadErrorTypes(); // 목록 새로고침
}
return response;
} catch (error) {
console.error('[API] 아이템 추가 오류:', error);
throw error;
}
}
/**
* 모든 기본 데이터 로드
*/
async loadAllData() {
console.log('[API] 모든 기본 데이터 로딩 시작...');
await Promise.all([
this.loadWorkers(),
this.loadProjects(),
this.loadWorkTypes(),
this.loadWorkStatusTypes(),
this.loadErrorTypes()
]);
console.log('[API] 모든 기본 데이터 로딩 완료');
}
// 유틸리티: 날짜 형식 변환 (API 형식)
formatDateForApi(date) {
if (!date) return null;
let dateObj;
if (date instanceof Date) {
dateObj = date;
} else if (typeof date === 'string') {
dateObj = new Date(date);
} else {
return null;
}
const year = dateObj.getFullYear();
const month = String(dateObj.getMonth() + 1).padStart(2, '0');
const day = String(dateObj.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
}
// 전역 인스턴스 생성
window.DailyWorkReportAPI = new DailyWorkReportAPI();
// 하위 호환성: 기존 함수들
window.loadWorkers = () => window.DailyWorkReportAPI.loadWorkers();
window.loadProjects = () => window.DailyWorkReportAPI.loadProjects();
window.loadWorkTypes = () => window.DailyWorkReportAPI.loadWorkTypes();
window.loadWorkStatusTypes = () => window.DailyWorkReportAPI.loadWorkStatusTypes();
window.loadErrorTypes = () => window.DailyWorkReportAPI.loadErrorTypes();
window.loadIncompleteTbms = () => window.DailyWorkReportAPI.loadIncompleteTbms();
window.loadDailyIssuesForTbms = () => window.DailyWorkReportAPI.loadDailyIssuesForTbms();
window.loadCompletedReports = () => window.DailyWorkReportAPI.loadCompletedReports(
document.getElementById('completedReportDate')?.value
);
// 통합 데이터 로드 함수
window.loadData = async () => {
try {
window.showMessage?.('데이터를 불러오는 중...', 'loading');
await window.DailyWorkReportAPI.loadAllData();
window.hideMessage?.();
} catch (error) {
console.error('[API] 데이터 로드 실패:', error);
window.showMessage?.('데이터 로드 중 오류가 발생했습니다: ' + error.message, 'error');
}
};

View File

@@ -1,300 +0,0 @@
/**
* Daily Work Report - State Manager
* 작업보고서 페이지의 전역 상태 관리 (BaseState 상속)
*/
class DailyWorkReportState extends BaseState {
constructor() {
super();
// 마스터 데이터
this.workTypes = [];
this.workStatusTypes = [];
this.errorTypes = []; // 레거시 호환용
this.issueCategories = []; // 신고 카테고리 (nonconformity)
this.issueItems = []; // 신고 아이템
this.workers = [];
this.projects = [];
// UI 상태
this.selectedWorkers = new Set();
this.workEntryCounter = 0;
this.currentStep = 1;
this.editingWorkId = null;
this.currentTab = 'tbm';
// TBM 관련
this.incompleteTbms = [];
// 부적합 원인 관리
this.currentDefectIndex = null;
this.tempDefects = {}; // { index: [{ error_type_id, defect_hours, note }] }
// 작업장소 지도 관련
this.mapCanvas = null;
this.mapCtx = null;
this.mapImage = null;
this.mapRegions = [];
this.selectedWorkplace = null;
this.selectedWorkplaceName = null;
this.selectedWorkplaceCategory = null;
this.selectedWorkplaceCategoryName = null;
// 시간 선택 관련
this.currentEditingField = null; // { index, type: 'total' | 'error' }
this.currentTimeValue = 0;
// 캐시
this.dailyIssuesCache = {}; // { 'YYYY-MM-DD': [issues] }
console.log('[State] DailyWorkReportState 초기화 완료');
}
/**
* 토큰에서 사용자 정보 추출
*/
getCurrentUser() {
try {
const token = localStorage.getItem('sso_token');
if (!token) return null;
const payloadBase64 = token.split('.')[1];
if (payloadBase64) {
const payload = JSON.parse(atob(payloadBase64));
return payload;
}
} catch (error) {
console.log('[State] 토큰에서 사용자 정보 추출 실패:', error);
}
try {
const userInfo = localStorage.getItem('sso_user');
if (userInfo) {
return JSON.parse(userInfo);
}
} catch (error) {
console.log('[State] localStorage에서 사용자 정보 가져오기 실패:', error);
}
return null;
}
/**
* 선택된 작업자 토글
*/
toggleWorkerSelection(workerId) {
if (this.selectedWorkers.has(workerId)) {
this.selectedWorkers.delete(workerId);
} else {
this.selectedWorkers.add(workerId);
}
this.notifyListeners('selectedWorkers', this.selectedWorkers, null);
}
/**
* 작업자 전체 선택/해제
*/
selectAllWorkers(select = true) {
if (select) {
this.workers.forEach(w => this.selectedWorkers.add(w.user_id));
} else {
this.selectedWorkers.clear();
}
this.notifyListeners('selectedWorkers', this.selectedWorkers, null);
}
/**
* 작업 항목 카운터 증가
*/
incrementWorkEntryCounter() {
this.workEntryCounter++;
return this.workEntryCounter;
}
/**
* 탭 변경
*/
setCurrentTab(tab) {
const prevTab = this.currentTab;
this.currentTab = tab;
this.notifyListeners('currentTab', tab, prevTab);
}
/**
* 부적합 임시 저장소 초기화
*/
initTempDefects(index) {
if (!this.tempDefects[index]) {
this.tempDefects[index] = [];
}
}
/**
* 부적합 추가
*/
addTempDefect(index, defect) {
this.initTempDefects(index);
this.tempDefects[index].push(defect);
this.notifyListeners('tempDefects', this.tempDefects, null);
}
/**
* 부적합 업데이트
*/
updateTempDefect(index, defectIndex, field, value) {
if (this.tempDefects[index] && this.tempDefects[index][defectIndex]) {
this.tempDefects[index][defectIndex][field] = value;
this.notifyListeners('tempDefects', this.tempDefects, null);
}
}
/**
* 부적합 삭제
*/
removeTempDefect(index, defectIndex) {
if (this.tempDefects[index]) {
this.tempDefects[index].splice(defectIndex, 1);
this.notifyListeners('tempDefects', this.tempDefects, null);
}
}
/**
* 일일 이슈 캐시 설정
*/
setDailyIssuesCache(dateStr, issues) {
this.dailyIssuesCache[dateStr] = issues;
}
/**
* 일일 이슈 캐시 조회
*/
getDailyIssuesCache(dateStr) {
return this.dailyIssuesCache[dateStr] || [];
}
/**
* 상태 초기화
*/
reset() {
this.selectedWorkers.clear();
this.workEntryCounter = 0;
this.currentStep = 1;
this.editingWorkId = null;
this.tempDefects = {};
this.currentDefectIndex = null;
this.dailyIssuesCache = {};
}
/**
* 디버그 출력
*/
debug() {
console.log('[State] 현재 상태:', {
workTypes: this.workTypes.length,
workers: this.workers.length,
projects: this.projects.length,
selectedWorkers: this.selectedWorkers.size,
currentTab: this.currentTab,
incompleteTbms: this.incompleteTbms.length,
tempDefects: Object.keys(this.tempDefects).length
});
}
}
// 전역 인스턴스 생성
window.DailyWorkReportState = new DailyWorkReportState();
// 하위 호환성을 위한 전역 변수 프록시
const stateProxy = window.DailyWorkReportState;
// 기존 전역 변수들과 호환
Object.defineProperties(window, {
workTypes: {
get: () => stateProxy.workTypes,
set: (v) => { stateProxy.workTypes = v; }
},
workStatusTypes: {
get: () => stateProxy.workStatusTypes,
set: (v) => { stateProxy.workStatusTypes = v; }
},
errorTypes: {
get: () => stateProxy.errorTypes,
set: (v) => { stateProxy.errorTypes = v; }
},
issueCategories: {
get: () => stateProxy.issueCategories,
set: (v) => { stateProxy.issueCategories = v; }
},
issueItems: {
get: () => stateProxy.issueItems,
set: (v) => { stateProxy.issueItems = v; }
},
workers: {
get: () => stateProxy.workers,
set: (v) => { stateProxy.workers = v; }
},
projects: {
get: () => stateProxy.projects,
set: (v) => { stateProxy.projects = v; }
},
selectedWorkers: {
get: () => stateProxy.selectedWorkers,
set: (v) => { stateProxy.selectedWorkers = v; }
},
incompleteTbms: {
get: () => stateProxy.incompleteTbms,
set: (v) => { stateProxy.incompleteTbms = v; }
},
tempDefects: {
get: () => stateProxy.tempDefects,
set: (v) => { stateProxy.tempDefects = v; }
},
dailyIssuesCache: {
get: () => stateProxy.dailyIssuesCache,
set: (v) => { stateProxy.dailyIssuesCache = v; }
},
currentTab: {
get: () => stateProxy.currentTab,
set: (v) => { stateProxy.currentTab = v; }
},
currentStep: {
get: () => stateProxy.currentStep,
set: (v) => { stateProxy.currentStep = v; }
},
editingWorkId: {
get: () => stateProxy.editingWorkId,
set: (v) => { stateProxy.editingWorkId = v; }
},
workEntryCounter: {
get: () => stateProxy.workEntryCounter,
set: (v) => { stateProxy.workEntryCounter = v; }
},
currentDefectIndex: {
get: () => stateProxy.currentDefectIndex,
set: (v) => { stateProxy.currentDefectIndex = v; }
},
currentEditingField: {
get: () => stateProxy.currentEditingField,
set: (v) => { stateProxy.currentEditingField = v; }
},
currentTimeValue: {
get: () => stateProxy.currentTimeValue,
set: (v) => { stateProxy.currentTimeValue = v; }
},
selectedWorkplace: {
get: () => stateProxy.selectedWorkplace,
set: (v) => { stateProxy.selectedWorkplace = v; }
},
selectedWorkplaceName: {
get: () => stateProxy.selectedWorkplaceName,
set: (v) => { stateProxy.selectedWorkplaceName = v; }
},
selectedWorkplaceCategory: {
get: () => stateProxy.selectedWorkplaceCategory,
set: (v) => { stateProxy.selectedWorkplaceCategory = v; }
},
selectedWorkplaceCategoryName: {
get: () => stateProxy.selectedWorkplaceCategoryName,
set: (v) => { stateProxy.selectedWorkplaceCategoryName = v; }
}
});

View File

@@ -1,299 +0,0 @@
/**
* Daily Work Report - Utilities
* 작업보고서 관련 유틸리티 함수들 (공통 함수는 CommonUtils에 위임)
*/
class DailyWorkReportUtils {
constructor() {
this._common = window.CommonUtils;
console.log('[Utils] DailyWorkReportUtils 초기화');
}
// --- CommonUtils 위임 ---
getKoreaToday() { return this._common.getTodayKST(); }
formatDateForApi(date) { return this._common.formatDate(date); }
formatDate(date) { return this._common.formatDate(date) || '-'; }
getDayOfWeek(date) { return this._common.getDayOfWeek(date); }
isToday(date) { return this._common.isToday(date); }
generateUUID() { return this._common.generateUUID(); }
escapeHtml(text) { return this._common.escapeHtml(text); }
debounce(func, wait) { return this._common.debounce(func, wait); }
throttle(func, limit) { return this._common.throttle(func, limit); }
deepClone(obj) { return this._common.deepClone(obj); }
isEmpty(value) { return this._common.isEmpty(value); }
groupBy(array, key) { return this._common.groupBy(array, key); }
// --- 작업보고 전용 ---
/**
* 시간 포맷팅 (HH:mm)
*/
formatTime(time) {
if (!time) return '-';
if (typeof time === 'string' && time.includes(':')) {
return time.substring(0, 5);
}
return time;
}
/**
* 상태 라벨 반환
*/
getStatusLabel(status) {
const labels = {
'pending': '접수',
'in_progress': '처리중',
'resolved': '해결',
'completed': '완료',
'closed': '종료'
};
return labels[status] || status || '-';
}
/**
* 숫자 포맷팅 (천 단위 콤마)
*/
formatNumber(num) {
if (num === null || num === undefined) return '0';
return num.toLocaleString('ko-KR');
}
/**
* 소수점 자리수 포맷팅
*/
formatDecimal(num, decimals = 1) {
if (num === null || num === undefined) return '0';
return Number(num).toFixed(decimals);
}
/**
* 두 날짜 사이 일수 계산
*/
daysBetween(date1, date2) {
const d1 = new Date(date1);
const d2 = new Date(date2);
const diffTime = Math.abs(d2 - d1);
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
}
/**
* 숫자 유효성 검사
*/
isValidNumber(value) {
return !isNaN(value) && isFinite(value);
}
/**
* 시간 유효성 검사 (0-24)
*/
isValidHours(hours) {
const num = parseFloat(hours);
return this.isValidNumber(num) && num >= 0 && num <= 24;
}
/**
* 쿼리 스트링 파싱
*/
parseQueryString(queryString) {
const params = new URLSearchParams(queryString);
const result = {};
for (const [key, value] of params) {
result[key] = value;
}
return result;
}
/**
* 쿼리 스트링 생성
*/
buildQueryString(params) {
return new URLSearchParams(params).toString();
}
/**
* 로컬 스토리지 안전하게 가져오기
*/
getLocalStorage(key, defaultValue = null) {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : defaultValue;
} catch (error) {
console.error('[Utils] localStorage 읽기 오류:', error);
return defaultValue;
}
}
/**
* 로컬 스토리지 안전하게 저장하기
*/
setLocalStorage(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
return true;
} catch (error) {
console.error('[Utils] localStorage 저장 오류:', error);
return false;
}
}
/**
* 배열 정렬 (다중 키)
*/
sortBy(array, ...keys) {
return [...array].sort((a, b) => {
for (const key of keys) {
const direction = key.startsWith('-') ? -1 : 1;
const actualKey = key.replace(/^-/, '');
const aVal = a[actualKey];
const bVal = b[actualKey];
if (aVal < bVal) return -1 * direction;
if (aVal > bVal) return 1 * direction;
}
return 0;
});
}
}
// 전역 인스턴스 생성
window.DailyWorkReportUtils = new DailyWorkReportUtils();
// 하위 호환성: 기존 함수들
window.getKoreaToday = () => window.DailyWorkReportUtils.getKoreaToday();
window.formatDateForApi = (date) => window.DailyWorkReportUtils.formatDateForApi(date);
window.formatDate = (date) => window.DailyWorkReportUtils.formatDate(date);
window.getStatusLabel = (status) => window.DailyWorkReportUtils.getStatusLabel(status);
// 메시지 표시 함수들
window.showMessage = function(message, type = 'info') {
const container = document.getElementById('message-container');
if (!container) {
console.log(`[Message] ${type}: ${message}`);
return;
}
container.innerHTML = `<div class="message ${type}">${message}</div>`;
if (type === 'success') {
setTimeout(() => window.hideMessage(), 5000);
}
};
window.hideMessage = function() {
const container = document.getElementById('message-container');
if (container) {
container.innerHTML = '';
}
};
// 저장 결과 모달
window.showSaveResultModal = function(type, title, message, details = null) {
const modal = document.getElementById('saveResultModal');
const titleElement = document.getElementById('resultModalTitle');
const contentElement = document.getElementById('resultModalContent');
if (!modal || !contentElement) {
alert(`${title}\n\n${message}`);
return;
}
const icons = {
success: '✅',
error: '❌',
warning: '⚠️',
info: ''
};
let content = `
<div class="result-icon ${type}">${icons[type] || icons.info}</div>
<h3 class="result-title ${type}">${title}</h3>
<p class="result-message">${message}</p>
`;
if (details) {
if (Array.isArray(details) && details.length > 0) {
content += `
<div class="result-details">
<h4>상세 정보:</h4>
<ul>${details.map(d => `<li>${d}</li>`).join('')}</ul>
</div>
`;
} else if (typeof details === 'string') {
content += `<div class="result-details"><p>${details}</p></div>`;
}
}
if (titleElement) titleElement.textContent = '저장 결과';
contentElement.innerHTML = content;
modal.style.display = 'flex';
// ESC 키로 닫기
const escHandler = (e) => {
if (e.key === 'Escape') {
window.closeSaveResultModal();
document.removeEventListener('keydown', escHandler);
}
};
document.addEventListener('keydown', escHandler);
// 배경 클릭으로 닫기
modal.onclick = (e) => {
if (e.target === modal) {
window.closeSaveResultModal();
}
};
};
window.closeSaveResultModal = function() {
const modal = document.getElementById('saveResultModal');
if (modal) {
modal.style.display = 'none';
}
};
// 단계 이동 함수
window.goToStep = function(stepNumber) {
const state = window.DailyWorkReportState;
for (let i = 1; i <= 3; i++) {
const step = document.getElementById(`step${i}`);
if (step) {
step.classList.remove('active', 'completed');
if (i < stepNumber) {
step.classList.add('completed');
const stepNum = step.querySelector('.step-number');
if (stepNum) stepNum.classList.add('completed');
} else if (i === stepNumber) {
step.classList.add('active');
}
}
}
window.updateProgressSteps(stepNumber);
state.currentStep = stepNumber;
};
window.updateProgressSteps = function(currentStepNumber) {
for (let i = 1; i <= 3; i++) {
const progressStep = document.getElementById(`progressStep${i}`);
if (progressStep) {
progressStep.classList.remove('active', 'completed');
if (i < currentStepNumber) {
progressStep.classList.add('completed');
} else if (i === currentStepNumber) {
progressStep.classList.add('active');
}
}
}
};
// showToast → api-base.js 전역 사용
// 확인 다이얼로그
window.showConfirmDialog = function(message, onConfirm, onCancel) {
if (confirm(message)) {
onConfirm?.();
} else {
onCancel?.();
}
};

View File

@@ -1,329 +0,0 @@
// department-management.js
// 부서 관리 페이지 JavaScript
let departments = [];
let selectedDepartmentId = null;
let selectedWorkers = new Set();
// 페이지 초기화
document.addEventListener('DOMContentLoaded', async () => {
await waitForApi();
await loadDepartments();
});
// waitForApi → api-base.js 전역 사용
// 부서 목록 로드
async function loadDepartments() {
try {
const result = await window.apiCall('/departments');
if (result.success) {
departments = result.data;
renderDepartmentList();
updateMoveToDepartmentSelect();
}
} catch (error) {
console.error('부서 목록 로드 실패:', error);
}
}
// 부서 목록 렌더링
function renderDepartmentList() {
const container = document.getElementById('departmentList');
if (departments.length === 0) {
container.innerHTML = `
<div style="text-align: center; padding: 2rem; color: #9ca3af;">
등록된 부서가 없습니다.<br>
<button class="btn btn-primary btn-sm" style="margin-top: 1rem;" onclick="openDepartmentModal()">
첫 부서 등록하기
</button>
</div>
`;
return;
}
container.innerHTML = departments.map(dept => `
<div class="department-item ${selectedDepartmentId === dept.department_id ? 'active' : ''}"
onclick="selectDepartment(${dept.department_id})">
<div class="department-info">
<span class="department-name">${dept.department_name}</span>
<span class="department-count">${dept.worker_count || 0}명</span>
</div>
<div class="department-actions" onclick="event.stopPropagation()">
<button class="btn-icon" onclick="editDepartment(${dept.department_id})" title="수정">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</button>
<button class="btn-icon danger" onclick="deleteDepartment(${dept.department_id})" title="삭제">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
</div>
</div>
`).join('');
}
// 부서 선택
async function selectDepartment(departmentId) {
selectedDepartmentId = departmentId;
selectedWorkers.clear();
updateBulkActions();
renderDepartmentList();
const dept = departments.find(d => d.department_id === departmentId);
document.getElementById('workerListTitle').textContent = `${dept.department_name} 작업자`;
document.getElementById('addWorkerBtn').style.display = 'inline-flex';
await loadWorkers(departmentId);
}
// 부서별 작업자 로드
async function loadWorkers(departmentId) {
try {
const result = await window.apiCall(`/departments/${departmentId}/workers`);
if (result.success) {
renderWorkerList(result.data);
}
} catch (error) {
console.error('작업자 목록 로드 실패:', error);
}
}
// 작업자 목록 렌더링
function renderWorkerList(workers) {
const container = document.getElementById('workerList');
if (workers.length === 0) {
container.innerHTML = `
<div style="text-align: center; padding: 2rem; color: #9ca3af;">
이 부서에 소속된 작업자가 없습니다.
</div>
`;
return;
}
container.innerHTML = workers.map(worker => `
<div class="worker-card ${selectedWorkers.has(worker.user_id) ? 'selected' : ''}"
onclick="toggleWorkerSelection(${worker.user_id})">
<div class="worker-info-row">
<input type="checkbox" ${selectedWorkers.has(worker.user_id) ? 'checked' : ''}
onclick="event.stopPropagation(); toggleWorkerSelection(${worker.user_id})">
<div class="worker-avatar">${worker.worker_name.charAt(0)}</div>
<div class="worker-details">
<span class="worker-name">${worker.worker_name}</span>
<span class="worker-job">${getJobTypeName(worker.job_type)}</span>
</div>
</div>
</div>
`).join('');
}
// 직책 한글 변환
function getJobTypeName(jobType) {
const names = {
leader: '그룹장',
worker: '작업자',
admin: '관리자'
};
return names[jobType] || jobType || '-';
}
// 작업자 선택 토글
function toggleWorkerSelection(workerId) {
if (selectedWorkers.has(workerId)) {
selectedWorkers.delete(workerId);
} else {
selectedWorkers.add(workerId);
}
updateBulkActions();
// 선택 상태 업데이트
const card = document.querySelector(`.worker-card[onclick*="${workerId}"]`);
if (card) {
card.classList.toggle('selected', selectedWorkers.has(workerId));
const checkbox = card.querySelector('input[type="checkbox"]');
if (checkbox) checkbox.checked = selectedWorkers.has(workerId);
}
}
// 일괄 작업 영역 업데이트
function updateBulkActions() {
const bulkActions = document.getElementById('bulkActions');
const selectedCount = document.getElementById('selectedCount');
if (selectedWorkers.size > 0) {
bulkActions.classList.add('visible');
selectedCount.textContent = selectedWorkers.size;
} else {
bulkActions.classList.remove('visible');
}
}
// 이동 대상 부서 선택 업데이트
function updateMoveToDepartmentSelect() {
const select = document.getElementById('moveToDepartment');
select.innerHTML = '<option value="">부서 이동...</option>' +
departments.map(d => `<option value="${d.department_id}">${d.department_name}</option>`).join('');
}
// 선택한 작업자 이동
async function moveSelectedWorkers() {
const targetDepartmentId = document.getElementById('moveToDepartment').value;
if (!targetDepartmentId) {
alert('이동할 부서를 선택하세요.');
return;
}
if (selectedWorkers.size === 0) {
alert('이동할 작업자를 선택하세요.');
return;
}
if (parseInt(targetDepartmentId) === selectedDepartmentId) {
alert('같은 부서로는 이동할 수 없습니다.');
return;
}
try {
const result = await window.apiCall('/departments/move-workers', 'POST', {
workerIds: Array.from(selectedWorkers),
departmentId: parseInt(targetDepartmentId)
});
if (result.success) {
alert(result.message);
selectedWorkers.clear();
updateBulkActions();
document.getElementById('moveToDepartment').value = '';
await loadDepartments();
await loadWorkers(selectedDepartmentId);
} else {
alert(result.error || '이동 실패');
}
} catch (error) {
console.error('작업자 이동 실패:', error);
alert('작업자 이동에 실패했습니다.');
}
}
// 부서 모달 열기
function openDepartmentModal(departmentId = null) {
const modal = document.getElementById('departmentModal');
const title = document.getElementById('departmentModalTitle');
const form = document.getElementById('departmentForm');
// 상위 부서 선택 옵션 업데이트
const parentSelect = document.getElementById('parentDepartment');
parentSelect.innerHTML = '<option value="">없음 (최상위 부서)</option>' +
departments
.filter(d => d.department_id !== departmentId)
.map(d => `<option value="${d.department_id}">${d.department_name}</option>`)
.join('');
if (departmentId) {
const dept = departments.find(d => d.department_id === departmentId);
title.textContent = '부서 수정';
document.getElementById('departmentId').value = dept.department_id;
document.getElementById('departmentName').value = dept.department_name;
document.getElementById('parentDepartment').value = dept.parent_id || '';
document.getElementById('departmentDescription').value = dept.description || '';
document.getElementById('displayOrder').value = dept.display_order || 0;
document.getElementById('isActive').checked = dept.is_active;
} else {
title.textContent = '새 부서 등록';
form.reset();
document.getElementById('departmentId').value = '';
document.getElementById('isActive').checked = true;
}
modal.classList.add('show');
}
// 부서 모달 닫기
function closeDepartmentModal() {
document.getElementById('departmentModal').classList.remove('show');
}
// 부서 저장
async function saveDepartment(event) {
event.preventDefault();
const departmentId = document.getElementById('departmentId').value;
const data = {
department_name: document.getElementById('departmentName').value,
parent_id: document.getElementById('parentDepartment').value || null,
description: document.getElementById('departmentDescription').value,
display_order: parseInt(document.getElementById('displayOrder').value) || 0,
is_active: document.getElementById('isActive').checked
};
try {
const url = departmentId ? `/departments/${departmentId}` : '/departments';
const method = departmentId ? 'PUT' : 'POST';
const result = await window.apiCall(url, method, data);
if (result.success) {
alert(result.message);
closeDepartmentModal();
await loadDepartments();
} else {
alert(result.error || '저장 실패');
}
} catch (error) {
console.error('부서 저장 실패:', error);
alert('부서 저장에 실패했습니다.');
}
}
// 부서 수정
function editDepartment(departmentId) {
openDepartmentModal(departmentId);
}
// 부서 삭제
async function deleteDepartment(departmentId) {
const dept = departments.find(d => d.department_id === departmentId);
if (!confirm(`"${dept.department_name}" 부서를 삭제하시겠습니까?\n\n소속 작업자가 있거나 하위 부서가 있으면 삭제할 수 없습니다.`)) {
return;
}
try {
const result = await window.apiCall(`/departments/${departmentId}`, 'DELETE');
if (result.success) {
alert('부서가 삭제되었습니다.');
if (selectedDepartmentId === departmentId) {
selectedDepartmentId = null;
document.getElementById('workerListTitle').textContent = '부서를 선택하세요';
document.getElementById('addWorkerBtn').style.display = 'none';
document.getElementById('workerList').innerHTML = `
<div style="text-align: center; padding: 2rem; color: #9ca3af;">
왼쪽에서 부서를 선택하면 해당 부서의 작업자가 표시됩니다.
</div>
`;
}
await loadDepartments();
} else {
alert(result.error || '삭제 실패');
}
} catch (error) {
console.error('부서 삭제 실패:', error);
alert('부서 삭제에 실패했습니다.');
}
}
// 작업자 추가 모달 (작업자 관리 페이지로 이동)
function openAddWorkerModal() {
alert('작업자 관리 페이지에서 작업자를 등록한 후 이 페이지에서 부서를 배정하세요.');
// window.location.href = '/pages/admin/workers.html';
}

View File

@@ -1,793 +0,0 @@
/**
* equipment-detail.js - 설비 상세 페이지 스크립트
*/
// 전역 변수
let currentEquipment = null;
let equipmentId = null;
let workplaces = [];
let factories = [];
let selectedMovePosition = null;
let repairPhotoBases = [];
// 상태 라벨
const STATUS_LABELS = {
active: '정상 가동',
maintenance: '점검 중',
repair_needed: '수리 필요',
inactive: '비활성',
external: '외부 반출',
repair_external: '수리 외주'
};
// 페이지 초기화
document.addEventListener('DOMContentLoaded', () => {
// URL에서 equipment_id 추출
const urlParams = new URLSearchParams(window.location.search);
equipmentId = urlParams.get('id');
if (!equipmentId) {
alert('설비 ID가 필요합니다.');
goBack();
return;
}
// API 설정 후 데이터 로드
waitForApi().then(() => {
loadEquipmentData();
loadFactories();
loadRepairCategories();
});
});
// waitForApi → api-base.js 전역 사용
// 뒤로가기
function goBack() {
if (document.referrer && document.referrer.includes(window.location.host)) {
history.back();
} else {
window.location.href = '/pages/admin/equipments.html';
}
}
// ==========================================
// 설비 데이터 로드
// ==========================================
async function loadEquipmentData() {
try {
const response = await axios.get(`/equipments/${equipmentId}`);
if (response.data.success) {
currentEquipment = response.data.data;
renderEquipmentInfo();
loadPhotos();
loadRepairHistory();
loadExternalLogs();
loadMoveLogs();
}
} catch (error) {
console.error('설비 정보 로드 실패:', error);
alert('설비 정보를 불러오는데 실패했습니다.');
}
}
function renderEquipmentInfo() {
const eq = currentEquipment;
// 헤더
document.getElementById('equipmentTitle').textContent = `[${eq.equipment_code}] ${eq.equipment_name}`;
document.getElementById('equipmentMeta').textContent = `${eq.model_name || '-'} | ${eq.manufacturer || '-'}`;
// 상태 배지
const statusBadge = document.getElementById('equipmentStatus');
statusBadge.textContent = STATUS_LABELS[eq.status] || eq.status;
statusBadge.className = `eq-status-badge ${eq.status}`;
// 기본 정보 카드
document.getElementById('equipmentInfoCard').innerHTML = `
<div class="eq-info-grid">
<div class="eq-info-item">
<span class="eq-info-label">관리번호</span>
<span class="eq-info-value">${escapeHtml(eq.equipment_code || '-')}</span>
</div>
<div class="eq-info-item">
<span class="eq-info-label">설비명</span>
<span class="eq-info-value">${escapeHtml(eq.equipment_name || '-')}</span>
</div>
<div class="eq-info-item">
<span class="eq-info-label">모델명</span>
<span class="eq-info-value">${escapeHtml(eq.model_name || '-')}</span>
</div>
<div class="eq-info-item">
<span class="eq-info-label">규격</span>
<span class="eq-info-value">${escapeHtml(eq.specifications || '-')}</span>
</div>
<div class="eq-info-item">
<span class="eq-info-label">제조사</span>
<span class="eq-info-value">${escapeHtml(eq.manufacturer || '-')}</span>
</div>
<div class="eq-info-item">
<span class="eq-info-label">구입처</span>
<span class="eq-info-value">${escapeHtml(eq.supplier || '-')}</span>
</div>
<div class="eq-info-item">
<span class="eq-info-label">구입일</span>
<span class="eq-info-value">${eq.installation_date ? formatDate(eq.installation_date) : '-'}</span>
</div>
<div class="eq-info-item">
<span class="eq-info-label">구입가격</span>
<span class="eq-info-value">${eq.purchase_price ? Number(eq.purchase_price).toLocaleString() + '원' : '-'}</span>
</div>
<div class="eq-info-item">
<span class="eq-info-label">시리얼번호</span>
<span class="eq-info-value">${escapeHtml(eq.serial_number || '-')}</span>
</div>
<div class="eq-info-item">
<span class="eq-info-label">설비유형</span>
<span class="eq-info-value">${escapeHtml(eq.equipment_type || '-')}</span>
</div>
</div>
`;
// 위치 정보
const originalLocation = eq.workplace_name
? `${eq.category_name || ''} > ${eq.workplace_name}`
: '미배정';
document.getElementById('originalLocation').textContent = originalLocation;
if (eq.is_temporarily_moved && eq.current_workplace_id) {
document.getElementById('currentLocationRow').style.display = 'flex';
// 현재 위치 작업장 이름 로드 필요
loadCurrentWorkplaceName(eq.current_workplace_id);
}
// 지도 미리보기 (작업장 지도 표시)
renderMapPreview();
}
async function loadCurrentWorkplaceName(workplaceId) {
try {
const response = await axios.get(`/workplaces/${workplaceId}`);
if (response.data.success) {
const wp = response.data.data;
document.getElementById('currentLocation').textContent = `${wp.category_name || ''} > ${wp.workplace_name}`;
}
} catch (error) {
console.error('현재 위치 로드 실패:', error);
}
}
function renderMapPreview() {
const eq = currentEquipment;
const mapPreview = document.getElementById('mapPreview');
if (!eq.workplace_id) {
mapPreview.innerHTML = '<div style="padding: 1rem; text-align: center; color: #9ca3af;">위치 미배정</div>';
return;
}
// 작업장 지도 정보 로드
axios.get(`/workplaces/${eq.workplace_id}`).then(response => {
if (response.data.success && response.data.data.map_image_url) {
const wp = response.data.data;
const xPercent = eq.is_temporarily_moved ? eq.current_map_x_percent : eq.map_x_percent;
const yPercent = eq.is_temporarily_moved ? eq.current_map_y_percent : eq.map_y_percent;
mapPreview.innerHTML = `
<img src="${window.API_BASE_URL}${wp.map_image_url}" alt="작업장 지도">
<div class="eq-map-marker" style="left: ${xPercent}%; top: ${yPercent}%;"></div>
`;
} else {
mapPreview.innerHTML = '<div style="padding: 1rem; text-align: center; color: #9ca3af;">지도 없음</div>';
}
}).catch(() => {
mapPreview.innerHTML = '<div style="padding: 1rem; text-align: center; color: #9ca3af;">지도 로드 실패</div>';
});
}
// ==========================================
// 사진 관리
// ==========================================
async function loadPhotos() {
try {
const response = await axios.get(`/equipments/${equipmentId}/photos`);
if (response.data.success) {
renderPhotos(response.data.data);
}
} catch (error) {
console.error('사진 로드 실패:', error);
}
}
function renderPhotos(photos) {
const grid = document.getElementById('photoGrid');
if (!photos || photos.length === 0) {
grid.innerHTML = '<div class="eq-photo-empty">등록된 사진이 없습니다</div>';
return;
}
grid.innerHTML = photos.map(photo => {
const safePhotoId = parseInt(photo.photo_id) || 0;
const safePhotoPath = encodeURI(photo.photo_path || '');
const safeDescription = escapeHtml(photo.description || '설비 사진');
return `
<div class="eq-photo-item" onclick="viewPhoto('${window.API_BASE_URL}${safePhotoPath}')">
<img src="${window.API_BASE_URL}${safePhotoPath}" alt="${safeDescription}">
<button class="eq-photo-delete" onclick="event.stopPropagation(); deletePhoto(${safePhotoId})">&times;</button>
</div>
`;
}).join('');
}
function openPhotoModal() {
document.getElementById('photoInput').value = '';
document.getElementById('photoDescription').value = '';
document.getElementById('photoPreviewContainer').style.display = 'none';
document.getElementById('photoModal').style.display = 'flex';
}
function closePhotoModal() {
document.getElementById('photoModal').style.display = 'none';
}
function previewPhoto(event) {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = e => {
document.getElementById('photoPreview').src = e.target.result;
document.getElementById('photoPreviewContainer').style.display = 'block';
};
reader.readAsDataURL(file);
}
}
async function uploadPhoto() {
const fileInput = document.getElementById('photoInput');
const description = document.getElementById('photoDescription').value;
if (!fileInput.files[0]) {
alert('사진을 선택하세요.');
return;
}
const reader = new FileReader();
reader.onload = async e => {
try {
const response = await axios.post(`/equipments/${equipmentId}/photos`, {
photo_base64: e.target.result,
description: description
});
if (response.data.success) {
closePhotoModal();
loadPhotos();
alert('사진이 추가되었습니다.');
}
} catch (error) {
console.error('사진 업로드 실패:', error);
alert('사진 업로드에 실패했습니다.');
}
};
reader.readAsDataURL(fileInput.files[0]);
}
async function deletePhoto(photoId) {
if (!confirm('이 사진을 삭제하시겠습니까?')) return;
try {
const response = await axios.delete(`/equipments/photos/${photoId}`);
if (response.data.success) {
loadPhotos();
}
} catch (error) {
console.error('사진 삭제 실패:', error);
alert('사진 삭제에 실패했습니다.');
}
}
function viewPhoto(url) {
document.getElementById('photoViewImage').src = url;
document.getElementById('photoViewModal').style.display = 'flex';
}
function closePhotoView() {
document.getElementById('photoViewModal').style.display = 'none';
}
// ==========================================
// 임시 이동
// ==========================================
async function loadFactories() {
try {
const response = await axios.get('/workplace-categories');
if (response.data.success) {
factories = response.data.data;
}
} catch (error) {
console.error('공장 목록 로드 실패:', error);
}
}
function openMoveModal() {
// 공장 선택 초기화
const factorySelect = document.getElementById('moveFactorySelect');
factorySelect.innerHTML = '<option value="">공장을 선택하세요</option>';
factories.forEach(f => {
const safeCategoryId = parseInt(f.category_id) || 0;
factorySelect.innerHTML += `<option value="${safeCategoryId}">${escapeHtml(f.category_name || '-')}</option>`;
});
document.getElementById('moveWorkplaceSelect').innerHTML = '<option value="">작업장을 선택하세요</option>';
document.getElementById('moveStep2').style.display = 'none';
document.getElementById('moveStep1').style.display = 'block';
document.getElementById('moveConfirmBtn').disabled = true;
document.getElementById('moveReason').value = '';
selectedMovePosition = null;
document.getElementById('moveModal').style.display = 'flex';
}
function closeMoveModal() {
document.getElementById('moveModal').style.display = 'none';
}
async function loadMoveWorkplaces() {
const categoryId = document.getElementById('moveFactorySelect').value;
const workplaceSelect = document.getElementById('moveWorkplaceSelect');
workplaceSelect.innerHTML = '<option value="">작업장을 선택하세요</option>';
if (!categoryId) return;
try {
const response = await axios.get(`/workplaces?category_id=${categoryId}`);
if (response.data.success) {
workplaces = response.data.data;
workplaces.forEach(wp => {
if (wp.map_image_url) {
const safeWorkplaceId = parseInt(wp.workplace_id) || 0;
workplaceSelect.innerHTML += `<option value="${safeWorkplaceId}">${escapeHtml(wp.workplace_name || '-')}</option>`;
}
});
}
} catch (error) {
console.error('작업장 로드 실패:', error);
}
}
function loadMoveMap() {
const workplaceId = document.getElementById('moveWorkplaceSelect').value;
if (!workplaceId) {
document.getElementById('moveStep2').style.display = 'none';
return;
}
const workplace = workplaces.find(wp => wp.workplace_id == workplaceId);
if (!workplace || !workplace.map_image_url) {
alert('선택한 작업장에 지도가 없습니다.');
return;
}
const container = document.getElementById('moveMapContainer');
container.innerHTML = `<img src="${window.API_BASE_URL}${workplace.map_image_url}" id="moveMapImage" onclick="onMoveMapClick(event)">`;
document.getElementById('moveStep2').style.display = 'block';
}
function onMoveMapClick(event) {
const img = event.target;
const rect = img.getBoundingClientRect();
const x = ((event.clientX - rect.left) / rect.width) * 100;
const y = ((event.clientY - rect.top) / rect.height) * 100;
selectedMovePosition = { x, y };
// 기존 마커 제거
const container = document.getElementById('moveMapContainer');
const existingMarker = container.querySelector('.move-marker');
if (existingMarker) existingMarker.remove();
// 새 마커 추가
const marker = document.createElement('div');
marker.className = 'move-marker';
marker.style.left = x + '%';
marker.style.top = y + '%';
container.appendChild(marker);
document.getElementById('moveConfirmBtn').disabled = false;
}
async function confirmMove() {
const targetWorkplaceId = document.getElementById('moveWorkplaceSelect').value;
const reason = document.getElementById('moveReason').value;
if (!targetWorkplaceId || !selectedMovePosition) {
alert('이동할 위치를 선택하세요.');
return;
}
try {
const response = await axios.post(`/equipments/${equipmentId}/move`, {
target_workplace_id: targetWorkplaceId,
target_x_percent: selectedMovePosition.x.toFixed(2),
target_y_percent: selectedMovePosition.y.toFixed(2),
from_workplace_id: currentEquipment.workplace_id,
from_x_percent: currentEquipment.map_x_percent,
from_y_percent: currentEquipment.map_y_percent,
reason: reason
});
if (response.data.success) {
closeMoveModal();
loadEquipmentData();
loadMoveLogs();
alert('설비가 임시 이동되었습니다.');
}
} catch (error) {
console.error('이동 실패:', error);
alert('설비 이동에 실패했습니다.');
}
}
async function returnToOriginal() {
if (!confirm('설비를 원래 위치로 복귀시키겠습니까?')) return;
try {
const response = await axios.post(`/equipments/${equipmentId}/return`);
if (response.data.success) {
loadEquipmentData();
loadMoveLogs();
alert('설비가 원위치로 복귀되었습니다.');
}
} catch (error) {
console.error('복귀 실패:', error);
alert('설비 복귀에 실패했습니다.');
}
}
// ==========================================
// 수리 신청
// ==========================================
let repairCategories = [];
async function loadRepairCategories() {
try {
const response = await axios.get('/equipments/repair-categories');
if (response.data.success) {
repairCategories = response.data.data;
}
} catch (error) {
console.error('수리 항목 로드 실패:', error);
}
}
function openRepairModal() {
const select = document.getElementById('repairItemSelect');
select.innerHTML = '<option value="">선택하세요</option>';
repairCategories.forEach(item => {
const safeItemId = parseInt(item.item_id) || 0;
select.innerHTML += `<option value="${safeItemId}">${escapeHtml(item.item_name || '-')}</option>`;
});
document.getElementById('repairDescription').value = '';
document.getElementById('repairPhotoInput').value = '';
document.getElementById('repairPhotoPreviews').innerHTML = '';
repairPhotoBases = [];
document.getElementById('repairModal').style.display = 'flex';
}
function closeRepairModal() {
document.getElementById('repairModal').style.display = 'none';
}
function previewRepairPhotos(event) {
const files = event.target.files;
const previewContainer = document.getElementById('repairPhotoPreviews');
previewContainer.innerHTML = '';
repairPhotoBases = [];
Array.from(files).forEach(file => {
const reader = new FileReader();
reader.onload = e => {
repairPhotoBases.push(e.target.result);
const img = document.createElement('img');
img.src = e.target.result;
img.className = 'repair-photo-preview';
previewContainer.appendChild(img);
};
reader.readAsDataURL(file);
});
}
async function submitRepairRequest() {
const itemId = document.getElementById('repairItemSelect').value;
const description = document.getElementById('repairDescription').value;
if (!description) {
alert('수리 내용을 입력하세요.');
return;
}
try {
const response = await axios.post(`/equipments/${equipmentId}/repair-request`, {
item_id: itemId || null,
description: description,
photo_base64_list: repairPhotoBases,
workplace_id: currentEquipment.workplace_id
});
if (response.data.success) {
closeRepairModal();
loadEquipmentData();
loadRepairHistory();
alert('수리 신청이 접수되었습니다.');
}
} catch (error) {
console.error('수리 신청 실패:', error);
alert('수리 신청에 실패했습니다.');
}
}
async function loadRepairHistory() {
try {
const response = await axios.get(`/equipments/${equipmentId}/repair-history`);
if (response.data.success) {
renderRepairHistory(response.data.data);
}
} catch (error) {
console.error('수리 이력 로드 실패:', error);
}
}
function renderRepairHistory(history) {
const container = document.getElementById('repairHistory');
if (!history || history.length === 0) {
container.innerHTML = '<div class="eq-history-empty">수리 이력이 없습니다</div>';
return;
}
const validStatuses = ['pending', 'in_progress', 'completed', 'closed'];
container.innerHTML = history.map(h => {
const safeStatus = validStatuses.includes(h.status) ? h.status : 'pending';
return `
<div class="eq-history-item">
<span class="eq-history-date">${formatDate(h.created_at)}</span>
<div class="eq-history-content">
<div class="eq-history-title">${escapeHtml(h.item_name || '수리 요청')}</div>
<div class="eq-history-detail">${escapeHtml(h.description || '-')}</div>
</div>
<span class="eq-history-status ${safeStatus}">${getRepairStatusLabel(h.status)}</span>
</div>
`;
}).join('');
}
function getRepairStatusLabel(status) {
const labels = {
pending: '대기중',
in_progress: '처리중',
completed: '완료',
closed: '종료'
};
return labels[status] || status;
}
// ==========================================
// 외부 반출
// ==========================================
function openExportModal() {
document.getElementById('exportDate').value = new Date().toISOString().slice(0, 10);
document.getElementById('expectedReturnDate').value = '';
document.getElementById('exportDestination').value = '';
document.getElementById('exportReason').value = '';
document.getElementById('exportNotes').value = '';
document.getElementById('isRepairExport').checked = false;
document.getElementById('exportModal').style.display = 'flex';
}
function closeExportModal() {
document.getElementById('exportModal').style.display = 'none';
}
function toggleRepairFields() {
// 현재는 특별한 필드 차이 없음
}
async function submitExport() {
const exportDate = document.getElementById('exportDate').value;
const expectedReturnDate = document.getElementById('expectedReturnDate').value;
const destination = document.getElementById('exportDestination').value;
const reason = document.getElementById('exportReason').value;
const notes = document.getElementById('exportNotes').value;
const isRepair = document.getElementById('isRepairExport').checked;
if (!exportDate) {
alert('반출일을 입력하세요.');
return;
}
try {
const response = await axios.post(`/equipments/${equipmentId}/export`, {
export_date: exportDate,
expected_return_date: expectedReturnDate || null,
destination: destination,
reason: reason,
notes: notes,
is_repair: isRepair
});
if (response.data.success) {
closeExportModal();
loadEquipmentData();
loadExternalLogs();
alert('외부 반출이 등록되었습니다.');
}
} catch (error) {
console.error('반출 등록 실패:', error);
alert('반출 등록에 실패했습니다.');
}
}
async function loadExternalLogs() {
try {
const response = await axios.get(`/equipments/${equipmentId}/external-logs`);
if (response.data.success) {
renderExternalLogs(response.data.data);
}
} catch (error) {
console.error('외부반출 이력 로드 실패:', error);
}
}
function renderExternalLogs(logs) {
const container = document.getElementById('externalHistory');
if (!logs || logs.length === 0) {
container.innerHTML = '<div class="eq-history-empty">외부반출 이력이 없습니다</div>';
return;
}
container.innerHTML = logs.map(log => {
const dateRange = log.actual_return_date
? `${formatDate(log.export_date)} ~ ${formatDate(log.actual_return_date)}`
: `${formatDate(log.export_date)} ~ (미반입)`;
const isReturned = !!log.actual_return_date;
const statusClass = isReturned ? 'returned' : 'exported';
const statusLabel = isReturned ? '반입완료' : '반출중';
const safeLogId = parseInt(log.log_id) || 0;
return `
<div class="eq-history-item">
<span class="eq-history-date">${dateRange}</span>
<div class="eq-history-content">
<div class="eq-history-title">${escapeHtml(log.destination || '외부')}</div>
<div class="eq-history-detail">${escapeHtml(log.reason || '-')}</div>
</div>
<span class="eq-history-status ${statusClass}">${statusLabel}</span>
${!isReturned ? `<button class="eq-history-action" onclick="openReturnModal(${safeLogId})">반입처리</button>` : ''}
</div>
`;
}).join('');
}
function openReturnModal(logId) {
document.getElementById('returnLogId').value = logId;
document.getElementById('returnDate').value = new Date().toISOString().slice(0, 10);
document.getElementById('returnStatus').value = 'active';
document.getElementById('returnNotes').value = '';
document.getElementById('returnModal').style.display = 'flex';
}
function closeReturnModal() {
document.getElementById('returnModal').style.display = 'none';
}
async function submitReturn() {
const logId = document.getElementById('returnLogId').value;
const returnDate = document.getElementById('returnDate').value;
const newStatus = document.getElementById('returnStatus').value;
const notes = document.getElementById('returnNotes').value;
if (!returnDate) {
alert('반입일을 입력하세요.');
return;
}
try {
const response = await axios.post(`/equipments/external-logs/${logId}/return`, {
return_date: returnDate,
new_status: newStatus,
notes: notes
});
if (response.data.success) {
closeReturnModal();
loadEquipmentData();
loadExternalLogs();
alert('반입 처리가 완료되었습니다.');
}
} catch (error) {
console.error('반입 처리 실패:', error);
alert('반입 처리에 실패했습니다.');
}
}
// ==========================================
// 이동 이력
// ==========================================
async function loadMoveLogs() {
try {
const response = await axios.get(`/equipments/${equipmentId}/move-logs`);
if (response.data.success) {
renderMoveLogs(response.data.data);
}
} catch (error) {
console.error('이동 이력 로드 실패:', error);
}
}
function renderMoveLogs(logs) {
const container = document.getElementById('moveHistory');
if (!logs || logs.length === 0) {
container.innerHTML = '<div class="eq-history-empty">이동 이력이 없습니다</div>';
return;
}
container.innerHTML = logs.map(log => {
const typeLabel = log.move_type === 'temporary' ? '임시이동' : '복귀';
const location = log.move_type === 'temporary'
? escapeHtml(log.to_workplace_name || '-')
: '원위치 복귀';
return `
<div class="eq-history-item">
<span class="eq-history-date">${formatDateTime(log.moved_at)}</span>
<div class="eq-history-content">
<div class="eq-history-title">${typeLabel}: ${location}</div>
<div class="eq-history-detail">${escapeHtml(log.reason || '-')} (${escapeHtml(log.moved_by_name || '시스템')})</div>
</div>
</div>
`;
}).join('');
}
// ==========================================
// 유틸리티
// ==========================================
function formatDate(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).replace(/\. /g, '-').replace('.', '');
}
function formatDateTime(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}

View File

@@ -1,466 +0,0 @@
// equipment-management.js
// 설비 관리 페이지 JavaScript
let equipments = [];
let allEquipments = []; // 필터링 전 전체 데이터
let workplaces = [];
let equipmentTypes = [];
let currentEquipment = null;
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', async () => {
await waitForAxiosConfig();
await loadInitialData();
});
// axios 설정 대기 함수
function waitForAxiosConfig() {
return new Promise((resolve) => {
const check = setInterval(() => {
if (axios.defaults.baseURL) {
clearInterval(check);
resolve();
}
}, 50);
setTimeout(() => {
clearInterval(check);
if (!axios.defaults.baseURL) {
console.error('Axios 설정 시간 초과');
}
resolve();
}, 5000);
});
}
// 초기 데이터 로드
async function loadInitialData() {
try {
await Promise.all([
loadEquipments(),
loadWorkplaces(),
loadEquipmentTypes()
]);
} catch (error) {
console.error('초기 데이터 로드 실패:', error);
alert('데이터를 불러오는데 실패했습니다.');
}
}
// 설비 목록 로드
async function loadEquipments() {
try {
const response = await axios.get('/equipments');
if (response.data.success) {
allEquipments = response.data.data;
equipments = [...allEquipments];
renderStats();
renderEquipmentList();
}
} catch (error) {
console.error('설비 목록 로드 실패:', error);
throw error;
}
}
// 작업장 목록 로드
async function loadWorkplaces() {
try {
const response = await axios.get('/workplaces');
if (response.data.success) {
workplaces = response.data.data;
populateWorkplaceFilters();
}
} catch (error) {
console.error('작업장 목록 로드 실패:', error);
}
}
// 설비 유형 목록 로드
async function loadEquipmentTypes() {
try {
const response = await axios.get('/equipments/types');
if (response.data.success) {
equipmentTypes = response.data.data;
populateTypeFilter();
}
} catch (error) {
console.error('설비 유형 로드 실패:', error);
}
}
// 통계 렌더링
function renderStats() {
const container = document.getElementById('statsSection');
if (!container) return;
const totalCount = allEquipments.length;
const activeCount = allEquipments.filter(e => e.status === 'active').length;
const maintenanceCount = allEquipments.filter(e => e.status === 'maintenance').length;
const inactiveCount = allEquipments.filter(e => e.status === 'inactive').length;
const totalValue = allEquipments.reduce((sum, e) => sum + (Number(e.purchase_price) || 0), 0);
const avgValue = totalCount > 0 ? totalValue / totalCount : 0;
container.innerHTML = `
<div class="eq-stat-card highlight">
<div class="eq-stat-label">전체 설비</div>
<div class="eq-stat-value">${totalCount}대</div>
<div class="eq-stat-sub">총 자산가치 ${formatPriceShort(totalValue)}</div>
</div>
<div class="eq-stat-card">
<div class="eq-stat-label">활성</div>
<div class="eq-stat-value" style="color: #16a34a;">${activeCount}대</div>
<div class="eq-stat-sub">${totalCount > 0 ? Math.round(activeCount / totalCount * 100) : 0}%</div>
</div>
<div class="eq-stat-card">
<div class="eq-stat-label">정비중</div>
<div class="eq-stat-value" style="color: #d97706;">${maintenanceCount}대</div>
<div class="eq-stat-sub">${totalCount > 0 ? Math.round(maintenanceCount / totalCount * 100) : 0}%</div>
</div>
<div class="eq-stat-card">
<div class="eq-stat-label">비활성</div>
<div class="eq-stat-value" style="color: #dc2626;">${inactiveCount}대</div>
<div class="eq-stat-sub">${totalCount > 0 ? Math.round(inactiveCount / totalCount * 100) : 0}%</div>
</div>
<div class="eq-stat-card">
<div class="eq-stat-label">평균 구입가</div>
<div class="eq-stat-value">${formatPriceShort(avgValue)}</div>
<div class="eq-stat-sub">설비당 평균</div>
</div>
`;
}
// 작업장 필터 채우기
function populateWorkplaceFilters() {
const filterWorkplace = document.getElementById('filterWorkplace');
const modalWorkplace = document.getElementById('workplaceId');
const workplaceOptions = workplaces.map(w => {
const safeId = parseInt(w.workplace_id) || 0;
const categoryName = escapeHtml(w.category_name || '');
const workplaceName = escapeHtml(w.workplace_name || '');
const label = categoryName ? categoryName + ' - ' + workplaceName : workplaceName;
return `<option value="${safeId}">${label}</option>`;
}).join('');
if (filterWorkplace) filterWorkplace.innerHTML = '<option value="">전체</option>' + workplaceOptions;
if (modalWorkplace) modalWorkplace.innerHTML = '<option value="">선택 안함</option>' + workplaceOptions;
}
// 설비 유형 필터 채우기
function populateTypeFilter() {
const filterType = document.getElementById('filterType');
if (!filterType) return;
const typeOptions = equipmentTypes.map(type => {
const safeType = escapeHtml(type || '');
return `<option value="${safeType}">${safeType}</option>`;
}).join('');
filterType.innerHTML = '<option value="">전체</option>' + typeOptions;
}
// 설비 목록 렌더링
function renderEquipmentList() {
const container = document.getElementById('equipmentList');
if (equipments.length === 0) {
container.innerHTML = `
<div class="eq-empty-state">
<p>등록된 설비가 없습니다.</p>
<button class="btn btn-primary" onclick="openEquipmentModal()">설비 추가하기</button>
</div>
`;
return;
}
const tableHTML = `
<div class="eq-result-count">
<span>검색 결과 <strong>${equipments.length}건</strong></span>
</div>
<div class="eq-table-wrapper">
<table class="eq-table">
<thead>
<tr>
<th>관리번호</th>
<th>설비명</th>
<th>모델명</th>
<th>규격</th>
<th>제조사</th>
<th>구입처</th>
<th style="text-align:right">구입가격</th>
<th>구입일자</th>
<th>상태</th>
<th style="width:80px">관리</th>
</tr>
</thead>
<tbody>
${equipments.map(eq => {
const safeId = parseInt(eq.equipment_id) || 0;
const safeCode = escapeHtml(eq.equipment_code || '-');
const safeName = escapeHtml(eq.equipment_name || '-');
const safeModel = escapeHtml(eq.model_name || '-');
const safeSpec = escapeHtml(eq.specifications || '-');
const safeManufacturer = escapeHtml(eq.manufacturer || '-');
const safeSupplier = escapeHtml(eq.supplier || '-');
const validStatuses = ['active', 'maintenance', 'inactive'];
const safeStatus = validStatuses.includes(eq.status) ? eq.status : 'inactive';
return `
<tr>
<td class="eq-col-code">${safeCode}</td>
<td class="eq-col-name" title="${safeName}">${safeName}</td>
<td class="eq-col-model" title="${safeModel}">${safeModel}</td>
<td class="eq-col-spec" title="${safeSpec}">${safeSpec}</td>
<td>${safeManufacturer}</td>
<td>${safeSupplier}</td>
<td class="eq-col-price">${eq.purchase_price ? formatPrice(eq.purchase_price) : '-'}</td>
<td class="eq-col-date">${eq.installation_date ? formatDate(eq.installation_date) : '-'}</td>
<td>
<span class="eq-status eq-status-${safeStatus}">
${getStatusText(eq.status)}
</span>
</td>
<td>
<div class="eq-actions">
<button class="eq-btn-action eq-btn-edit" onclick="editEquipment(${safeId})" title="수정">
✏️
</button>
<button class="eq-btn-action eq-btn-delete" onclick="deleteEquipment(${safeId})" title="삭제">
🗑️
</button>
</div>
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
</div>
`;
container.innerHTML = tableHTML;
}
// 상태 텍스트 변환
function getStatusText(status) {
const statusMap = {
'active': '활성',
'maintenance': '정비중',
'inactive': '비활성'
};
return statusMap[status] || status || '-';
}
// 가격 포맷팅 (전체)
function formatPrice(price) {
if (!price) return '-';
return Number(price).toLocaleString('ko-KR') + '원';
}
// 가격 포맷팅 (축약)
function formatPriceShort(price) {
if (!price) return '0원';
const num = Number(price);
if (num >= 100000000) {
return (num / 100000000).toFixed(1).replace(/\.0$/, '') + '억원';
} else if (num >= 10000) {
return (num / 10000).toFixed(0) + '만원';
}
return num.toLocaleString('ko-KR') + '원';
}
// 날짜 포맷팅
function formatDate(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit' });
}
// 필터링
function filterEquipments() {
const workplaceFilter = document.getElementById('filterWorkplace').value;
const typeFilter = document.getElementById('filterType').value;
const statusFilter = document.getElementById('filterStatus').value;
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
equipments = allEquipments.filter(e => {
if (workplaceFilter && e.workplace_id != workplaceFilter) return false;
if (typeFilter && e.equipment_type !== typeFilter) return false;
if (statusFilter && e.status !== statusFilter) return false;
if (searchTerm) {
const searchFields = [
e.equipment_name,
e.equipment_code,
e.manufacturer,
e.supplier,
e.model_name
].map(f => (f || '').toLowerCase());
if (!searchFields.some(f => f.includes(searchTerm))) return false;
}
return true;
});
renderEquipmentList();
}
// 설비 추가 모달 열기
async function openEquipmentModal(equipmentId = null) {
currentEquipment = equipmentId;
const modal = document.getElementById('equipmentModal');
const modalTitle = document.getElementById('modalTitle');
const form = document.getElementById('equipmentForm');
form.reset();
document.getElementById('equipmentId').value = '';
if (equipmentId) {
modalTitle.textContent = '설비 수정';
loadEquipmentData(equipmentId);
} else {
modalTitle.textContent = '설비 추가';
// 새 설비일 경우 다음 관리번호 자동 생성
await loadNextEquipmentCode();
}
modal.style.display = 'flex';
}
// 다음 관리번호 로드
async function loadNextEquipmentCode() {
try {
const response = await axios.get('/equipments/next-code');
if (response.data.success) {
document.getElementById('equipmentCode').value = response.data.data.next_code;
}
} catch (error) {
console.error(' 다음 관리번호 조회 실패:', error);
console.error(' 에러 상세:', error.response?.data || error.message);
// 오류 시 기본값으로 빈 값 유지 (사용자가 직접 입력)
}
}
// 설비 데이터 로드 (수정용)
async function loadEquipmentData(equipmentId) {
try {
const response = await axios.get(`/equipments/${equipmentId}`);
if (response.data.success) {
const eq = response.data.data;
document.getElementById('equipmentId').value = eq.equipment_id;
document.getElementById('equipmentCode').value = eq.equipment_code || '';
document.getElementById('equipmentName').value = eq.equipment_name || '';
document.getElementById('equipmentType').value = eq.equipment_type || '';
document.getElementById('workplaceId').value = eq.workplace_id || '';
document.getElementById('manufacturer').value = eq.manufacturer || '';
document.getElementById('supplier').value = eq.supplier || '';
document.getElementById('purchasePrice').value = eq.purchase_price || '';
document.getElementById('modelName').value = eq.model_name || '';
document.getElementById('serialNumber').value = eq.serial_number || '';
document.getElementById('installationDate').value = eq.installation_date ? eq.installation_date.split('T')[0] : '';
document.getElementById('equipmentStatus').value = eq.status || 'active';
document.getElementById('specifications').value = eq.specifications || '';
document.getElementById('notes').value = eq.notes || '';
}
} catch (error) {
console.error('설비 데이터 로드 실패:', error);
alert('설비 정보를 불러오는데 실패했습니다.');
}
}
// 설비 모달 닫기
function closeEquipmentModal() {
document.getElementById('equipmentModal').style.display = 'none';
currentEquipment = null;
}
// 설비 저장
async function saveEquipment() {
const equipmentId = document.getElementById('equipmentId').value;
const equipmentData = {
equipment_code: document.getElementById('equipmentCode').value.trim(),
equipment_name: document.getElementById('equipmentName').value.trim(),
equipment_type: document.getElementById('equipmentType').value.trim() || null,
workplace_id: document.getElementById('workplaceId').value || null,
manufacturer: document.getElementById('manufacturer').value.trim() || null,
supplier: document.getElementById('supplier').value.trim() || null,
purchase_price: document.getElementById('purchasePrice').value || null,
model_name: document.getElementById('modelName').value.trim() || null,
serial_number: document.getElementById('serialNumber').value.trim() || null,
installation_date: document.getElementById('installationDate').value || null,
status: document.getElementById('equipmentStatus').value,
specifications: document.getElementById('specifications').value.trim() || null,
notes: document.getElementById('notes').value.trim() || null
};
if (!equipmentData.equipment_code) {
alert('관리번호를 입력해주세요.');
return;
}
if (!equipmentData.equipment_name) {
alert('설비명을 입력해주세요.');
return;
}
try {
let response;
if (equipmentId) {
response = await axios.put(`/equipments/${equipmentId}`, equipmentData);
} else {
response = await axios.post('/equipments', equipmentData);
}
if (response.data.success) {
alert(equipmentId ? '설비가 수정되었습니다.' : '설비가 추가되었습니다.');
closeEquipmentModal();
await loadEquipments();
await loadEquipmentTypes();
}
} catch (error) {
console.error('설비 저장 실패:', error);
if (error.response?.data?.message) {
alert(error.response.data.message);
} else {
alert('설비 저장 중 오류가 발생했습니다.');
}
}
}
// 설비 수정
function editEquipment(equipmentId) {
openEquipmentModal(equipmentId);
}
// 설비 삭제
async function deleteEquipment(equipmentId) {
const equipment = allEquipments.find(e => e.equipment_id === equipmentId);
if (!equipment) return;
if (!confirm(`'${equipment.equipment_name}' 설비를 삭제하시겠습니까?`)) {
return;
}
try {
const response = await axios.delete(`/equipments/${equipmentId}`);
if (response.data.success) {
alert('설비가 삭제되었습니다.');
await loadEquipments();
}
} catch (error) {
console.error('설비 삭제 실패:', error);
alert('설비 삭제 중 오류가 발생했습니다.');
}
}
// ESC 키로 모달 닫기
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeEquipmentModal();
}
});
// 모달 외부 클릭 시 닫기
document.getElementById('equipmentModal')?.addEventListener('click', (e) => {
if (e.target.id === 'equipmentModal') {
closeEquipmentModal();
}
});

View File

@@ -1,49 +0,0 @@
import { API, getAuthHeaders } from '/js/api-config.js';
document.getElementById('uploadForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
try {
// FormData를 사용할 때는 Content-Type을 설정하지 않음 (자동 설정됨)
const token = localStorage.getItem('sso_token');
const res = await fetch(`${API}/factoryinfo`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
},
body: formData
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || '등록 실패');
}
alert('등록 완료!');
location.reload();
} catch (err) {
console.error(err);
alert('등록 실패: ' + err.message);
}
});
// 파일 선택 시 미리보기 (선택사항)
const fileInput = document.querySelector('input[name="map_image"]');
if (fileInput) {
fileInput.addEventListener('change', function(e) {
const file = e.target.files[0];
if (file && file.type.startsWith('image/')) {
// 미리보기 요소가 있을 경우에만 동작
const preview = document.getElementById('file-preview');
if (preview) {
const reader = new FileReader();
reader.onload = function(e) {
preview.innerHTML = `<img src="${e.target.result}" alt="미리보기" style="max-width: 200px; max-height: 200px; border-radius: 8px;">`;
};
reader.readAsDataURL(file);
}
}
});
}

View File

@@ -1,38 +0,0 @@
import { API, getAuthHeaders } from '/js/api-config.js';
(async () => {
const pathParts = location.pathname.split('/');
const id = pathParts[pathParts.length - 1];
try {
const res = await fetch(`${API}/factoryinfo/${id}`, {
headers: getAuthHeaders()
});
if (!res.ok) {
throw new Error('조회 실패');
}
const data = await res.json();
// DOM 요소가 존재하는지 확인 후 설정
const nameEl = document.getElementById('factoryName');
if (nameEl) nameEl.textContent = data.factory_name;
const addressEl = document.getElementById('factoryAddress');
if (addressEl) addressEl.textContent = '📍 ' + data.address;
const imageEl = document.getElementById('factoryImage');
if (imageEl) imageEl.src = data.map_image_url;
const descEl = document.getElementById('factoryDescription');
if (descEl) descEl.textContent = data.description;
} catch (err) {
console.error(err);
const container = document.querySelector('.container');
if (container) {
container.innerHTML = '<p>공장 정보를 불러올 수 없습니다.</p>';
}
}
})();

View File

@@ -1,149 +0,0 @@
// /js/group-leader-dashboard.js
// 그룹장 전용 대시보드 - 실시간 근태 및 작업 현황 (Real Data Version)
import { apiCall } from './api-config.js';
// 상태별 스타일/텍스트 매핑
const STATUS_MAP = {
'incomplete': { text: '미제출', class: 'status-incomplete', icon: '❌', color: '#ff5252' },
'partial': { text: '작성중', class: 'status-warning', icon: '📝', color: '#ff9800' },
'complete': { text: '제출완료', class: 'status-success', icon: '✅', color: '#4caf50' },
'overtime': { text: '초과근무', class: 'status-info', icon: '🌙', color: '#673ab7' },
'vacation': { text: '휴가', class: 'status-vacation', icon: '🏖️', color: '#2196f3' }
};
// 현재 선택된 날짜
let currentSelectedDate = new Date().toISOString().split('T')[0];
/**
* 📅 날짜 초기화 및 이벤트 리스너 등록
*/
function initDateSelector() {
const dateInput = document.getElementById('selectedDate');
const refreshBtn = document.getElementById('refreshBtn');
if (dateInput) {
dateInput.value = currentSelectedDate;
dateInput.addEventListener('change', (e) => {
currentSelectedDate = e.target.value;
loadDailyWorkStatus();
});
}
if (refreshBtn) {
refreshBtn.addEventListener('click', () => {
loadDailyWorkStatus();
showToast('데이터를 새로고침했습니다.', 'success');
});
}
}
/**
* 🔄 일일 근태 현황 로드 (API 호출)
*/
async function loadDailyWorkStatus() {
const container = document.getElementById('workStatusContainer');
if (!container) return;
// 로딩 표시
container.innerHTML = `
<div class="loading-state">
<div class="spinner"></div>
<p>작업 현황을 불러오는 중...</p>
</div>
`;
try {
const result = await apiCall(`/attendance/daily-status?date=${currentSelectedDate}`);
const workers = result.data || [];
renderWorkStatus(workers);
updateSummaryStats(workers);
} catch (error) {
console.error('현황 로드 오류:', error);
container.innerHTML = `
<div class="error-state">
<p>⚠️ 데이터를 불러오는데 실패했습니다.</p>
<button onclick="loadDailyWorkStatus()" class="btn btn-sm btn-outline">재시도</button>
</div>
`;
}
}
/**
* 📊 통계 요약 업데이트
*/
function updateSummaryStats(workers) {
// 요약 카드가 있다면 업데이트 (현재 HTML에는 없으므로 생략 가능하거나 동적으로 추가)
// 여기서는 콘솔에만 로그
const stats = workers.reduce((acc, w) => {
acc[w.status] = (acc[w.status] || 0) + 1;
return acc;
}, {});
console.log('Daily Stats:', stats);
}
/**
* 🎨 현황 리스트 렌더링
*/
function renderWorkStatus(workers) {
const container = document.getElementById('workStatusContainer');
if (!container) return;
if (workers.length === 0) {
container.innerHTML = '<div class="empty-state">등록된 작업자가 없습니다.</div>';
return;
}
// 상태 우선순위 정렬 (미제출 -> 작성중 -> 완료)
const sortOrder = ['incomplete', 'partial', 'vacation', 'complete', 'overtime'];
workers.sort((a, b) => {
return sortOrder.indexOf(a.status) - sortOrder.indexOf(b.status) || a.worker_name.localeCompare(b.worker_name);
});
const html = `
<div class="status-grid">
${workers.map(worker => {
const statusInfo = STATUS_MAP[worker.status] || { text: worker.status, class: '', icon: '❓', color: '#999' };
return `
<div class="worker-card ${worker.status === 'incomplete' ? 'status-alert' : ''}" style="border-left: 4px solid ${statusInfo.color}">
<div class="worker-header">
<span class="worker-name">${worker.worker_name}</span>
<span class="worker-job">${worker.job_type || '-'}</span>
</div>
<div class="worker-body">
<div class="status-badge" style="background-color: ${statusInfo.color}20; color: ${statusInfo.color}">
${statusInfo.icon} ${statusInfo.text}
</div>
<div class="work-hours">
${worker.total_work_hours > 0 ? worker.total_work_hours + '시간' : '-'}
</div>
</div>
${worker.status === 'incomplete' ? `
<div class="worker-footer">
<span class="alert-text">⚠️ 보고서 미제출</span>
</div>
` : ''}
</div>
`;
}).join('')}
</div>
`;
container.innerHTML = html;
}
// showToast → api-base.js 전역 사용
// 초기화
document.addEventListener('DOMContentLoaded', () => {
initDateSelector();
loadDailyWorkStatus();
});
// 전역 노출 대신 모듈로 내보내기
export { loadDailyWorkStatus as refreshTeamStatus };

View File

@@ -1,421 +0,0 @@
/**
* 신고 카테고리 관리 JavaScript
*/
import { API, getAuthHeaders } from '/js/api-config.js';
let currentType = 'nonconformity';
let categories = [];
let items = [];
// 초기화
document.addEventListener('DOMContentLoaded', async () => {
await loadCategories();
});
/**
* 유형 탭 전환
*/
window.switchType = async function(type) {
currentType = type;
// 탭 상태 업데이트
document.querySelectorAll('.type-tab').forEach(tab => {
tab.classList.toggle('active', tab.dataset.type === type);
});
await loadCategories();
};
/**
* 카테고리 로드
*/
async function loadCategories() {
const container = document.getElementById('categoryList');
container.innerHTML = '<div class="empty-state">카테고리를 불러오는 중...</div>';
try {
const response = await fetch(`${API}/work-issues/categories/type/${currentType}`, {
headers: getAuthHeaders()
});
if (!response.ok) throw new Error('카테고리 조회 실패');
const data = await response.json();
if (data.success && data.data) {
categories = data.data;
// 항목도 로드
const itemsResponse = await fetch(`${API}/work-issues/items`, {
headers: getAuthHeaders()
});
if (itemsResponse.ok) {
const itemsData = await itemsResponse.json();
if (itemsData.success) {
items = itemsData.data || [];
}
}
renderCategories();
} else {
container.innerHTML = '<div class="empty-state">카테고리가 없습니다.</div>';
}
} catch (error) {
console.error('카테고리 로드 실패:', error);
container.innerHTML = '<div class="empty-state">카테고리를 불러오지 못했습니다.</div>';
}
}
/**
* 카테고리 렌더링
*/
function renderCategories() {
const container = document.getElementById('categoryList');
if (categories.length === 0) {
container.innerHTML = '<div class="empty-state">등록된 카테고리가 없습니다.</div>';
return;
}
const severityLabel = {
low: '낮음',
medium: '보통',
high: '높음',
critical: '심각'
};
container.innerHTML = categories.map(cat => {
const catItems = items.filter(item => item.category_id === cat.category_id);
return `
<div class="category-section" data-category-id="${cat.category_id}">
<div class="category-header" onclick="toggleCategory(${cat.category_id})">
<div class="category-name">${cat.category_name}</div>
<div class="category-badge">
<span class="severity-badge ${cat.severity || 'medium'}">${severityLabel[cat.severity] || '보통'}</span>
<span class="item-count">${catItems.length}개 항목</span>
<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); openCategoryModal(${cat.category_id})">수정</button>
</div>
</div>
<div class="category-items">
<div class="item-list">
${catItems.length > 0 ? catItems.map(item => `
<div class="item-card">
<div class="item-info">
<div class="item-name">${item.item_name}</div>
${item.description ? `<div class="item-desc">${item.description}</div>` : ''}
</div>
<div class="item-actions">
<span class="severity-badge ${item.severity || 'medium'}">${severityLabel[item.severity] || '보통'}</span>
<button class="btn btn-secondary btn-sm" onclick="openItemModal(${cat.category_id}, ${item.item_id})">수정</button>
</div>
</div>
`).join('') : '<div class="empty-state" style="padding: 24px;">등록된 항목이 없습니다.</div>'}
</div>
<div class="add-item-form">
<input type="text" id="newItemName_${cat.category_id}" placeholder="새 항목 이름">
<button class="btn btn-primary btn-sm" onclick="quickAddItem(${cat.category_id})">추가</button>
<button class="btn btn-secondary btn-sm" onclick="openItemModal(${cat.category_id})">상세 추가</button>
</div>
</div>
</div>
`;
}).join('');
}
/**
* 카테고리 토글
*/
window.toggleCategory = function(categoryId) {
const section = document.querySelector(`.category-section[data-category-id="${categoryId}"]`);
if (section) {
section.classList.toggle('expanded');
}
};
/**
* 카테고리 모달 열기
*/
window.openCategoryModal = function(categoryId = null) {
const modal = document.getElementById('categoryModal');
const title = document.getElementById('categoryModalTitle');
const deleteBtn = document.getElementById('deleteCategoryBtn');
document.getElementById('categoryId').value = '';
document.getElementById('categoryName').value = '';
document.getElementById('categoryDescription').value = '';
document.getElementById('categorySeverity').value = 'medium';
if (categoryId) {
const category = categories.find(c => c.category_id === categoryId);
if (category) {
title.textContent = '카테고리 수정';
document.getElementById('categoryId').value = category.category_id;
document.getElementById('categoryName').value = category.category_name;
document.getElementById('categoryDescription').value = category.description || '';
document.getElementById('categorySeverity').value = category.severity || 'medium';
deleteBtn.style.display = 'block';
}
} else {
title.textContent = '새 카테고리';
deleteBtn.style.display = 'none';
}
modal.style.display = 'flex';
};
/**
* 카테고리 모달 닫기
*/
window.closeCategoryModal = function() {
document.getElementById('categoryModal').style.display = 'none';
};
/**
* 카테고리 저장
*/
window.saveCategory = async function() {
const categoryId = document.getElementById('categoryId').value;
const name = document.getElementById('categoryName').value.trim();
const description = document.getElementById('categoryDescription').value.trim();
const severity = document.getElementById('categorySeverity').value;
if (!name) {
alert('카테고리 이름을 입력하세요.');
return;
}
try {
const url = categoryId
? `${API}/work-issues/categories/${categoryId}`
: `${API}/work-issues/categories`;
const response = await fetch(url, {
method: categoryId ? 'PUT' : 'POST',
headers: {
...getAuthHeaders(),
'Content-Type': 'application/json'
},
body: JSON.stringify({
category_name: name,
category_type: currentType,
description,
severity
})
});
const data = await response.json();
if (response.ok && data.success) {
alert(categoryId ? '카테고리가 수정되었습니다.' : '카테고리가 추가되었습니다.');
closeCategoryModal();
await loadCategories();
} else {
throw new Error(data.error || '저장 실패');
}
} catch (error) {
console.error('카테고리 저장 실패:', error);
alert('카테고리 저장에 실패했습니다: ' + error.message);
}
};
/**
* 카테고리 삭제
*/
window.deleteCategory = async function() {
const categoryId = document.getElementById('categoryId').value;
if (!categoryId) return;
const catItems = items.filter(item => item.category_id == categoryId);
if (catItems.length > 0) {
alert(`이 카테고리에 ${catItems.length}개의 항목이 있습니다. 먼저 항목을 삭제하세요.`);
return;
}
if (!confirm('이 카테고리를 삭제하시겠습니까?')) return;
try {
const response = await fetch(`${API}/work-issues/categories/${categoryId}`, {
method: 'DELETE',
headers: getAuthHeaders()
});
const data = await response.json();
if (response.ok && data.success) {
alert('카테고리가 삭제되었습니다.');
closeCategoryModal();
await loadCategories();
} else {
throw new Error(data.error || '삭제 실패');
}
} catch (error) {
console.error('카테고리 삭제 실패:', error);
alert('카테고리 삭제에 실패했습니다: ' + error.message);
}
};
/**
* 항목 모달 열기
*/
window.openItemModal = function(categoryId, itemId = null) {
const modal = document.getElementById('itemModal');
const title = document.getElementById('itemModalTitle');
const deleteBtn = document.getElementById('deleteItemBtn');
document.getElementById('itemId').value = '';
document.getElementById('itemCategoryId').value = categoryId;
document.getElementById('itemName').value = '';
document.getElementById('itemDescription').value = '';
document.getElementById('itemSeverity').value = 'medium';
if (itemId) {
const item = items.find(i => i.item_id === itemId);
if (item) {
title.textContent = '항목 수정';
document.getElementById('itemId').value = item.item_id;
document.getElementById('itemName').value = item.item_name;
document.getElementById('itemDescription').value = item.description || '';
document.getElementById('itemSeverity').value = item.severity || 'medium';
deleteBtn.style.display = 'block';
}
} else {
const category = categories.find(c => c.category_id === categoryId);
title.textContent = `새 항목 (${category?.category_name || ''})`;
deleteBtn.style.display = 'none';
}
modal.style.display = 'flex';
};
/**
* 항목 모달 닫기
*/
window.closeItemModal = function() {
document.getElementById('itemModal').style.display = 'none';
};
/**
* 항목 저장
*/
window.saveItem = async function() {
const itemId = document.getElementById('itemId').value;
const categoryId = document.getElementById('itemCategoryId').value;
const name = document.getElementById('itemName').value.trim();
const description = document.getElementById('itemDescription').value.trim();
const severity = document.getElementById('itemSeverity').value;
if (!name) {
alert('항목 이름을 입력하세요.');
return;
}
try {
const url = itemId
? `${API}/work-issues/items/${itemId}`
: `${API}/work-issues/items`;
const response = await fetch(url, {
method: itemId ? 'PUT' : 'POST',
headers: {
...getAuthHeaders(),
'Content-Type': 'application/json'
},
body: JSON.stringify({
category_id: categoryId,
item_name: name,
description,
severity
})
});
const data = await response.json();
if (response.ok && data.success) {
alert(itemId ? '항목이 수정되었습니다.' : '항목이 추가되었습니다.');
closeItemModal();
await loadCategories();
} else {
throw new Error(data.error || '저장 실패');
}
} catch (error) {
console.error('항목 저장 실패:', error);
alert('항목 저장에 실패했습니다: ' + error.message);
}
};
/**
* 항목 삭제
*/
window.deleteItem = async function() {
const itemId = document.getElementById('itemId').value;
if (!itemId) return;
if (!confirm('이 항목을 삭제하시겠습니까?')) return;
try {
const response = await fetch(`${API}/work-issues/items/${itemId}`, {
method: 'DELETE',
headers: getAuthHeaders()
});
const data = await response.json();
if (response.ok && data.success) {
alert('항목이 삭제되었습니다.');
closeItemModal();
await loadCategories();
} else {
throw new Error(data.error || '삭제 실패');
}
} catch (error) {
console.error('항목 삭제 실패:', error);
alert('항목 삭제에 실패했습니다: ' + error.message);
}
};
/**
* 빠른 항목 추가
*/
window.quickAddItem = async function(categoryId) {
const input = document.getElementById(`newItemName_${categoryId}`);
const name = input.value.trim();
if (!name) {
alert('항목 이름을 입력하세요.');
input.focus();
return;
}
try {
const response = await fetch(`${API}/work-issues/items`, {
method: 'POST',
headers: {
...getAuthHeaders(),
'Content-Type': 'application/json'
},
body: JSON.stringify({
category_id: categoryId,
item_name: name,
severity: 'medium'
})
});
const data = await response.json();
if (response.ok && data.success) {
input.value = '';
await loadCategories();
// 카테고리 펼침 유지
toggleCategory(categoryId);
} else {
throw new Error(data.error || '추가 실패');
}
} catch (error) {
console.error('항목 추가 실패:', error);
alert('항목 추가에 실패했습니다: ' + error.message);
}
};

View File

@@ -1,49 +0,0 @@
// /js/login.js
import { saveAuthData, clearAuthData } from './auth.js';
import { redirectToDefaultDashboard } from './navigation.js';
// api-helper.js가 ES6 모듈로 변환되면 import를 사용해야 합니다.
// import { login } from './api-helper.js';
document.getElementById('loginForm').addEventListener('submit', async function (e) {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const errorDiv = document.getElementById('error');
const submitBtn = e.target.querySelector('button[type="submit"]');
const originalText = submitBtn.textContent;
// 로딩 상태 시작
submitBtn.disabled = true;
submitBtn.textContent = '로그인 중...';
errorDiv.style.display = 'none';
try {
// 현재는 window 객체를 통해 호출하지만, 향후 모듈화 필요
const result = await window.login(username, password);
if (result.success && result.data && result.data.token) {
// auth.js에서 가져온 함수로 인증 정보 저장
saveAuthData(result.data.token, result.data.user);
// navigation.js를 통해 리디렉션
redirectToDefaultDashboard(result.data.redirectUrl);
} else {
// api-helper가 에러를 throw하므로 이 블록은 실행될 가능성이 낮음
clearAuthData();
errorDiv.textContent = result.error || '로그인에 실패했습니다.';
errorDiv.style.display = 'block';
}
} catch (err) {
console.error('로그인 오류:', err);
clearAuthData();
errorDiv.textContent = err.message || '서버 연결에 실패했습니다.';
errorDiv.style.display = 'block';
} finally {
// 로딩 상태 해제
submitBtn.disabled = false;
submitBtn.textContent = originalText;
}
});

View File

@@ -1,86 +0,0 @@
import { API, getAuthHeaders } from '/js/api-config.js';
function createRow(item, cols, delHandler) {
const tr = document.createElement('tr');
cols.forEach(key => {
const td = document.createElement('td');
td.textContent = item[key];
tr.appendChild(td);
});
const delBtn = document.createElement('button');
delBtn.textContent = '삭제';
delBtn.className = 'btn-delete';
delBtn.onclick = () => delHandler(item);
const td = document.createElement('td');
td.appendChild(delBtn);
tr.appendChild(td);
return tr;
}
const form = document.getElementById('issueTypeForm');
form?.addEventListener('submit', async e => {
e.preventDefault();
const body = {
category: document.getElementById('category').value,
subcategory: document.getElementById('subcategory').value
};
try {
const res = await fetch(`${API}/issue-types`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(body)
});
const result = await res.json();
if (res.ok && result.success) {
alert('✅ 등록 완료');
form.reset();
loadIssueTypes();
} else {
alert('❌ 실패: ' + (result.error || '알 수 없는 오류'));
}
} catch (err) {
alert('🚨 서버 오류: ' + err.message);
}
});
async function loadIssueTypes() {
const tbody = document.getElementById('issueTypeTableBody');
tbody.innerHTML = '<tr><td colspan="4">불러오는 중...</td></tr>';
try {
const res = await fetch(`${API}/issue-types`, {
headers: getAuthHeaders()
});
const list = await res.json();
tbody.innerHTML = '';
if (Array.isArray(list)) {
list.forEach(item => {
const row = createRow(item, ['issue_type_id', 'category', 'subcategory'], async t => {
if (!confirm('삭제하시겠습니까?')) return;
try {
const delRes = await fetch(`${API}/issue-types/${t.issue_type_id}`, {
method: 'DELETE',
headers: getAuthHeaders()
});
if (delRes.ok) {
loadIssueTypes();
} else {
alert('삭제 실패');
}
} catch (err) {
alert('삭제 중 오류: ' + err.message);
}
});
tbody.appendChild(row);
});
} else {
tbody.innerHTML = '<tr><td colspan="4">데이터 형식 오류</td></tr>';
}
} catch (err) {
tbody.innerHTML = '<tr><td colspan="4">로드 실패: ' + err.message + '</td></tr>';
}
}
document.addEventListener('DOMContentLoaded', () => {
loadIssueTypes();
});

View File

@@ -1,93 +0,0 @@
import { API, getAuthHeaders, ensureAuthenticated } from '/js/api-config.js';
// 인증 확인
ensureAuthenticated();
// 행 생성
function createRow(item, delHandler) {
const tr = document.createElement('tr');
const label = `${item.material} / ${item.diameter_in} / ${item.schedule}`;
tr.innerHTML = `
<td>${item.spec_id}</td>
<td>${label}</td>
<td><button class="btn-delete">삭제</button></td>
`;
tr.querySelector('.btn-delete').onclick = () => delHandler(item);
return tr;
}
// 등록
document.getElementById('specForm')?.addEventListener('submit', async e => {
e.preventDefault();
const material = document.getElementById('material').value.trim();
const diameter = document.getElementById('diameter_in').value.trim();
const schedule = document.getElementById('schedule').value.trim();
if (!material || !diameter || !schedule) {
return alert('모든 항목을 입력하세요.');
}
try {
const res = await fetch(`${API}/pipespecs`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ material, diameter_in: diameter, schedule })
});
const result = await res.json();
if (res.ok && result.success) {
alert('✅ 등록 완료');
e.target.reset();
loadSpecs();
} else {
alert('❌ 실패: ' + (result.error || '등록 실패'));
}
} catch (err) {
alert('🚨 서버 오류: ' + err.message);
}
});
// 불러오기
async function loadSpecs() {
const tbody = document.getElementById('specTableBody');
tbody.innerHTML = '<tr><td colspan="3">불러오는 중...</td></tr>';
try {
const res = await fetch(`${API}/pipespecs`, {
headers: getAuthHeaders()
});
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`);
}
const list = await res.json();
tbody.innerHTML = '';
if (Array.isArray(list)) {
list.forEach(item => {
const row = createRow(item, async (spec) => {
if (!confirm('삭제하시겠습니까?')) return;
try {
const delRes = await fetch(`${API}/pipespecs/${spec.spec_id}`, {
method: 'DELETE',
headers: getAuthHeaders()
});
if (delRes.ok) {
loadSpecs();
} else {
alert('삭제 실패');
}
} catch (err) {
alert('삭제 중 오류: ' + err.message);
}
});
tbody.appendChild(row);
});
} else {
tbody.innerHTML = '<tr><td colspan="3">데이터 형식 오류</td></tr>';
}
} catch (err) {
tbody.innerHTML = '<tr><td colspan="3">로드 실패: ' + err.message + '</td></tr>';
}
}
window.addEventListener('DOMContentLoaded', loadSpecs);

View File

@@ -1,90 +0,0 @@
// /js/manage-project.js
// The ensureAuthenticated, API, and getAuthHeaders functions are now handled by the global api-helper.js
function createRow(item, cols, delHandler) {
const tr = document.createElement('tr');
cols.forEach(key => {
const td = document.createElement('td');
td.textContent = item[key];
tr.appendChild(td);
});
const delBtn = document.createElement('button');
delBtn.textContent = '삭제';
delBtn.className = 'btn-delete';
delBtn.onclick = () => delHandler(item);
const td = document.createElement('td');
td.appendChild(delBtn);
tr.appendChild(td);
return tr;
}
const projectForm = document.getElementById('projectForm');
projectForm?.addEventListener('submit', async e => {
e.preventDefault();
const body = {
job_no: document.getElementById('job_no').value.trim(),
project_name: document.getElementById('project_name').value.trim(),
contract_date: document.getElementById('contract_date').value,
due_date: document.getElementById('due_date').value,
delivery_method: document.getElementById('delivery_method').value.trim(),
site: document.getElementById('site').value.trim(),
pm: document.getElementById('pm').value.trim()
};
if (!body.project_name || !body.job_no) {
return alert('필수 항목을 입력하세요.');
}
try {
const result = await apiPost('/projects', body);
if (result.success) {
alert('✅ 등록 완료');
projectForm.reset();
loadProjects();
} else {
alert('❌ 실패: ' + (result.error || '알 수 없는 오류'));
}
} catch (err) {
alert('🚨 서버 오류: ' + err.message);
}
});
async function loadProjects() {
const tbody = document.getElementById('projectTableBody');
tbody.innerHTML = '<tr><td colspan="9">불러오는 중...</td></tr>';
try {
const result = await apiGet('/projects');
tbody.innerHTML = '';
if (result.success && Array.isArray(result.data)) {
result.data.forEach(item => {
const row = createRow(item, [
'project_id', 'job_no', 'project_name', 'contract_date',
'due_date', 'delivery_method', 'site', 'pm'
], async p => {
if (!confirm('삭제하시겠습니까?')) return;
try {
const delRes = await apiDelete(`/projects/${p.project_id}`);
if (delRes.success) {
alert('✅ 삭제 완료');
loadProjects();
} else {
alert('❌ 삭제 실패: ' + (delRes.error || '알 수 없는 오류'));
}
} catch (err) {
alert('🚨 삭제 중 오류: ' + err.message);
}
});
tbody.appendChild(row);
});
} else {
tbody.innerHTML = '<tr><td colspan="9">데이터 형식 오류</td></tr>';
}
} catch (err) {
tbody.innerHTML = '<tr><td colspan="9">로드 실패: ' + err.message + '</td></tr>';
}
}
window.addEventListener('DOMContentLoaded', loadProjects);

View File

@@ -1,111 +0,0 @@
// /js/manage-worker.js
import { API, getAuthHeaders, ensureAuthenticated } from '/js/api-config.js';
// 인증 확인
ensureAuthenticated();
// ✅ 테이블 행 생성
function createRow(item, cols, delHandler) {
const tr = document.createElement('tr');
cols.forEach(key => {
const td = document.createElement('td');
td.textContent = item[key] || '-';
tr.appendChild(td);
});
const delBtn = document.createElement('button');
delBtn.textContent = '삭제';
delBtn.className = 'btn-delete';
delBtn.onclick = () => delHandler(item);
const td = document.createElement('td');
td.appendChild(delBtn);
tr.appendChild(td);
return tr;
}
// ✅ 작업자 등록
const workerForm = document.getElementById('workerForm');
workerForm?.addEventListener('submit', async e => {
e.preventDefault();
const body = {
worker_name: document.getElementById('workerName').value.trim(),
position: document.getElementById('position').value.trim()
};
if (!body.worker_name || !body.position) {
return alert('모든 필드를 입력해주세요.');
}
try {
const res = await fetch(`${API}/workers`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(body)
});
const result = await res.json();
if (res.ok && result.success) {
alert('✅ 등록 완료');
workerForm.reset();
loadWorkers();
} else {
alert('❌ 실패: ' + (result.error || '알 수 없는 오류'));
}
} catch (err) {
alert('🚨 서버 오류: ' + err.message);
}
});
// ✅ 작업자 목록 불러오기
async function loadWorkers() {
const tbody = document.getElementById('workerTableBody');
tbody.innerHTML = '<tr><td colspan="4">불러오는 중...</td></tr>';
try {
const res = await fetch(`${API}/workers`, {
headers: getAuthHeaders()
});
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`);
}
const response = await res.json();
const list = response.data || response; // 새로운 API 응답 구조 지원
tbody.innerHTML = '';
if (Array.isArray(list)) {
list.forEach(item => {
const row = createRow(item, ['user_id', 'worker_name', 'position'], async w => {
if (!confirm('삭제하시겠습니까?')) return;
try {
const delRes = await fetch(`${API}/workers/${w.user_id}`, {
method: 'DELETE',
headers: getAuthHeaders()
});
if (delRes.ok) {
alert('✅ 삭제 완료');
loadWorkers();
} else {
alert('❌ 삭제 실패');
}
} catch (err) {
alert('🚨 삭제 중 오류: ' + err.message);
}
});
tbody.appendChild(row);
});
} else {
tbody.innerHTML = '<tr><td colspan="4">데이터 형식 오류</td></tr>';
}
} catch (err) {
tbody.innerHTML = '<tr><td colspan="4">로드 실패: ' + err.message + '</td></tr>';
}
}
// ✅ 초기 로딩
window.addEventListener('DOMContentLoaded', loadWorkers);

View File

@@ -1,940 +0,0 @@
// management-dashboard.js - 관리자 대시보드 전용 스크립트
// =================================================================
// 🌐 통합 API 설정 import
// =================================================================
import { API, getAuthHeaders, apiCall } from '/js/api-config.js';
// 전역 변수
let workers = [];
let workData = [];
let filteredWorkData = [];
let currentDate = '';
let currentUser = null;
// 권한 레벨 매핑
const ACCESS_LEVELS = {
worker: 1,
group_leader: 2,
support_team: 3,
admin: 4,
system: 5
};
// 한국 시간 기준 오늘 날짜 가져오기
function getKoreaToday() {
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
// 현재 로그인한 사용자 정보 가져오기
function getCurrentUser() {
try {
const token = localStorage.getItem('sso_token');
if (!token) return null;
const payloadBase64 = token.split('.')[1];
if (payloadBase64) {
const payload = JSON.parse(atob(payloadBase64));
return payload;
}
} catch (error) {
}
try {
const userInfo = localStorage.getItem('sso_user');
if (userInfo) {
const parsed = JSON.parse(userInfo);
return parsed;
}
} catch (error) {
}
return null;
}
// 권한 체크 함수
function checkPermission() {
currentUser = getCurrentUser();
if (!currentUser) {
showMessage('로그인이 필요합니다.', 'error');
setTimeout(() => {
window.location.href = '/';
}, 2000);
return false;
}
const userAccessLevel = currentUser.access_level;
const accessLevelValue = ACCESS_LEVELS[userAccessLevel] || 0;
console.log('사용자 권한 체크:', {
username: currentUser.username || currentUser.name,
access_level: userAccessLevel,
level_value: accessLevelValue,
required_level: ACCESS_LEVELS.group_leader
});
if (accessLevelValue < ACCESS_LEVELS.group_leader) {
showMessage('그룹장 이상의 권한이 필요합니다. 현재 권한: ' + userAccessLevel, 'error');
setTimeout(() => {
window.location.href = '/';
}, 3000);
return false;
}
return true;
}
// 메시지 표시
function showMessage(message, type = 'info') {
const container = document.getElementById('message-container');
container.innerHTML = `<div class="message ${type}">${message}</div>`;
if (type === 'success') {
setTimeout(() => {
hideMessage();
}, 5000);
}
}
function hideMessage() {
document.getElementById('message-container').innerHTML = '';
}
// 로딩 표시
function showLoading() {
document.getElementById('loadingSpinner').style.display = 'flex';
document.getElementById('summarySection').style.display = 'none';
document.getElementById('actionBar').style.display = 'none';
document.getElementById('workersSection').style.display = 'none';
document.getElementById('noDataMessage').style.display = 'none';
}
function hideLoading() {
document.getElementById('loadingSpinner').style.display = 'none';
}
// 작업자 데이터 로드
async function loadWorkers() {
try {
console.log('작업자 데이터 로딩 중... (통합 API)');
const data = await apiCall(`${API}/workers`);
const allWorkers = Array.isArray(data) ? data : (data.data || data.workers || []);
// 활성화된 작업자만 필터링
workers = allWorkers.filter(worker => {
return worker.status === 'active' || worker.is_active === 1 || worker.is_active === true;
});
} catch (error) {
console.error('작업자 로딩 오류:', error);
throw error;
}
}
// 특정 날짜의 작업 데이터 로드 (개선된 버전)
async function loadWorkData(date) {
try {
console.log(`${date} 날짜의 작업 데이터 로딩 중... (통합 API)`);
// 1차: view_all=true로 전체 데이터 시도
let queryParams = `date=${date}&view_all=true`;
let data = await apiCall(`${API}/daily-work-reports?${queryParams}`);
workData = Array.isArray(data) ? data : (data.data || []);
// 데이터가 없으면 다른 방법들 시도
if (workData.length === 0) {
// 2차: admin=true로 시도
queryParams = `date=${date}&admin=true`;
data = await apiCall(`${API}/daily-work-reports?${queryParams}`);
workData = Array.isArray(data) ? data : (data.data || []);
if (workData.length === 0) {
// 3차: 날짜 경로 파라미터로 시도
data = await apiCall(`${API}/daily-work-reports/date/${date}`);
workData = Array.isArray(data) ? data : (data.data || []);
if (workData.length === 0) {
// 4차: 기본 파라미터만으로 시도
data = await apiCall(`${API}/daily-work-reports?date=${date}`);
workData = Array.isArray(data) ? data : (data.data || []);
}
}
}
// 디버깅을 위한 상세 로그
if (workData.length > 0) {
const uniqueWorkers = [...new Set(workData.map(w => w.worker_name))];
} else {
}
return workData;
} catch (error) {
console.error('작업 데이터 로딩 오류:', error);
// 에러 시에도 빈 배열 반환하여 앱이 중단되지 않도록
workData = [];
// 구체적인 에러 정보 표시
if (error.message.includes('403')) {
throw new Error('해당 날짜의 데이터에 접근할 권한이 없습니다.');
} else if (error.message.includes('404')) {
throw new Error('해당 날짜에 입력된 작업 데이터가 없습니다.');
} else {
throw error;
}
}
}
// 대시보드 데이터 로드
async function loadDashboardData() {
const selectedDate = document.getElementById('selectedDate').value;
if (!selectedDate) {
showMessage('날짜를 선택해주세요.', 'error');
return;
}
currentDate = selectedDate;
showLoading();
hideMessage();
try {
// 병렬로 데이터 로드
await Promise.all([
loadWorkers(),
loadWorkData(selectedDate)
]);
// 데이터 분석 및 표시
const dashboardData = analyzeDashboardData();
displayDashboard(dashboardData);
hideLoading();
} catch (error) {
console.error('대시보드 데이터 로드 실패:', error);
hideLoading();
showMessage('데이터를 불러오는 중 오류가 발생했습니다: ' + error.message, 'error');
// 에러 시 데이터 없음 메시지 표시
document.getElementById('noDataMessage').style.display = 'block';
}
}
// 대시보드 데이터 분석 (개선된 버전)
function analyzeDashboardData() {
console.log('대시보드 데이터 분석 시작');
// 작업자별 데이터 그룹화
const workerWorkData = {};
workData.forEach(work => {
const userId = work.user_id;
if (!workerWorkData[userId]) {
workerWorkData[userId] = [];
}
workerWorkData[userId].push(work);
});
// 전체 통계 계산
const totalWorkers = workers.length;
const workersWithData = Object.keys(workerWorkData).length;
const workersWithoutData = totalWorkers - workersWithData;
const totalHours = workData.reduce((sum, work) => sum + parseFloat(work.work_hours || 0), 0);
const totalEntries = workData.length;
const errorCount = workData.filter(work => work.work_status_id === 2).length;
// 작업자별 상세 분석 (개선된 버전)
const workerAnalysis = workers.map(worker => {
const workerWorks = workerWorkData[worker.user_id] || [];
const workerHours = workerWorks.reduce((sum, work) => sum + parseFloat(work.work_hours || 0), 0);
// 작업 유형 분석 (실제 이름으로)
const workTypes = [...new Set(workerWorks.map(work => work.work_type_name).filter(Boolean))];
// 프로젝트 분석
const workerProjects = [...new Set(workerWorks.map(work => work.project_name).filter(Boolean))];
// 기여자 분석
const workerContributors = [...new Set(workerWorks.map(work => work.created_by_name).filter(Boolean))];
// 상태 결정 (더 세밀한 기준)
let status = 'missing';
if (workerWorks.length > 0) {
if (workerHours >= 6) {
status = 'completed'; // 6시간 이상을 완료로 간주
} else {
status = 'partial'; // 1시간 이상이지만 6시간 미만은 부분입력
}
}
// 최근 업데이트 시간
const lastUpdate = workerWorks.length > 0
? new Date(Math.max(...workerWorks.map(work => new Date(work.created_at))))
: null;
return {
...worker,
status,
totalHours: Math.round(workerHours * 10) / 10, // 소수점 1자리로 반올림
entryCount: workerWorks.length,
workTypes, // 작업 유형 배열 (실제 이름)
projects: workerProjects,
contributors: workerContributors,
lastUpdate,
works: workerWorks
};
});
const summary = {
totalWorkers,
completedWorkers: workerAnalysis.filter(w => w.status === 'completed').length,
missingWorkers: workerAnalysis.filter(w => w.status === 'missing').length,
partialWorkers: workerAnalysis.filter(w => w.status === 'partial').length,
totalHours: Math.round(totalHours * 10) / 10,
totalEntries,
errorCount
};
console.log('대시보드 분석 결과:', { summary, workerAnalysis });
return {
summary,
workers: workerAnalysis,
date: currentDate
};
}
// 대시보드 표시
function displayDashboard(data) {
displaySummary(data.summary);
displayWorkers(data.workers);
// 섹션 표시
document.getElementById('summarySection').style.display = 'block';
document.getElementById('actionBar').style.display = 'flex';
document.getElementById('workersSection').style.display = 'block';
// 필터링 설정
filteredWorkData = data.workers;
setupFiltering();
}
// 요약 섹션 표시
function displaySummary(summary) {
document.getElementById('totalWorkers').textContent = summary.totalWorkers;
document.getElementById('completedWorkers').textContent = summary.completedWorkers;
document.getElementById('missingWorkers').textContent = summary.missingWorkers;
document.getElementById('totalHours').textContent = summary.totalHours + 'h';
document.getElementById('totalEntries').textContent = summary.totalEntries;
document.getElementById('errorCount').textContent = summary.errorCount;
}
// 작업자 목록 표시 (테이블 형태로 개선)
function displayWorkers(workersData) {
const tableBody = document.getElementById('workersTableBody');
tableBody.innerHTML = '';
if (workersData.length === 0) {
tableBody.innerHTML = `
<tr>
<td colspan="9" class="no-data-row">표시할 작업자가 없습니다.</td>
</tr>
`;
return;
}
workersData.forEach(worker => {
const row = createWorkerRow(worker);
tableBody.appendChild(row);
});
}
// 작업자 테이블 행 생성 (개선된 버전)
function createWorkerRow(worker) {
const row = document.createElement('tr');
const statusText = {
completed: '✅ 완료',
missing: '❌ 미입력',
partial: '⚠️ 부분입력'
};
const statusClass = {
completed: 'completed',
missing: 'missing',
partial: 'partial'
};
// 작업 유형 태그 생성 (실제 이름으로)
const workTypeTags = worker.workTypes && worker.workTypes.length > 0
? worker.workTypes.map(type => `<span class="work-type-tag">${type}</span>`).join('')
: '<span class="work-type-tag" style="background: #f8f9fa; color: #6c757d;">없음</span>';
// 프로젝트 태그 생성
const projectTags = worker.projects && worker.projects.length > 0
? worker.projects.map(project => `<span class="project-tag">${project}</span>`).join('')
: '<span class="project-tag" style="background: #f8f9fa; color: #6c757d;">없음</span>';
// 기여자 태그 생성
const contributorTags = worker.contributors && worker.contributors.length > 0
? worker.contributors.map(contributor => `<span class="contributor-tag">${contributor}</span>`).join('')
: '<span class="contributor-tag" style="background: #f8f9fa; color: #6c757d;">없음</span>';
// 시간에 따른 스타일 클래스
let hoursClass = 'zero';
if (worker.totalHours > 0) {
hoursClass = worker.totalHours >= 6 ? 'full' : 'partial';
}
// 업데이트 시간 포맷팅 및 스타일
let updateTimeText = '없음';
let updateClass = '';
if (worker.lastUpdate) {
const now = new Date();
const diff = now - worker.lastUpdate;
const hours = diff / (1000 * 60 * 60);
updateTimeText = formatDateTime(worker.lastUpdate);
updateClass = hours < 1 ? 'recent' : hours > 24 ? 'old' : '';
}
row.innerHTML = `
<td>
<div class="worker-name-cell">
👤 ${worker.worker_name}
</div>
</td>
<td>
<span class="status-badge ${statusClass[worker.status]}">${statusText[worker.status]}</span>
</td>
<td>
<div class="hours-cell ${hoursClass}">${worker.totalHours}h</div>
</td>
<td>
<strong>${worker.entryCount}</strong>개
</td>
<td>
<div class="work-types-container">${workTypeTags}</div>
</td>
<td>
<div class="projects-container">${projectTags}</div>
</td>
<td>
<div class="contributors-container">${contributorTags}</div>
</td>
<td>
<div class="update-time ${updateClass}">${updateTimeText}</div>
</td>
<td>
<button class="detail-btn" onclick="showWorkerDetailSafe('${worker.user_id}')">
📋 상세
</button>
</td>
`;
return row;
}
// 날짜/시간 포맷팅
function formatDateTime(date) {
const d = new Date(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const hours = String(d.getHours()).padStart(2, '0');
const minutes = String(d.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}`;
}
// 작업자 상세 모달 표시 (안전한 버전)
function showWorkerDetailSafe(workerId) {
// 현재 분석된 데이터에서 해당 작업자 찾기
const worker = filteredWorkData.find(w => w.user_id == workerId);
if (!worker) {
showMessage('작업자 정보를 찾을 수 없습니다.', 'error');
return;
}
showWorkerDetail(worker);
}
// 작업자 상세 모달 표시 (개선된 버전)
function showWorkerDetail(worker) {
const modal = document.getElementById('workerDetailModal');
const modalTitle = document.getElementById('modalWorkerName');
const modalBody = document.getElementById('modalWorkerDetails');
modalTitle.textContent = `👤 ${worker.worker_name} 상세 현황`;
let detailHtml = `
<div style="margin-bottom: 20px;">
<h4>📊 기본 정보</h4>
<p><strong>작업자명:</strong> ${worker.worker_name}</p>
<p><strong>총 작업시간:</strong> ${worker.totalHours}시간</p>
<p><strong>작업 항목 수:</strong> ${worker.entryCount}개</p>
<p><strong>상태:</strong> ${worker.status === 'completed' ? '✅ 완료' : worker.status === 'missing' ? '❌ 미입력' : '⚠️ 부분입력'}</p>
<p><strong>작업 유형:</strong> ${worker.workTypes && worker.workTypes.length > 0 ? worker.workTypes.join(', ') : '없음'}</p>
</div>
`;
if (worker.works && worker.works.length > 0) {
detailHtml += `
<div style="margin-bottom: 20px;">
<h4>🔧 작업 내역</h4>
<div style="max-height: 400px; overflow-y: auto;">
`;
worker.works.forEach((work, index) => {
detailHtml += `
<div style="border: 1px solid #e9ecef; padding: 15px; margin-bottom: 10px; border-radius: 8px; background: #f8f9fa; position: relative;">
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 10px;">
<p><strong>작업 ${index + 1}</strong></p>
<div style="display: flex; gap: 8px;">
<button onclick="editWorkItem('${work.id}')" style="background: #007bff; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 12px;">
✏️ 수정
</button>
<button onclick="deleteWorkItem('${work.id}')" style="background: #dc3545; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 12px;">
🗑️ 삭제
</button>
</div>
</div>
<p><strong>프로젝트:</strong> ${work.project_name || '미지정'}</p>
<p><strong>작업 유형:</strong> ${work.work_type_name || '미지정'}</p>
<p><strong>작업 시간:</strong> ${work.work_hours}시간</p>
<p><strong>상태:</strong> ${work.work_status_name || '미지정'}</p>
${work.error_type_name ? `<p><strong>에러 유형:</strong> ${work.error_type_name}</p>` : ''}
<p><strong>입력자:</strong> ${work.created_by_name || '미지정'}</p>
<p><strong>입력 시간:</strong> ${formatDateTime(work.created_at)}</p>
</div>
`;
});
detailHtml += `
</div>
</div>
`;
} else {
detailHtml += `
<div style="margin-bottom: 20px;">
<h4>📭 작업 내역</h4>
<p style="text-align: center; color: #666; padding: 20px;">입력된 작업이 없습니다.</p>
</div>
`;
}
if (worker.contributors && worker.contributors.length > 0) {
detailHtml += `
<div>
<h4>👥 기여자</h4>
<p>${worker.contributors.join(', ')}</p>
</div>
`;
}
modalBody.innerHTML = detailHtml;
modal.style.display = 'flex';
}
// 작업 항목 수정 함수 (통합 API 사용)
async function editWorkItem(workId) {
try {
console.log('수정할 작업 ID:', workId);
// 현재 작업 데이터에서 해당 작업 찾기
let workData = null;
for (const worker of filteredWorkData) {
if (worker.works) {
workData = worker.works.find(work => work.id == workId);
if (workData) break;
}
}
if (!workData) {
showMessage('수정할 작업을 찾을 수 없습니다.', 'error');
return;
}
// 필요한 마스터 데이터 로드
await loadMasterDataForEdit();
// 수정 모달 표시
showEditModal(workData);
} catch (error) {
console.error('작업 정보 조회 오류:', error);
showMessage('작업 정보를 불러올 수 없습니다: ' + error.message, 'error');
}
}
// 수정용 마스터 데이터 로드
async function loadMasterDataForEdit() {
try {
if (!window.projects || window.projects.length === 0) {
const projectData = await apiCall(`${API}/projects`);
window.projects = Array.isArray(projectData) ? projectData : (projectData.projects || []);
}
if (!window.workTypes || window.workTypes.length === 0) {
const workTypeData = await apiCall(`${API}/daily-work-reports/work-types`);
window.workTypes = Array.isArray(workTypeData) ? workTypeData : [];
}
if (!window.workStatusTypes || window.workStatusTypes.length === 0) {
const statusData = await apiCall(`${API}/daily-work-reports/work-status-types`);
window.workStatusTypes = Array.isArray(statusData) ? statusData : [];
}
if (!window.errorTypes || window.errorTypes.length === 0) {
const errorData = await apiCall(`${API}/daily-work-reports/error-types`);
window.errorTypes = Array.isArray(errorData) ? errorData : [];
}
} catch (error) {
console.error('마스터 데이터 로드 오류:', error);
// 기본값 설정
window.projects = window.projects || [];
window.workTypes = window.workTypes || [
{id: 1, name: 'Base'},
{id: 2, name: 'Vessel'},
{id: 3, name: 'Piping'}
];
window.workStatusTypes = window.workStatusTypes || [
{id: 1, name: '정규'},
{id: 2, name: '에러'}
];
window.errorTypes = window.errorTypes || [
{id: 1, name: '설계미스'},
{id: 2, name: '외주작업 불량'},
{id: 3, name: '입고지연'},
{id: 4, name: '작업 불량'}
];
}
}
// 수정 모달 표시
function showEditModal(workData) {
// 기존 상세 모달 닫기
closeWorkerDetailModal();
const modalHtml = `
<div class="edit-modal" id="editModal">
<div class="edit-modal-content">
<div class="edit-modal-header">
<h3>✏️ 작업 수정</h3>
<button class="close-modal-btn" onclick="closeEditModal()">×</button>
</div>
<div class="edit-modal-body">
<div class="edit-form-group">
<label>🏗️ 프로젝트</label>
<select class="edit-select" id="editProject">
<option value="">프로젝트 선택</option>
${(window.projects || []).map(p => `
<option value="${p.project_id}" ${p.project_id == workData.project_id ? 'selected' : ''}>
${p.project_name}
</option>
`).join('')}
</select>
</div>
<div class="edit-form-group">
<label>⚙️ 작업 유형</label>
<select class="edit-select" id="editWorkType">
<option value="">작업 유형 선택</option>
${(window.workTypes || []).map(wt => `
<option value="${wt.id}" ${wt.id == workData.work_type_id ? 'selected' : ''}>
${wt.name}
</option>
`).join('')}
</select>
</div>
<div class="edit-form-group">
<label>📊 업무 상태</label>
<select class="edit-select" id="editWorkStatus">
<option value="">업무 상태 선택</option>
${(window.workStatusTypes || []).map(ws => `
<option value="${ws.id}" ${ws.id == workData.work_status_id ? 'selected' : ''}>
${ws.name}
</option>
`).join('')}
</select>
</div>
<div class="edit-form-group" id="editErrorTypeGroup" style="${workData.work_status_id == 2 ? '' : 'display: none;'}">
<label>❌ 에러 유형</label>
<select class="edit-select" id="editErrorType">
<option value="">에러 유형 선택</option>
${(window.errorTypes || []).map(et => `
<option value="${et.id}" ${et.id == workData.error_type_id ? 'selected' : ''}>
${et.name}
</option>
`).join('')}
</select>
</div>
<div class="edit-form-group">
<label>⏰ 작업 시간</label>
<input type="number" class="edit-input" id="editWorkHours"
value="${workData.work_hours}"
min="0" max="24" step="0.5">
</div>
</div>
<div class="edit-modal-footer">
<button class="btn btn-secondary" onclick="closeEditModal()">취소</button>
<button class="btn btn-success" onclick="saveEditedWork('${workData.id}')">💾 저장</button>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
// 업무 상태 변경 이벤트
document.getElementById('editWorkStatus').addEventListener('change', (e) => {
const errorTypeGroup = document.getElementById('editErrorTypeGroup');
if (e.target.value === '2') {
errorTypeGroup.style.display = 'block';
} else {
errorTypeGroup.style.display = 'none';
}
});
}
// 수정 모달 닫기
function closeEditModal() {
const modal = document.getElementById('editModal');
if (modal) {
modal.remove();
}
}
// 수정된 작업 저장 (통합 API 사용)
async function saveEditedWork(workId) {
try {
const projectId = document.getElementById('editProject').value;
const workTypeId = document.getElementById('editWorkType').value;
const workStatusId = document.getElementById('editWorkStatus').value;
const errorTypeId = document.getElementById('editErrorType').value;
const workHours = document.getElementById('editWorkHours').value;
if (!projectId || !workTypeId || !workStatusId || !workHours) {
showMessage('모든 필수 항목을 입력해주세요.', 'error');
return;
}
if (workStatusId === '2' && !errorTypeId) {
showMessage('에러 상태인 경우 에러 유형을 선택해주세요.', 'error');
return;
}
const updateData = {
project_id: parseInt(projectId),
work_type_id: parseInt(workTypeId),
work_status_id: parseInt(workStatusId),
error_type_id: errorTypeId ? parseInt(errorTypeId) : null,
work_hours: parseFloat(workHours)
};
showMessage('작업을 수정하는 중... (통합 API)', 'loading');
const result = await apiCall(`${API}/daily-work-reports/${workId}`, {
method: 'PUT',
body: JSON.stringify(updateData)
});
showMessage('✅ 작업이 성공적으로 수정되었습니다!', 'success');
closeEditModal();
closeWorkerDetailModal();
// 데이터 새로고침
await loadDashboardData();
} catch (error) {
console.error(' 수정 실패:', error);
showMessage('수정 중 오류가 발생했습니다: ' + error.message, 'error');
}
}
// 작업 항목 삭제 함수 (통합 API 사용)
async function deleteWorkItem(workId) {
if (!confirm('정말로 이 작업을 삭제하시겠습니까?\n삭제된 작업은 복구할 수 없습니다.')) {
return;
}
try {
console.log('삭제할 작업 ID:', workId);
showMessage('작업을 삭제하는 중... (통합 API)', 'loading');
// 개별 항목 삭제 API 호출 - 통합 API 사용
const result = await apiCall(`${API}/daily-work-reports/${workId}`, {
method: 'DELETE'
});
showMessage('✅ 작업이 성공적으로 삭제되었습니다!', 'success');
closeWorkerDetailModal();
// 데이터 새로고침
await loadDashboardData();
} catch (error) {
console.error(' 삭제 실패:', error);
showMessage('삭제 중 오류가 발생했습니다: ' + error.message, 'error');
}
}
// 작업자 상세 모달 닫기
function closeWorkerDetailModal() {
document.getElementById('workerDetailModal').style.display = 'none';
}
// 필터링 설정
function setupFiltering() {
const showOnlyMissingCheckbox = document.getElementById('showOnlyMissing');
showOnlyMissingCheckbox.addEventListener('change', (e) => {
if (e.target.checked) {
// 미입력자만 필터링
const missingWorkers = filteredWorkData.filter(worker => worker.status === 'missing');
displayWorkers(missingWorkers);
} else {
// 전체 표시
displayWorkers(filteredWorkData);
}
});
}
// 엑셀 다운로드 (개선된 버전)
function exportToExcel() {
try {
// CSV 형태로 데이터 구성 (개선된 버전)
let csvContent = "작업자명,상태,총시간,작업항목수,작업유형,프로젝트,기여자,최근업데이트\n";
filteredWorkData.forEach(worker => {
const statusText = {
completed: '완료',
missing: '미입력',
partial: '부분입력'
};
const workTypes = worker.workTypes && worker.workTypes.length > 0 ? worker.workTypes.join('; ') : '없음';
const projects = worker.projects && worker.projects.length > 0 ? worker.projects.join('; ') : '없음';
const contributors = worker.contributors && worker.contributors.length > 0 ? worker.contributors.join('; ') : '없음';
const lastUpdate = worker.lastUpdate ? formatDateTime(worker.lastUpdate) : '없음';
csvContent += `"${worker.worker_name}","${statusText[worker.status]}","${worker.totalHours}","${worker.entryCount}","${workTypes}","${projects}","${contributors}","${lastUpdate}"\n`;
});
// UTF-8 BOM 추가 (한글 깨짐 방지)
const BOM = '\uFEFF';
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `작업현황_${currentDate}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
showMessage('✅ 엑셀 파일이 다운로드되었습니다!', 'success');
} catch (error) {
console.error('엑셀 다운로드 오류:', error);
showMessage('엑셀 다운로드 중 오류가 발생했습니다.', 'error');
}
}
// 새로고침
function refreshData() {
loadDashboardData();
}
// 이벤트 리스너 설정
function setupEventListeners() {
document.getElementById('loadDataBtn').addEventListener('click', loadDashboardData);
document.getElementById('refreshBtn').addEventListener('click', refreshData);
document.getElementById('exportBtn').addEventListener('click', exportToExcel);
// 엔터키로 조회
document.getElementById('selectedDate').addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
loadDashboardData();
}
});
}
// 초기화
async function init() {
try {
// 권한 체크
if (!checkPermission()) {
return;
}
// 권한 체크 메시지 숨기기
document.getElementById('permission-check-message').style.display = 'none';
// 오늘 날짜 설정
document.getElementById('selectedDate').value = getKoreaToday();
// 이벤트 리스너 설정
setupEventListeners();
// 자동으로 오늘 데이터 로드
loadDashboardData();
} catch (error) {
console.error('초기화 오류:', error);
showMessage('초기화 중 오류가 발생했습니다.', 'error');
}
}
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', () => {
// 권한 체크 메시지 표시
document.getElementById('permission-check-message').style.display = 'block';
// 토큰 확인
const token = localStorage.getItem('sso_token');
if (!token || token === 'undefined') {
showMessage('로그인이 필요합니다.', 'error');
localStorage.removeItem('sso_token');
setTimeout(() => {
window.location.href = '/';
}, 2000);
return;
}
// 초기화 실행
init();
});
// 전역 함수로 노출
window.closeWorkerDetailModal = closeWorkerDetailModal;
window.refreshData = refreshData;
window.showWorkerDetailSafe = showWorkerDetailSafe;
window.showWorkerDetail = showWorkerDetail;
window.editWorkItem = editWorkItem;
window.deleteWorkItem = deleteWorkItem;
window.closeEditModal = closeEditModal;
window.saveEditedWork = saveEditedWork;

View File

@@ -1,358 +0,0 @@
/* meeting-detail.js — 회의록 상세/작성 */
let meetingId = null;
let meetingData = null;
let selectedAttendees = []; // [{user_id, name, username}]
let projects = [];
let users = [];
let canEdit = false;
let isAdmin = false;
let isPublished = false;
document.addEventListener('DOMContentLoaded', async () => {
const ok = await initAuth();
if (!ok) return;
document.querySelector('.fade-in').classList.add('visible');
const role = currentUser?.role || '';
canEdit = ['support_team', 'admin', 'system', 'system admin'].includes(role);
isAdmin = ['admin', 'system', 'system admin'].includes(role);
// Parse URL
const params = new URLSearchParams(location.search);
meetingId = params.get('id');
// Load master data
try {
const [projRes, userRes] = await Promise.all([
api('/projects'),
api('/users')
]);
projects = projRes.data || [];
users = (userRes.data || []).filter(u => u.is_active !== 0);
} catch {}
// Populate project select in item modal
const projSel = document.getElementById('itemProject');
projects.forEach(p => {
projSel.innerHTML += `<option value="${p.project_id}">${escapeHtml(p.job_no)} ${escapeHtml(p.project_name)}</option>`;
});
// Populate responsible user select
const respSel = document.getElementById('itemResponsible');
users.forEach(u => {
respSel.innerHTML += `<option value="${u.user_id}">${escapeHtml(u.name)} (${escapeHtml(u.username)})</option>`;
});
// Attendee search
const searchInput = document.getElementById('attendeeSearch');
const resultsDiv = document.getElementById('attendeeResults');
searchInput.addEventListener('input', debounce(() => {
const q = searchInput.value.trim().toLowerCase();
if (q.length < 1) { resultsDiv.classList.add('hidden'); return; }
const matches = users.filter(u =>
!selectedAttendees.some(a => a.user_id === u.user_id) &&
(u.name?.toLowerCase().includes(q) || u.username?.toLowerCase().includes(q))
).slice(0, 10);
if (matches.length === 0) { resultsDiv.classList.add('hidden'); return; }
resultsDiv.innerHTML = matches.map(u =>
`<div class="user-search-item" onclick="addAttendee(${u.user_id}, '${escapeHtml(u.name)}', '${escapeHtml(u.username)}')">${escapeHtml(u.name)} <span class="text-gray-400">(${escapeHtml(u.username)})</span></div>`
).join('');
resultsDiv.classList.remove('hidden');
}, 200));
searchInput.addEventListener('blur', () => setTimeout(() => resultsDiv.classList.add('hidden'), 200));
if (meetingId) {
await loadMeeting();
} else {
// New meeting
document.getElementById('meetingDate').value = new Date().toISOString().split('T')[0];
updateUI();
}
});
async function loadMeeting() {
try {
const res = await api(`/meetings/${meetingId}`);
meetingData = res.data;
isPublished = meetingData.status === 'published';
document.getElementById('pageTitle').textContent = meetingData.title;
document.getElementById('meetingDate').value = formatDate(meetingData.meeting_date);
document.getElementById('meetingTime').value = meetingData.meeting_time || '';
document.getElementById('meetingTitle').value = meetingData.title;
document.getElementById('meetingLocation').value = meetingData.location || '';
document.getElementById('meetingSummary').value = meetingData.summary || '';
// Status badge
const badge = document.getElementById('statusBadge');
badge.classList.remove('hidden');
if (isPublished) {
badge.className = 'badge badge-green';
badge.textContent = '발행';
} else {
badge.className = 'badge badge-gray';
badge.textContent = '초안';
}
// Attendees
selectedAttendees = (meetingData.attendees || []).map(a => ({
user_id: a.user_id, name: a.name, username: a.username
}));
renderAttendees();
// Agenda items
renderAgendaItems(meetingData.items || []);
updateUI();
} catch (err) {
showToast('회의록 로드 실패: ' + err.message, 'error');
}
}
function updateUI() {
const editable = canEdit && (!isPublished || isAdmin);
// Fields
['meetingDate', 'meetingTime', 'meetingTitle', 'meetingLocation', 'meetingSummary', 'attendeeSearch'].forEach(id => {
const el = document.getElementById(id);
if (el) { el.disabled = !editable; if (!editable) el.classList.add('bg-gray-100'); }
});
// Buttons
document.getElementById('btnSave').classList.toggle('hidden', !editable);
document.getElementById('btnAddItem').classList.toggle('hidden', !editable);
document.getElementById('btnPublish').classList.toggle('hidden', !canEdit || isPublished || !meetingId);
document.getElementById('btnUnpublish').classList.toggle('hidden', !isAdmin || !isPublished);
document.getElementById('btnDelete').classList.toggle('hidden', !isAdmin || !meetingId);
}
/* ===== Attendees ===== */
function addAttendee(userId, name, username) {
if (selectedAttendees.some(a => a.user_id === userId)) return;
selectedAttendees.push({ user_id: userId, name, username });
renderAttendees();
document.getElementById('attendeeSearch').value = '';
document.getElementById('attendeeResults').classList.add('hidden');
}
function removeAttendee(userId) {
selectedAttendees = selectedAttendees.filter(a => a.user_id !== userId);
renderAttendees();
}
function renderAttendees() {
const container = document.getElementById('attendeeTags');
const editable = canEdit && (!isPublished || isAdmin);
container.innerHTML = selectedAttendees.map(a =>
`<span class="attendee-tag">${escapeHtml(a.name)}${editable ? ` <span class="remove-btn" onclick="removeAttendee(${a.user_id})">×</span>` : ''}</span>`
).join('');
}
/* ===== Agenda Items ===== */
function renderAgendaItems(items) {
const list = document.getElementById('agendaList');
const empty = document.getElementById('agendaEmpty');
if (items.length === 0) {
list.innerHTML = '';
empty.classList.remove('hidden');
return;
}
empty.classList.add('hidden');
const typeLabels = { schedule_update: '공정현황', issue: '이슈', decision: '결정사항', action_item: '조치사항', other: '기타' };
const typeColors = { schedule_update: 'badge-blue', issue: 'badge-red', decision: 'badge-green', action_item: 'badge-amber', other: 'badge-gray' };
const statusLabels = { open: '미처리', in_progress: '진행중', completed: '완료', cancelled: '취소' };
const statusColors = { open: 'badge-amber', in_progress: 'badge-blue', completed: 'badge-green', cancelled: 'badge-gray' };
const editable = canEdit && (!isPublished || isAdmin);
const canUpdateStatus = ['group_leader', 'support_team', 'admin', 'system', 'system admin'].includes(currentUser?.role || '');
list.innerHTML = items.map(item => `
<div class="border rounded-lg p-4">
<div class="flex items-start justify-between gap-2 mb-2">
<div class="flex items-center gap-2 flex-wrap">
<span class="badge ${typeColors[item.item_type] || 'badge-gray'}">${typeLabels[item.item_type] || item.item_type}</span>
<span class="badge ${statusColors[item.status] || 'badge-gray'}">${statusLabels[item.status] || item.status}</span>
${item.project_code ? `<span class="text-xs text-gray-400">${escapeHtml(item.project_code)}</span>` : ''}
${item.milestone_name ? `<span class="text-xs text-purple-500">◆ ${escapeHtml(item.milestone_name)}</span>` : ''}
</div>
<div class="flex items-center gap-1 flex-shrink-0">
${canUpdateStatus && item.status !== 'completed' ? `<select class="text-xs border rounded px-1 py-0.5" onchange="updateItemStatus(${item.item_id}, this.value)">
<option value="">상태변경</option>
<option value="in_progress">진행중</option>
<option value="completed">완료</option>
</select>` : ''}
${editable ? `<button onclick="openItemModal(${item.item_id})" class="text-gray-400 hover:text-orange-600 text-xs px-1"><i class="fas fa-edit"></i></button>
<button onclick="deleteItem(${item.item_id})" class="text-gray-400 hover:text-red-600 text-xs px-1"><i class="fas fa-trash"></i></button>` : ''}
</div>
</div>
<p class="text-sm text-gray-800 mb-1">${escapeHtml(item.content)}</p>
${item.decision ? `<p class="text-sm text-green-700 bg-green-50 rounded p-2 mb-1"><strong>결정:</strong> ${escapeHtml(item.decision)}</p>` : ''}
${item.action_required ? `<p class="text-sm text-amber-700 bg-amber-50 rounded p-2 mb-1"><strong>조치:</strong> ${escapeHtml(item.action_required)}</p>` : ''}
<div class="flex items-center gap-4 text-xs text-gray-400 mt-2">
${item.responsible_name ? `<span><i class="fas fa-user mr-1"></i>${escapeHtml(item.responsible_name)}</span>` : ''}
${item.due_date ? `<span class="${new Date(item.due_date) < new Date() && item.status !== 'completed' ? 'text-red-500 font-semibold' : ''}"><i class="fas fa-clock mr-1"></i>${formatDate(item.due_date)}</span>` : ''}
${item.milestone_name ? `<a href="/pages/work/schedule.html?highlight=${item.milestone_id}" class="text-purple-500 hover:text-purple-700"><i class="fas fa-calendar-alt mr-1"></i>공정표 보기</a>` : ''}
</div>
</div>
`).join('');
}
/* ===== Save Meeting ===== */
async function saveMeeting() {
const title = document.getElementById('meetingTitle').value.trim();
const meetingDate = document.getElementById('meetingDate').value;
if (!title || !meetingDate) { showToast('날짜와 제목은 필수입니다.', 'error'); return; }
const data = {
meeting_date: meetingDate,
meeting_time: document.getElementById('meetingTime').value || null,
title,
location: document.getElementById('meetingLocation').value || null,
summary: document.getElementById('meetingSummary').value || null,
attendees: selectedAttendees.map(a => a.user_id)
};
try {
if (meetingId) {
await api(`/meetings/${meetingId}`, { method: 'PUT', body: JSON.stringify(data) });
showToast('회의록이 저장되었습니다.');
} else {
const res = await api('/meetings', { method: 'POST', body: JSON.stringify(data) });
meetingId = res.data.meeting_id;
history.replaceState(null, '', `?id=${meetingId}`);
showToast('회의록이 생성되었습니다.');
}
await loadMeeting();
} catch (err) { showToast(err.message, 'error'); }
}
async function publishMeeting() {
if (!confirm('회의록을 발행하시겠습니까? 발행 후 일반 사용자는 수정할 수 없습니다.')) return;
try {
await api(`/meetings/${meetingId}/publish`, { method: 'PUT' });
showToast('회의록이 발행되었습니다.');
await loadMeeting();
} catch (err) { showToast(err.message, 'error'); }
}
async function unpublishMeeting() {
if (!confirm('발행을 취소하시겠습니까?')) return;
try {
await api(`/meetings/${meetingId}/unpublish`, { method: 'PUT' });
showToast('발행이 취소되었습니다.');
await loadMeeting();
} catch (err) { showToast(err.message, 'error'); }
}
async function deleteMeeting() {
if (!confirm('회의록을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.')) return;
try {
await api(`/meetings/${meetingId}`, { method: 'DELETE' });
showToast('회의록이 삭제되었습니다.');
location.href = '/pages/work/meetings.html';
} catch (err) { showToast(err.message, 'error'); }
}
/* ===== Agenda Item Modal ===== */
function openItemModal(editItemId) {
const modal = document.getElementById('itemModal');
const isEdit = !!editItemId;
document.getElementById('itemModalTitle').textContent = isEdit ? '안건 수정' : '안건 추가';
if (isEdit && meetingData) {
const item = meetingData.items.find(i => i.item_id === editItemId);
if (!item) return;
document.getElementById('itemId').value = editItemId;
document.getElementById('itemType').value = item.item_type;
document.getElementById('itemProject').value = item.project_id || '';
loadItemMilestones(item.milestone_id);
document.getElementById('itemContent').value = item.content;
document.getElementById('itemDecision').value = item.decision || '';
document.getElementById('itemAction').value = item.action_required || '';
document.getElementById('itemResponsible').value = item.responsible_user_id || '';
document.getElementById('itemDueDate').value = item.due_date ? formatDate(item.due_date) : '';
document.getElementById('itemStatus').value = item.status;
} else {
document.getElementById('itemId').value = '';
document.getElementById('itemType').value = 'schedule_update';
document.getElementById('itemProject').value = '';
document.getElementById('itemMilestone').innerHTML = '<option value="">선택안함</option>';
document.getElementById('itemContent').value = '';
document.getElementById('itemDecision').value = '';
document.getElementById('itemAction').value = '';
document.getElementById('itemResponsible').value = '';
document.getElementById('itemDueDate').value = '';
document.getElementById('itemStatus').value = 'open';
}
modal.classList.remove('hidden');
}
function closeItemModal() { document.getElementById('itemModal').classList.add('hidden'); }
async function loadItemMilestones(selectedId) {
const projectId = document.getElementById('itemProject').value;
const sel = document.getElementById('itemMilestone');
sel.innerHTML = '<option value="">선택안함</option>';
if (!projectId) return;
try {
const res = await api(`/schedule/milestones?project_id=${projectId}`);
(res.data || []).forEach(m => {
const opt = document.createElement('option');
opt.value = m.milestone_id;
opt.textContent = `${m.milestone_name} (${formatDate(m.milestone_date)})`;
if (selectedId && m.milestone_id === selectedId) opt.selected = true;
sel.appendChild(opt);
});
} catch {}
}
async function saveItem() {
const content = document.getElementById('itemContent').value.trim();
if (!content) { showToast('안건 내용을 입력해주세요.', 'error'); return; }
if (!meetingId) { showToast('회의록을 먼저 저장해주세요.', 'error'); return; }
const itemId = document.getElementById('itemId').value;
const data = {
item_type: document.getElementById('itemType').value,
project_id: document.getElementById('itemProject').value || null,
milestone_id: document.getElementById('itemMilestone').value || null,
content,
decision: document.getElementById('itemDecision').value || null,
action_required: document.getElementById('itemAction').value || null,
responsible_user_id: document.getElementById('itemResponsible').value || null,
due_date: document.getElementById('itemDueDate').value || null,
status: document.getElementById('itemStatus').value
};
try {
if (itemId) {
await api(`/meetings/${meetingId}/items/${itemId}`, { method: 'PUT', body: JSON.stringify(data) });
showToast('안건이 수정되었습니다.');
} else {
await api(`/meetings/${meetingId}/items`, { method: 'POST', body: JSON.stringify(data) });
showToast('안건이 추가되었습니다.');
}
closeItemModal();
await loadMeeting();
} catch (err) { showToast(err.message, 'error'); }
}
async function deleteItem(itemId) {
if (!confirm('안건을 삭제하시겠습니까?')) return;
try {
await api(`/meetings/${meetingId}/items/${itemId}`, { method: 'DELETE' });
showToast('안건이 삭제되었습니다.');
await loadMeeting();
} catch (err) { showToast(err.message, 'error'); }
}
async function updateItemStatus(itemId, status) {
if (!status) return;
try {
await api(`/meetings/items/${itemId}/status`, { method: 'PUT', body: JSON.stringify({ status }) });
showToast('상태가 업데이트되었습니다.');
await loadMeeting();
} catch (err) { showToast(err.message, 'error'); }
}

View File

@@ -1,106 +0,0 @@
/* meetings.js — 생산회의록 목록 */
let canEdit = false;
document.addEventListener('DOMContentLoaded', async () => {
const ok = await initAuth();
if (!ok) return;
document.querySelector('.fade-in').classList.add('visible');
const role = currentUser?.role || '';
canEdit = ['support_team', 'admin', 'system', 'system admin'].includes(role);
if (canEdit) document.getElementById('btnNewMeeting').classList.remove('hidden');
// Year filter
const yearSel = document.getElementById('yearFilter');
const now = new Date();
for (let y = now.getFullYear() - 2; y <= now.getFullYear() + 1; y++) {
const opt = document.createElement('option');
opt.value = y; opt.textContent = y + '년';
if (y === now.getFullYear()) opt.selected = true;
yearSel.appendChild(opt);
}
document.getElementById('monthFilter').value = String(now.getMonth() + 1);
yearSel.addEventListener('change', loadMeetings);
document.getElementById('monthFilter').addEventListener('change', loadMeetings);
document.getElementById('searchInput').addEventListener('input', debounce(loadMeetings, 300));
document.getElementById('btnNewMeeting').addEventListener('click', () => {
location.href = '/pages/work/meeting-detail.html';
});
await Promise.all([loadMeetings(), loadActionItems()]);
});
async function loadMeetings() {
try {
const year = document.getElementById('yearFilter').value;
const month = document.getElementById('monthFilter').value;
const search = document.getElementById('searchInput').value.trim();
let url = `/meetings?year=${year}`;
if (month) url += `&month=${month}`;
if (search) url += `&search=${encodeURIComponent(search)}`;
const res = await api(url);
renderMeetings(res.data || []);
} catch (err) {
showToast('회의록 목록 로드 실패: ' + err.message, 'error');
}
}
function renderMeetings(meetings) {
const list = document.getElementById('meetingList');
const empty = document.getElementById('emptyState');
if (meetings.length === 0) {
list.innerHTML = '';
empty.classList.remove('hidden');
return;
}
empty.classList.add('hidden');
list.innerHTML = meetings.map(m => {
const statusBadge = m.status === 'published'
? '<span class="badge badge-green">발행</span>'
: '<span class="badge badge-gray">초안</span>';
return `
<a href="/pages/work/meeting-detail.html?id=${m.meeting_id}" class="block bg-white rounded-xl shadow-sm p-4 hover:shadow-md transition-shadow">
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="text-sm text-gray-500">${formatDate(m.meeting_date)}</span>
${statusBadge}
</div>
<h3 class="font-semibold text-gray-800 truncate">${escapeHtml(m.title)}</h3>
<div class="flex items-center gap-4 mt-2 text-xs text-gray-500">
<span><i class="fas fa-user mr-1"></i>${escapeHtml(m.created_by_name || '-')}</span>
<span><i class="fas fa-users mr-1"></i>참석 ${m.attendee_count || 0}명</span>
<span><i class="fas fa-list mr-1"></i>안건 ${m.agenda_count || 0}건</span>
${m.open_action_count > 0 ? `<span class="text-amber-600 font-semibold"><i class="fas fa-exclamation-circle mr-1"></i>미완료 ${m.open_action_count}건</span>` : ''}
</div>
</div>
<i class="fas fa-chevron-right text-gray-300 mt-2"></i>
</div>
</a>
`;
}).join('');
}
async function loadActionItems() {
try {
const res = await api('/meetings/action-items?status=open');
const items = res.data || [];
if (items.length === 0) return;
document.getElementById('actionSummary').classList.remove('hidden');
document.getElementById('actionCount').textContent = items.length;
document.getElementById('actionList').innerHTML = items.slice(0, 5).map(item => `
<div class="flex items-center gap-2 p-1.5 bg-white rounded">
<span class="text-amber-600"><i class="fas fa-circle text-[6px]"></i></span>
<span class="flex-1 truncate">${escapeHtml(item.content)}</span>
${item.responsible_name ? `<span class="text-gray-400 text-xs">${escapeHtml(item.responsible_name)}</span>` : ''}
${item.due_date ? `<span class="text-xs ${new Date(item.due_date) < new Date() ? 'text-red-500 font-semibold' : 'text-gray-400'}">${formatDate(item.due_date)}</span>` : ''}
</div>
`).join('') + (items.length > 5 ? `<div class="text-xs text-gray-400 text-center mt-1">외 ${items.length - 5}건</div>` : '');
} catch {}
}

View File

@@ -1,371 +0,0 @@
// mobile-dashboard.js - 모바일 대시보드 v2
// 공장별 카테고리 탭 → 작업장 리스트 → 작업장별 상태 요약
(function() {
'use strict';
if (window.innerWidth > 768) return;
var today = new Date().toISOString().slice(0, 10);
// ==================== 캐시 변수 ====================
var categories = [];
var allWorkplaces = [];
var tbmByWorkplace = {};
var movedByWorkplace = {};
var issuesByWorkplace = {};
var workplacesByCategory = {};
// ==================== 유틸리티 ====================
// escapeHtml, waitForApi → api-base.js 전역 사용
// ==================== 데이터 그룹핑 ====================
function groupTbmByWorkplace(sessions) {
tbmByWorkplace = {};
if (!Array.isArray(sessions)) return;
sessions.forEach(function(s) {
var wpId = s.workplace_id;
if (!wpId) return;
if (!tbmByWorkplace[wpId]) {
tbmByWorkplace[wpId] = { taskCount: 0, totalWorkers: 0, sessions: [] };
}
tbmByWorkplace[wpId].taskCount++;
tbmByWorkplace[wpId].totalWorkers += (parseInt(s.team_member_count) || 0);
tbmByWorkplace[wpId].sessions.push(s);
});
}
function groupMovedByWorkplace(items) {
movedByWorkplace = {};
if (!Array.isArray(items)) return;
items.forEach(function(eq) {
var wpId = eq.current_workplace_id;
if (!wpId) return;
if (!movedByWorkplace[wpId]) {
movedByWorkplace[wpId] = { movedCount: 0, items: [] };
}
movedByWorkplace[wpId].movedCount++;
movedByWorkplace[wpId].items.push(eq);
});
}
function groupIssuesByWorkplace(issues) {
issuesByWorkplace = {};
if (!Array.isArray(issues)) return;
var activeStatuses = ['reported', 'received', 'in_progress'];
issues.forEach(function(issue) {
var wpId = issue.workplace_id;
if (!wpId) return;
if (activeStatuses.indexOf(issue.status) === -1) return;
if (!issuesByWorkplace[wpId]) {
issuesByWorkplace[wpId] = { activeCount: 0, items: [] };
}
issuesByWorkplace[wpId].activeCount++;
issuesByWorkplace[wpId].items.push(issue);
});
}
function groupWorkplacesByCategory(workplaces) {
workplacesByCategory = {};
if (!Array.isArray(workplaces)) return;
workplaces.forEach(function(wp) {
var catId = wp.category_id;
if (!catId) return;
if (!workplacesByCategory[catId]) {
workplacesByCategory[catId] = [];
}
workplacesByCategory[catId].push(wp);
});
}
// ==================== 렌더링 ====================
function renderCategoryTabs() {
var container = document.getElementById('mCategoryTabs');
if (!container || !categories.length) return;
var html = '';
categories.forEach(function(cat, idx) {
html += '<button class="md-cat-tab' + (idx === 0 ? ' active' : '') +
'" data-id="' + cat.category_id + '">' +
escapeHtml(cat.category_name) + '</button>';
});
// 전체 탭
html += '<button class="md-cat-tab" data-id="all">전체</button>';
container.innerHTML = html;
// 이벤트 바인딩
var tabs = container.querySelectorAll('.md-cat-tab');
tabs.forEach(function(tab) {
tab.addEventListener('click', function() {
tabs.forEach(function(t) { t.classList.remove('active'); });
tab.classList.add('active');
var catId = tab.getAttribute('data-id');
selectCategory(catId);
});
});
// 첫 번째 카테고리 자동 선택
if (categories.length > 0) {
selectCategory(String(categories[0].category_id));
}
}
function selectCategory(categoryId) {
var workplaces;
if (categoryId === 'all') {
workplaces = allWorkplaces.filter(function(wp) { return wp.is_active !== false; });
} else {
workplaces = (workplacesByCategory[categoryId] || []).filter(function(wp) {
return wp.is_active !== false;
});
}
renderWorkplaceList(workplaces);
}
function renderWorkplaceList(workplaces) {
var container = document.getElementById('mWorkplaceList');
if (!container) return;
if (!workplaces || workplaces.length === 0) {
container.innerHTML = '<div class="md-wp-empty-all">등록된 작업장이 없습니다.</div>';
return;
}
var html = '';
workplaces.forEach(function(wp) {
var wpId = wp.workplace_id;
var tbm = tbmByWorkplace[wpId];
var moved = movedByWorkplace[wpId];
var issues = issuesByWorkplace[wpId];
var hasAny = tbm || moved || issues;
html += '<div class="md-wp-card" data-wp-id="' + wpId + '">';
// 헤더 (클릭 영역)
html += '<div class="md-wp-header">';
html += '<h3 class="md-wp-name">' + escapeHtml(wp.workplace_name);
if (hasAny) {
html += '<span class="md-wp-toggle">&#9660;</span>';
}
html += '</h3>';
if (!hasAny) {
html += '<p class="md-wp-no-activity">오늘 활동이 없습니다</p>';
} else {
html += '<div class="md-wp-stats">';
// TBM 작업
if (tbm) {
html += '<div class="md-wp-stat-row">' +
'<span class="md-wp-stat-icon">&#128736;</span>' +
'<span class="md-wp-stat-text">작업 ' + tbm.taskCount + '건 &middot; ' + tbm.totalWorkers + '명</span>' +
'</div>';
}
// 신고 (미완료만)
if (issues && issues.activeCount > 0) {
html += '<div class="md-wp-stat-row md-wp-stat--warning">' +
'<span class="md-wp-stat-icon">&#9888;</span>' +
'<span class="md-wp-stat-text">신고 ' + issues.activeCount + '건</span>' +
'</div>';
}
// 이동설비
if (moved && moved.movedCount > 0) {
html += '<div class="md-wp-stat-row">' +
'<span class="md-wp-stat-icon">&#8596;</span>' +
'<span class="md-wp-stat-text">이동설비 ' + moved.movedCount + '건</span>' +
'</div>';
}
html += '</div>';
}
html += '</div>'; // .md-wp-header
// 상세 영역 (활동 있는 카드만)
if (hasAny) {
html += '<div class="md-wp-detail">' + renderCardDetail(wpId) + '</div>';
}
html += '</div>'; // .md-wp-card
});
container.innerHTML = html;
// 클릭 이벤트 바인딩
var cards = container.querySelectorAll('.md-wp-card[data-wp-id]');
cards.forEach(function(card) {
var wpId = card.getAttribute('data-wp-id');
var hasActivity = tbmByWorkplace[wpId] ||
movedByWorkplace[wpId] || issuesByWorkplace[wpId];
if (!hasActivity) return;
card.querySelector('.md-wp-header').addEventListener('click', function() {
toggleCard(wpId);
});
});
}
// ==================== 카드 확장/접기 ====================
function toggleCard(wpId) {
var allCards = document.querySelectorAll('.md-wp-card.expanded');
var targetCard = document.querySelector('.md-wp-card[data-wp-id="' + wpId + '"]');
if (!targetCard) return;
var isExpanded = targetCard.classList.contains('expanded');
// 다른 카드 모두 접기 (아코디언)
allCards.forEach(function(card) {
card.classList.remove('expanded');
});
// 토글
if (!isExpanded) {
targetCard.classList.add('expanded');
}
}
function renderCardDetail(wpId) {
var html = '';
var tbm = tbmByWorkplace[wpId];
var issues = issuesByWorkplace[wpId];
var moved = movedByWorkplace[wpId];
// TBM 작업
if (tbm && tbm.sessions.length > 0) {
html += '<div class="md-wp-detail-section">';
html += '<div class="md-wp-detail-title">&#9654; 작업</div>';
tbm.sessions.forEach(function(s) {
var taskName = s.task_name || '작업명 미지정';
var leaderName = s.leader_name || '미지정';
var memberCount = (parseInt(s.team_member_count) || 0);
html += '<div class="md-wp-detail-item">';
html += '<div class="md-wp-detail-main">' + escapeHtml(taskName) + '</div>';
html += '<div class="md-wp-detail-sub">' + escapeHtml(leaderName) + ' &middot; ' + memberCount + '명</div>';
html += '</div>';
});
html += '</div>';
}
// 신고
if (issues && issues.items.length > 0) {
var statusMap = { reported: '신고', received: '접수', in_progress: '처리중' };
html += '<div class="md-wp-detail-section">';
html += '<div class="md-wp-detail-title">&#9654; 신고</div>';
issues.items.forEach(function(issue) {
var category = issue.issue_category_name || '미분류';
var desc = issue.additional_description || '';
if (desc.length > 30) desc = desc.substring(0, 30) + '...';
var statusText = statusMap[issue.status] || issue.status;
var statusClass = 'md-wp-issue-status--' + (issue.status || 'reported');
var reporter = issue.reporter_name || '';
var icon = issue.status === 'in_progress' ? '&#128308;' : '&#9888;';
html += '<div class="md-wp-detail-item">';
html += '<div class="md-wp-detail-main">' + icon + ' ' + escapeHtml(category);
if (desc) html += ' &middot; ' + escapeHtml(desc);
html += '</div>';
html += '<div class="md-wp-detail-sub"><span class="md-wp-issue-status ' + statusClass + '">' + statusText + '</span>';
if (reporter) html += ' &rarr; ' + escapeHtml(reporter);
html += '</div>';
html += '</div>';
});
html += '</div>';
}
// 이동설비
if (moved && moved.items.length > 0) {
html += '<div class="md-wp-detail-section">';
html += '<div class="md-wp-detail-title">&#9654; 이동설비</div>';
moved.items.forEach(function(eq) {
var eqName = eq.equipment_name || '설비명 미지정';
var fromWp = eq.original_workplace_name || '?';
var toWp = eq.current_workplace_name || '?';
html += '<div class="md-wp-detail-item">';
html += '<div class="md-wp-detail-main">' + escapeHtml(eqName) + '</div>';
html += '<div class="md-wp-detail-sub">' + escapeHtml(fromWp) + ' &rarr; ' + escapeHtml(toWp) + '</div>';
html += '</div>';
});
html += '</div>';
}
return html;
}
// ==================== 초기화 ====================
document.addEventListener('DOMContentLoaded', async function() {
try {
await waitForApi();
} catch (e) {
console.error('mobile-dashboard: apiCall not available');
return;
}
var view = document.getElementById('mobileDashboardView');
if (!view) return;
view.style.display = 'block';
// 날짜 표시
var now = new Date();
var days = ['일', '월', '화', '수', '목', '금', '토'];
var dateEl = document.getElementById('mDateValue');
if (dateEl) {
dateEl.textContent = now.getFullYear() + '.' +
String(now.getMonth() + 1).padStart(2, '0') + '.' +
String(now.getDate()).padStart(2, '0') + ' (' + days[now.getDay()] + ')';
}
// 로딩 표시
var listContainer = document.getElementById('mWorkplaceList');
if (listContainer) {
listContainer.innerHTML =
'<div class="md-skeleton"></div>' +
'<div class="md-skeleton" style="margin-top:8px;"></div>' +
'<div class="md-skeleton" style="margin-top:8px;"></div>';
}
// 데이터 병렬 로딩
var results = await Promise.allSettled([
window.apiCall('/workplaces/categories'),
window.apiCall('/tbm/sessions/date/' + today),
window.apiCall('/equipments/moved/list'),
window.apiCall('/work-issues?start_date=' + today + '&end_date=' + today),
window.apiCall('/workplaces')
]);
// 카테고리
if (results[0].status === 'fulfilled' && results[0].value && results[0].value.success) {
categories = results[0].value.data || [];
}
// TBM
if (results[1].status === 'fulfilled' && results[1].value && results[1].value.success) {
groupTbmByWorkplace(results[1].value.data || []);
}
// 이동설비
if (results[2].status === 'fulfilled' && results[2].value && results[2].value.success) {
groupMovedByWorkplace(results[2].value.data || []);
}
// 신고
if (results[3].status === 'fulfilled' && results[3].value && results[3].value.success) {
groupIssuesByWorkplace(results[3].value.data || []);
}
// 작업장 전체 (카테고리별 그룹핑)
if (results[4].status === 'fulfilled' && results[4].value && results[4].value.success) {
allWorkplaces = results[4].value.data || [];
groupWorkplacesByCategory(allWorkplaces);
}
// 렌더링
renderCategoryTabs();
});
})();

File diff suppressed because it is too large Load Diff

View File

@@ -1,833 +0,0 @@
/**
* monthly-comparison.js — 월간 비교·확인·정산
* Sprint 004 Section B
*/
// ===== Mock =====
const MOCK_ENABLED = false;
const MOCK_MY_RECORDS = {
success: true,
data: {
user: { user_id: 10, worker_name: '김철수', job_type: '용접', department_name: '생산1팀' },
period: { year: 2026, month: 3 },
summary: {
total_work_days: 22, total_work_hours: 182.5,
total_overtime_hours: 6.5, vacation_days: 1,
mismatch_count: 3,
mismatch_details: { hours_diff: 2, missing_report: 1, missing_attendance: 0 }
},
confirmation: { status: 'pending', confirmed_at: null, reject_reason: null },
daily_records: [
{ date: '2026-03-01', day_of_week: '월', is_holiday: false,
work_report: { total_hours: 8.0, entries: [{ project_name: 'A동 신축', work_type: '용접', hours: 8.0 }] },
attendance: { total_work_hours: 8.0, attendance_type: '정시근로', vacation_type: null },
status: 'match', hours_diff: 0 },
{ date: '2026-03-02', day_of_week: '화', is_holiday: false,
work_report: { total_hours: 9.0, entries: [{ project_name: 'A동 신축', work_type: '용접', hours: 9.0 }] },
attendance: { total_work_hours: 8.0, attendance_type: '정시근로', vacation_type: null },
status: 'mismatch', hours_diff: 1.0 },
{ date: '2026-03-03', day_of_week: '수', is_holiday: false,
work_report: null,
attendance: { total_work_hours: 0, attendance_type: '휴가근로', vacation_type: '연차' },
status: 'vacation', hours_diff: 0 },
{ date: '2026-03-04', day_of_week: '목', is_holiday: false,
work_report: { total_hours: 8.0, entries: [{ project_name: 'A동 신축', work_type: '용접', hours: 8.0 }] },
attendance: null,
status: 'report_only', hours_diff: 0 },
{ date: '2026-03-05', day_of_week: '금', is_holiday: false,
work_report: { total_hours: 8.0, entries: [{ project_name: 'B동 보수', work_type: '배관', hours: 8.0 }] },
attendance: { total_work_hours: 8.0, attendance_type: '정시근로', vacation_type: null },
status: 'match', hours_diff: 0 },
{ date: '2026-03-06', day_of_week: '토', is_holiday: true,
work_report: null, attendance: null, status: 'holiday', hours_diff: 0 },
{ date: '2026-03-07', day_of_week: '일', is_holiday: true,
work_report: null, attendance: null, status: 'holiday', hours_diff: 0 },
]
}
};
const MOCK_ADMIN_STATUS = {
success: true,
data: {
period: { year: 2026, month: 3 },
summary: { total_workers: 25, confirmed: 15, pending: 8, rejected: 2 },
workers: [
{ user_id: 10, worker_name: '김철수', job_type: '용접', department_name: '생산1팀',
total_work_days: 22, total_work_hours: 182.5, total_overtime_hours: 6.5,
status: 'confirmed', confirmed_at: '2026-03-30T10:00:00', mismatch_count: 0 },
{ user_id: 11, worker_name: '이영희', job_type: '도장', department_name: '생산1팀',
total_work_days: 20, total_work_hours: 168.0, total_overtime_hours: 2.0,
status: 'pending', confirmed_at: null, mismatch_count: 0 },
{ user_id: 12, worker_name: '박민수', job_type: '배관', department_name: '생산2팀',
total_work_days: 22, total_work_hours: 190.0, total_overtime_hours: 14.0,
status: 'rejected', confirmed_at: null, reject_reason: '3/15 근무시간 오류', mismatch_count: 2 },
]
}
};
// ===== State =====
let currentYear, currentMonth;
let currentMode = 'my'; // 'my' | 'admin' | 'detail'
let currentUserId = null;
let comparisonData = null;
let adminData = null;
let currentFilter = 'all';
const ADMIN_ROLES = ['support_team', 'admin', 'system'];
const DAYS_KR = ['일', '월', '화', '수', '목', '금', '토'];
// ===== Init =====
document.addEventListener('DOMContentLoaded', () => {
const now = new Date();
currentYear = now.getFullYear();
currentMonth = now.getMonth() + 1;
// URL 파라미터
const params = new URLSearchParams(location.search);
if (params.get('year')) currentYear = parseInt(params.get('year'));
if (params.get('month')) currentMonth = parseInt(params.get('month'));
if (params.get('user_id')) currentUserId = parseInt(params.get('user_id'));
const urlMode = params.get('mode');
setTimeout(() => {
const user = typeof getCurrentUser === 'function' ? getCurrentUser() : window.currentUser;
if (!user) return;
// 비관리자 → 작업자 전용 확인 페이지로 리다이렉트
if (!ADMIN_ROLES.includes(user.role)) {
location.href = '/pages/attendance/my-monthly-confirm.html';
return;
}
// 관리자 mode 결정
if (currentUserId) {
currentMode = 'detail';
} else {
currentMode = 'admin';
}
// 관리자 뷰 전환 버튼 (관리자만)
if (ADMIN_ROLES.includes(user.role)) {
document.getElementById('viewToggleBtn').classList.remove('hidden');
}
updateMonthLabel();
loadData();
}, 500);
});
// ===== Month Nav =====
function updateMonthLabel() {
document.getElementById('monthLabel').textContent = `${currentYear}${currentMonth}`;
}
function changeMonth(delta) {
currentMonth += delta;
if (currentMonth > 12) { currentMonth = 1; currentYear++; }
if (currentMonth < 1) { currentMonth = 12; currentYear--; }
updateMonthLabel();
loadData();
}
// ===== Data Load =====
async function loadData() {
if (currentMode === 'admin') {
await loadAdminStatus();
} else {
await loadMyRecords();
}
}
async function loadMyRecords() {
document.getElementById('workerView').classList.remove('hidden');
document.getElementById('adminView').classList.add('hidden');
document.getElementById('pageTitle').textContent = currentMode === 'detail' ? '작업자 근무 비교' : '월간 근무 비교';
const listEl = document.getElementById('dailyList');
listEl.innerHTML = '<div class="ds-skeleton"></div><div class="ds-skeleton"></div><div class="ds-skeleton"></div>';
try {
let res;
if (MOCK_ENABLED) {
res = JSON.parse(JSON.stringify(MOCK_MY_RECORDS));
} else {
const endpoint = currentMode === 'detail' && currentUserId
? `/monthly-comparison/records?year=${currentYear}&month=${currentMonth}&user_id=${currentUserId}`
: `/monthly-comparison/my-records?year=${currentYear}&month=${currentMonth}`;
res = await window.apiCall(endpoint);
}
if (!res || !res.success) {
listEl.innerHTML = '<div class="mc-empty"><p>데이터를 불러올 수 없습니다</p></div>';
return;
}
comparisonData = res.data;
// detail 모드: 작업자 이름 + 검토완료 버튼 (상단 헤더)
if (currentMode === 'detail' && comparisonData.user) {
var isChecked = comparisonData.confirmation && comparisonData.confirmation.admin_checked;
var checkBtnHtml = '<button type="button" id="headerCheckBtn" onclick="toggleAdminCheck()" style="' +
'padding:6px 12px;border-radius:8px;font-size:0.75rem;font-weight:600;border:none;cursor:pointer;margin-left:auto;' +
(isChecked ? 'background:#dcfce7;color:#166534;' : 'background:#f3f4f6;color:#6b7280;') +
'">' + (isChecked ? '✓ 검토완료' : '검토하기') + '</button>';
document.getElementById('pageTitle').innerHTML =
(comparisonData.user.worker_name || '') + ' 근무 비교' + checkBtnHtml;
}
renderSummaryCards(comparisonData.summary);
renderMismatchAlert(comparisonData.summary);
renderDailyList(comparisonData.daily_records || []);
renderConfirmationStatus(comparisonData.confirmation);
} catch (e) {
listEl.innerHTML = '<div class="mc-empty"><i class="fas fa-exclamation-triangle text-2xl text-red-300"></i><p>네트워크 오류</p></div>';
}
}
async function loadAdminStatus() {
document.getElementById('workerView').classList.add('hidden');
document.getElementById('adminView').classList.remove('hidden');
document.getElementById('pageTitle').textContent = '월간 근무 확인 현황';
const listEl = document.getElementById('adminWorkerList');
listEl.innerHTML = '<div class="ds-skeleton"></div><div class="ds-skeleton"></div>';
try {
let res;
if (MOCK_ENABLED) {
res = JSON.parse(JSON.stringify(MOCK_ADMIN_STATUS));
} else {
res = await window.apiCall(`/monthly-comparison/all-status?year=${currentYear}&month=${currentMonth}`);
}
if (!res || !res.success) {
listEl.innerHTML = '<div class="mc-empty"><p>데이터를 불러올 수 없습니다</p></div>';
return;
}
adminData = res.data;
renderAdminSummary(adminData.summary);
renderWorkerList(adminData.workers || []);
updateExportButton(adminData.summary, adminData.workers || []);
} catch (e) {
listEl.innerHTML = '<div class="mc-empty"><i class="fas fa-exclamation-triangle text-2xl text-red-300"></i><p>네트워크 오류</p></div>';
}
}
// ===== Render: Worker View =====
function renderSummaryCards(s) {
document.getElementById('totalDays').textContent = s.total_work_days || 0;
document.getElementById('totalHours').textContent = (s.total_work_hours || 0) + 'h';
document.getElementById('overtimeHours').textContent = (s.total_overtime_hours || 0) + 'h';
document.getElementById('vacationDays').textContent = (s.vacation_days || 0) + '일';
}
function renderMismatchAlert(s) {
const el = document.getElementById('mismatchAlert');
if (!s.mismatch_count || s.mismatch_count === 0) {
el.classList.add('hidden');
return;
}
el.classList.remove('hidden');
const details = s.mismatch_details || {};
const parts = [];
if (details.hours_diff) parts.push(`시간차이 ${details.hours_diff}`);
if (details.missing_report) parts.push(`보고서만 ${details.missing_report}`);
if (details.missing_attendance) parts.push(`근태만 ${details.missing_attendance}`);
document.getElementById('mismatchText').textContent =
`${s.mismatch_count}건의 불일치가 있습니다` + (parts.length ? ` (${parts.join(' | ')})` : '');
}
function renderDailyList(records) {
const el = document.getElementById('dailyList');
if (!records.length) {
el.innerHTML = '<div class="mc-empty"><p>데이터가 없습니다</p></div>';
return;
}
el.innerHTML = records.map(r => {
const dateStr = r.date.substring(5); // "03-01"
const dayStr = r.day_of_week || '';
const icon = getStatusIcon(r.status);
const label = getStatusLabel(r.status, r);
let reportLine = '';
let attendLine = '';
let diffLine = '';
if (r.work_report) {
const entries = (r.work_report.entries || []).map(e => `${e.project_name}-${e.work_type}`).join(', ');
reportLine = `<div class="mc-daily-row">작업보고: <strong>${r.work_report.total_hours}h</strong> <span>(${escHtml(entries)})</span></div>`;
} else if (r.status !== 'holiday') {
reportLine = '<div class="mc-daily-row" style="color:#9ca3af">작업보고: -</div>';
}
if (r.attendance) {
const vacInfo = r.attendance.vacation_type ? ` (${r.attendance.vacation_type})` : '';
// 주말+0h → 편집 불필요
const showEdit = currentMode === 'detail' && !(r.is_holiday && r.attendance.total_work_hours === 0);
const editBtn = showEdit ? `<button class="mc-edit-btn" onclick="editAttendance('${r.date}', ${r.attendance.total_work_hours}, ${r.attendance.vacation_type_id || 'null'})" title="근태 수정"><i class="fas fa-pen"></i></button>` : '';
// 주말+0h → 근태 행 숨김 (주말로 표시)
if (r.is_holiday && r.attendance.total_work_hours === 0) {
// 주말 표시만, 근태 행 생략
} else {
attendLine = `<div class="mc-daily-row mc-attend-row" id="attend-${r.date}">근태관리: <strong>${r.attendance.total_work_hours}h</strong> <span>(${escHtml(r.attendance.attendance_type)}${vacInfo})</span>${editBtn}</div>`;
}
} else if (r.status !== 'holiday') {
const addBtn = currentMode === 'detail' ? `<button class="mc-edit-btn" onclick="editAttendance('${r.date}', 0, null)" title="근태 입력"><i class="fas fa-plus"></i></button>` : '';
attendLine = `<div class="mc-daily-row mc-attend-row" id="attend-${r.date}" style="color:#9ca3af">근태관리: 미입력${addBtn}</div>`;
}
if (r.hours_diff && r.hours_diff !== 0) {
const sign = r.hours_diff > 0 ? '+' : '';
diffLine = `<div class="mc-daily-diff"><i class="fas fa-thumbtack"></i> 차이: ${sign}${r.hours_diff}h</div>`;
}
return `
<div class="mc-daily-card ${r.status}">
<div class="mc-daily-header">
<div class="mc-daily-date">${dateStr}(${dayStr})</div>
<div class="mc-daily-status">${icon} ${label}</div>
</div>
${reportLine}${attendLine}${diffLine}
</div>`;
}).join('');
}
function renderConfirmationStatus(conf) {
const actions = document.getElementById('bottomActions');
const statusEl = document.getElementById('confirmedStatus');
const badge = document.getElementById('statusBadge');
// 관리자 페이지: 확인/문제 버튼 항상 숨김 (작업자는 my-monthly-confirm에서 처리)
actions.classList.add('hidden');
if (!conf) {
statusEl.classList.add('hidden');
badge.textContent = '';
return;
}
var displayStatus = (conf.status === 'pending' && conf.admin_checked) ? 'admin_checked' : conf.status;
var labels = { pending: '미검토', admin_checked: '검토완료', review_sent: '확인요청', confirmed: '확인완료', change_request: '수정요청', rejected: '반려' };
badge.textContent = labels[displayStatus] || '';
badge.className = 'mc-status-badge ' + displayStatus;
if (conf.status === 'confirmed') {
statusEl.classList.remove('hidden');
var dt = conf.confirmed_at ? new Date(conf.confirmed_at).toLocaleString('ko') : '';
document.getElementById('confirmedText').textContent = dt + ' 확인 완료';
} else if (conf.status === 'rejected') {
statusEl.classList.remove('hidden');
document.getElementById('confirmedText').textContent = '반려: ' + (conf.reject_reason || '-');
} else if (conf.status === 'change_request') {
statusEl.classList.remove('hidden');
document.getElementById('confirmedText').innerHTML = '수정요청 접수됨';
// detail 모드: 수정 내역 + 승인/거부 버튼 표시
if (currentMode === 'detail') {
renderChangeRequestPanel(conf);
}
} else {
statusEl.classList.add('hidden');
}
}
// ===== Render: Admin View =====
function renderAdminSummary(s) {
const total = s.total_workers || 1;
const pct = Math.round((s.confirmed || 0) / total * 100);
document.getElementById('progressFill').style.width = pct + '%';
document.getElementById('progressText').textContent = `확인 현황: ${s.confirmed || 0}/${total}명 완료`;
document.getElementById('statusCounts').innerHTML =
`<span>✅ ${s.confirmed || 0} 확인</span>` +
`<span>📩 ${s.review_sent || 0} 확인요청</span>` +
`<span>⏳ ${s.pending || 0} 미검토</span>` +
`<span>📝 ${s.change_request || 0} 수정요청</span>` +
`<span>❌ ${s.rejected || 0} 반려</span>`;
// 확인요청 일괄 발송 버튼 — 전원 검토완료 시만 활성화
var reviewBtn = document.getElementById('reviewSendBtn');
if (reviewBtn) {
var pendingCount = (s.pending || 0);
var uncheckedCount = (adminData?.workers || []).filter(function(w) { return !w.admin_checked && w.status === 'pending'; }).length;
if (pendingCount > 0 && uncheckedCount === 0) {
reviewBtn.classList.remove('hidden');
reviewBtn.disabled = false;
reviewBtn.textContent = `${pendingCount}명 확인요청 발송`;
reviewBtn.style.background = '#2563eb';
} else if (pendingCount > 0 && uncheckedCount > 0) {
reviewBtn.classList.remove('hidden');
reviewBtn.disabled = true;
reviewBtn.textContent = `${uncheckedCount}명 미검토 — 전원 검토 후 발송 가능`;
reviewBtn.style.background = '#9ca3af';
} else {
reviewBtn.classList.add('hidden');
}
}
}
function renderWorkerList(workers) {
const el = document.getElementById('adminWorkerList');
let filtered = workers;
if (currentFilter !== 'all') {
filtered = workers.filter(w => w.status === currentFilter);
}
if (!filtered.length) {
el.innerHTML = '<div class="mc-empty"><p>해당 조건의 작업자가 없습니다</p></div>';
return;
}
el.innerHTML = filtered.map(w => {
// admin_checked면 "미검토" → "검토완료"로 표시
var displayStatus = (w.status === 'pending' && w.admin_checked) ? 'admin_checked' : w.status;
const statusLabels = { confirmed: '확인완료', pending: '미검토', admin_checked: '검토완료', review_sent: '확인요청', change_request: '수정요청', rejected: '반려' };
const statusBadge = `<span class="mc-worker-status-badge ${displayStatus}">${statusLabels[displayStatus] || ''}</span>`;
const mismatchBadge = w.mismatch_count > 0
? `<span class="mc-worker-mismatch">⚠️ 불일치${w.mismatch_count}</span>` : '';
const rejectReason = w.status === 'rejected' && w.reject_reason
? `<div class="mc-worker-reject-reason">사유: ${escHtml(w.reject_reason)}</div>` : '';
const changeSummary = w.status === 'change_request' && w.change_details
? `<div class="mc-worker-change-summary"><i class="fas fa-edit" style="font-size:10px"></i> ${escHtml(formatChangeDetailsSummary(w.change_details))}</div>` : '';
const confirmedAt = w.confirmed_at ? `(${new Date(w.confirmed_at).toLocaleDateString('ko')})` : '';
return `
<div class="mc-worker-card" onclick="viewWorkerDetail(${w.user_id})">
<div style="display:flex;justify-content:space-between;align-items:center">
<div>
<div class="mc-worker-name">${escHtml(w.worker_name)} ${mismatchBadge}</div>
<div class="mc-worker-dept">${escHtml(w.department_name)} · ${escHtml(w.job_type)}</div>
</div>
<i class="fas fa-chevron-right text-gray-300"></i>
</div>
<div class="mc-worker-stats">${w.total_work_days}일 | ${w.total_work_hours}h | 연장 ${w.total_overtime_hours}h</div>
<div class="mc-worker-status">
${statusBadge} <span style="font-size:0.7rem;color:#9ca3af">${confirmedAt}</span>
</div>
${rejectReason}${changeSummary}
</div>`;
}).join('');
}
function filterWorkers(status) {
currentFilter = status;
document.querySelectorAll('.mc-tab').forEach(t => {
t.classList.toggle('active', t.dataset.filter === status);
});
if (adminData) renderWorkerList(adminData.workers || []);
}
function updateExportButton(summary, workers) {
const btn = document.getElementById('exportBtn');
const note = document.getElementById('exportNote');
const pendingCount = (workers || []).filter(w => !w.status || w.status === 'pending').length;
const rejectedCount = (workers || []).filter(w => w.status === 'rejected').length;
const allConfirmed = pendingCount === 0 && rejectedCount === 0;
if (allConfirmed) {
btn.disabled = false;
note.textContent = '모든 작업자가 확인을 완료했습니다';
} else {
btn.disabled = true;
const parts = [];
if (pendingCount > 0) parts.push(`${pendingCount}명 미확인`);
if (rejectedCount > 0) parts.push(`${rejectedCount}명 반려`);
note.textContent = `${parts.join(', ')} — 전원 확인 후 다운로드 가능합니다`;
}
}
// ===== Actions =====
let isProcessing = false;
async function confirmMonth() {
if (isProcessing) return;
if (!confirm(`${currentYear}${currentMonth}월 근무 내역을 확인하시겠습니까?`)) return;
isProcessing = true;
try {
let res;
if (MOCK_ENABLED) {
await new Promise(r => setTimeout(r, 500));
res = { success: true, message: '확인이 완료되었습니다.' };
} else {
res = await window.apiCall('/monthly-comparison/confirm', 'POST', {
year: currentYear, month: currentMonth, status: 'confirmed'
});
}
if (res && res.success) {
showToast(res.message || '확인 완료', 'success');
loadMyRecords();
} else {
showToast(res?.message || '처리 실패', 'error');
}
} catch (e) {
showToast('네트워크 오류', 'error');
} finally {
isProcessing = false;
}
}
function openRejectModal() {
document.getElementById('rejectReason').value = '';
document.getElementById('rejectModal').classList.remove('hidden');
}
function closeRejectModal() {
document.getElementById('rejectModal').classList.add('hidden');
}
async function submitReject() {
if (isProcessing) return;
const reason = document.getElementById('rejectReason').value.trim();
if (!reason) {
showToast('반려 사유를 입력해주세요', 'error');
return;
}
isProcessing = true;
try {
let res;
if (MOCK_ENABLED) {
await new Promise(r => setTimeout(r, 500));
res = { success: true, message: '이의가 접수되었습니다. 지원팀에 알림이 전달됩니다.' };
} else {
res = await window.apiCall('/monthly-comparison/confirm', 'POST', {
year: currentYear, month: currentMonth, status: 'rejected', reject_reason: reason
});
}
if (res && res.success) {
showToast(res.message || '반려 제출 완료', 'success');
closeRejectModal();
loadMyRecords();
} else {
showToast(res?.message || '처리 실패', 'error');
}
} catch (e) {
showToast('네트워크 오류', 'error');
} finally {
isProcessing = false;
}
}
async function downloadExcel() {
try {
if (MOCK_ENABLED) {
showToast('Mock 모드에서는 다운로드를 지원하지 않습니다', 'info');
return;
}
const token = (window.getSSOToken && window.getSSOToken()) || '';
const response = await fetch(`/api/monthly-comparison/export?year=${currentYear}&month=${currentMonth}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.status === 401) {
if (typeof _safeRedirect === 'function') _safeRedirect();
else location.href = '/pages/login.html';
return;
}
if (!response.ok) throw new Error('다운로드 실패');
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `월간근무_${currentYear}${currentMonth}월.xlsx`;
a.click();
window.URL.revokeObjectURL(url);
} catch (e) {
showToast('엑셀 다운로드 실패', 'error');
}
}
// ===== Admin Check (검토완료 토글) =====
async function toggleAdminCheck() {
if (!currentUserId || isProcessing) return;
var isCurrentlyChecked = comparisonData?.confirmation?.admin_checked;
var newChecked = !isCurrentlyChecked;
isProcessing = true;
try {
var res = await window.apiCall('/monthly-comparison/admin-check', 'POST', {
user_id: currentUserId, year: currentYear, month: currentMonth, checked: newChecked
});
if (res && res.success) {
// 상태 업데이트
if (comparisonData.confirmation) {
comparisonData.confirmation.admin_checked = newChecked ? 1 : 0;
}
var btn = document.getElementById('headerCheckBtn');
if (btn) {
btn.textContent = newChecked ? '✓ 검토완료' : '검토하기';
btn.style.background = newChecked ? '#dcfce7' : '#f3f4f6';
btn.style.color = newChecked ? '#166534' : '#6b7280';
}
showToast(newChecked ? '검토완료' : '검토 해제', 'success');
} else {
showToast(res?.message || '처리 실패', 'error');
}
} catch (e) { showToast('네트워크 오류', 'error'); }
finally { isProcessing = false; }
}
// 목록으로 복귀 (월 유지)
function goBackToList() {
location.href = '/pages/attendance/monthly-comparison.html?mode=admin&year=' + currentYear + '&month=' + currentMonth;
}
// ===== Review Send (확인요청 일괄 발송) =====
async function sendReviewAll() {
if (isProcessing) return;
if (!confirm(currentYear + '년 ' + currentMonth + '월 미검토 작업자 전체에게 확인요청을 발송하시겠습니까?')) return;
isProcessing = true;
try {
var res = await window.apiCall('/monthly-comparison/review-send', 'POST', {
year: currentYear, month: currentMonth
});
if (res && res.success) {
showToast(res.message || '확인요청 발송 완료', 'success');
loadAdminStatus();
} else {
showToast(res && res.message || '발송 실패', 'error');
}
} catch (e) {
showToast('네트워크 오류', 'error');
} finally {
isProcessing = false;
}
}
// ===== View Toggle =====
function toggleViewMode() {
if (currentMode === 'admin') {
currentMode = 'my';
} else {
currentMode = 'admin';
}
currentFilter = 'all';
loadData();
}
function viewWorkerDetail(userId) {
location.href = `/pages/attendance/monthly-comparison.html?mode=detail&user_id=${userId}&year=${currentYear}&month=${currentMonth}`;
}
// ===== Helpers =====
function getStatusIcon(status) {
const icons = {
match: '<i class="fas fa-check-circle text-green-500"></i>',
mismatch: '<i class="fas fa-exclamation-triangle text-amber-500"></i>',
report_only: '<i class="fas fa-file-alt text-blue-500"></i>',
attend_only: '<i class="fas fa-clock text-purple-500"></i>',
vacation: '<i class="fas fa-umbrella-beach text-green-400"></i>',
holiday: '<i class="fas fa-calendar text-gray-400"></i>',
none: '<i class="fas fa-minus-circle text-red-400"></i>'
};
return icons[status] || '';
}
function getStatusLabel(status, record) {
const labels = {
match: '일치', mismatch: '불일치', report_only: '보고서만',
attend_only: '근태만', holiday: '주말', none: '미입력'
};
if (status === 'vacation') {
return record?.attendance?.vacation_type || '연차';
}
return labels[status] || '';
}
function escHtml(s) {
return (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// showToast — tkfb-core.js의 전역 showToast 사용 (재정의 불필요)
// ===== Inline Attendance Edit (detail mode) =====
function getAttendanceTypeId(hours, vacTypeId) {
if (vacTypeId) return 4; // VACATION
if (hours >= 8) return 1; // REGULAR
if (hours > 0) return 3; // PARTIAL
return 0;
}
function editAttendance(date, currentHours, currentVacTypeId) {
const el = document.getElementById('attend-' + date);
if (!el) return;
const vacTypeId = currentVacTypeId === 'null' || currentVacTypeId === null ? '' : currentVacTypeId;
el.innerHTML = `
<div class="mc-edit-form">
<div class="mc-edit-row">
<label>시간</label>
<input type="number" id="editHours-${date}" value="${currentHours}" step="0.5" min="0" max="24" class="mc-edit-input">
<span>h</span>
</div>
<div class="mc-edit-row">
<label>휴가</label>
<select id="editVacType-${date}" class="mc-edit-select" onchange="onVacTypeChange('${date}')">
<option value="">없음</option>
<option value="1" ${vacTypeId == 1 ? 'selected' : ''}>연차</option>
<option value="2" ${vacTypeId == 2 ? 'selected' : ''}>반차</option>
<option value="3" ${vacTypeId == 3 ? 'selected' : ''}>반반차</option>
<option value="10" ${vacTypeId == 10 ? 'selected' : ''}>조퇴</option>
</select>
</div>
<div class="mc-edit-actions">
<button class="mc-edit-save" onclick="saveAttendance('${date}')"><i class="fas fa-check"></i> 저장</button>
<button class="mc-edit-cancel" onclick="loadData()">취소</button>
</div>
</div>
`;
}
function onVacTypeChange(date) {
const vacType = document.getElementById('editVacType-' + date).value;
const hoursInput = document.getElementById('editHours-' + date);
if (vacType === '1') hoursInput.value = '0'; // 연차 → 0시간
else if (vacType === '2') hoursInput.value = '4'; // 반차 → 4시간
else if (vacType === '3') hoursInput.value = '6'; // 반반차 → 6시간
else if (vacType === '10') hoursInput.value = '2'; // 조퇴 → 2시간
}
async function saveAttendance(date) {
const hours = parseFloat(document.getElementById('editHours-' + date).value) || 0;
const vacTypeVal = document.getElementById('editVacType-' + date).value;
const vacTypeId = vacTypeVal ? parseInt(vacTypeVal) : null;
const attTypeId = getAttendanceTypeId(hours, vacTypeId);
try {
await window.apiCall('/attendance/records', 'POST', {
record_date: date,
user_id: currentUserId,
total_work_hours: hours,
vacation_type_id: vacTypeId,
attendance_type_id: attTypeId
});
showToast('근태 수정 완료', 'success');
await loadData(); // 전체 새로고침
} catch (e) {
showToast('저장 실패: ' + (e.message || e), 'error');
}
}
// ===== Change Request Panel (detail mode) =====
function renderChangeRequestPanel(conf) {
var panel = document.getElementById('changeRequestPanel');
if (!panel) {
// 동적 생성: mismatchAlert 뒤에 삽입
panel = document.createElement('div');
panel.id = 'changeRequestPanel';
var anchor = document.getElementById('mismatchAlert');
anchor.parentNode.insertBefore(panel, anchor.nextSibling);
}
var details = null;
if (conf.change_details) {
try { details = typeof conf.change_details === 'string' ? JSON.parse(conf.change_details) : conf.change_details; }
catch (e) { details = null; }
}
var html = '<div class="mc-change-panel">';
html += '<div class="mc-change-header"><i class="fas fa-edit text-orange-500"></i> 수정요청 내역</div>';
if (details && details.changes && details.changes.length) {
html += '<div class="mc-change-list">';
details.changes.forEach(function(c) {
var dateLabel = c.date ? c.date.substring(5).replace('-', '/') : '';
html += '<div class="mc-change-item">' + escHtml(dateLabel) + ': ' +
'<span class="mc-change-from">' + escHtml(c.from) + '</span>' +
' <i class="fas fa-arrow-right" style="font-size:10px;color:#9ca3af"></i> ' +
'<span class="mc-change-to">' + escHtml(c.to) + '</span></div>';
});
html += '</div>';
} else if (details && details.description) {
html += '<div class="mc-change-desc">' + escHtml(details.description) + '</div>';
} else {
html += '<div class="mc-change-desc" style="color:#9ca3af">상세 내역 없음</div>';
}
html += '<div class="mc-change-actions">';
html += '<button type="button" class="mc-change-approve" onclick="approveChangeRequest()"><i class="fas fa-check"></i> 승인 (재확인 요청)</button>';
html += '<button type="button" class="mc-change-reject" onclick="openRejectChangeModal()"><i class="fas fa-times"></i> 거부</button>';
html += '</div>';
html += '</div>';
panel.innerHTML = html;
}
async function approveChangeRequest() {
if (!currentUserId || isProcessing) return;
if (!confirm('수정요청을 승인하시겠습니까? 작업자에게 재확인 요청이 발송됩니다.')) return;
isProcessing = true;
try {
var res = await window.apiCall('/monthly-comparison/review-respond', 'POST', {
user_id: currentUserId, year: currentYear, month: currentMonth, action: 'approve'
});
if (res && res.success) {
showToast(res.message || '승인 완료', 'success');
loadData();
} else {
showToast(res?.message || '처리 실패', 'error');
}
} catch (e) { showToast('네트워크 오류', 'error'); }
finally { isProcessing = false; }
}
function openRejectChangeModal() {
document.getElementById('rejectReason').value = '';
// 모달 텍스트를 수정요청 거부용으로 변경
var headerSpan = document.querySelector('#rejectModal .mc-modal-header span');
if (headerSpan) headerSpan.innerHTML = '<i class="fas fa-times-circle text-red-500 mr-2"></i>수정요청 거부';
var desc = document.querySelector('#rejectModal .mc-modal-desc');
if (desc) desc.textContent = '거부 사유를 입력해주세요:';
var submitBtn = document.getElementById('rejectSubmitBtn');
if (submitBtn) {
submitBtn.textContent = '거부 제출';
submitBtn.onclick = submitRejectChange;
}
document.getElementById('rejectModal').classList.remove('hidden');
}
async function submitRejectChange() {
if (!currentUserId || isProcessing) return;
var reason = document.getElementById('rejectReason').value.trim();
if (!reason) { showToast('거부 사유를 입력해주세요', 'error'); return; }
isProcessing = true;
try {
var res = await window.apiCall('/monthly-comparison/review-respond', 'POST', {
user_id: currentUserId, year: currentYear, month: currentMonth,
action: 'reject', reject_reason: reason
});
if (res && res.success) {
showToast(res.message || '거부 완료', 'success');
closeRejectModal();
loadData();
} else {
showToast(res?.message || '처리 실패', 'error');
}
} catch (e) { showToast('네트워크 오류', 'error'); }
finally { isProcessing = false; }
}
function formatChangeDetailsSummary(changeDetails) {
var details = null;
if (!changeDetails) return '';
try { details = typeof changeDetails === 'string' ? JSON.parse(changeDetails) : changeDetails; }
catch (e) { return ''; }
if (details.changes && details.changes.length) {
var items = details.changes.map(function(c) {
var d = c.date ? c.date.substring(5).replace('-', '/') : '';
return d + ' ' + (c.from || '') + '\u2192' + (c.to || '');
});
return items.join(', ');
}
if (details.description) return details.description;
return '';
}
// ESC로 모달 닫기
function handleEscKey(e) {
if (e.key === 'Escape') closeRejectModal();
}
document.addEventListener('keydown', handleEscKey);
window.addEventListener('beforeunload', function() {
document.removeEventListener('keydown', handleEscKey);
});

View File

@@ -1,391 +0,0 @@
/**
* 나의 출근 현황 페이지
* 본인의 출근 기록과 근태 현황을 조회하고 표시합니다
*/
// 전역 상태
let currentYear = new Date().getFullYear();
let currentMonth = new Date().getMonth() + 1;
let attendanceData = [];
let vacationBalance = null;
let monthlyStats = null;
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', () => {
initializePage();
});
/**
* 페이지 초기화
*/
function initializePage() {
initializeYearMonthSelects();
setupEventListeners();
loadAttendanceData();
}
/**
* 년도/월 선택 옵션 초기화
*/
function initializeYearMonthSelects() {
const yearSelect = document.getElementById('yearSelect');
const monthSelect = document.getElementById('monthSelect');
// 년도 옵션 (현재 년도 기준 ±2년)
const currentYearValue = new Date().getFullYear();
for (let year = currentYearValue - 2; year <= currentYearValue + 2; year++) {
const option = document.createElement('option');
option.value = year;
option.textContent = `${year}`;
if (year === currentYear) option.selected = true;
yearSelect.appendChild(option);
}
// 월 옵션
for (let month = 1; month <= 12; month++) {
const option = document.createElement('option');
option.value = month;
option.textContent = `${month}`;
if (month === currentMonth) option.selected = true;
monthSelect.appendChild(option);
}
}
/**
* 이벤트 리스너 설정
*/
function setupEventListeners() {
// 조회 버튼
document.getElementById('loadAttendance').addEventListener('click', () => {
currentYear = parseInt(document.getElementById('yearSelect').value);
currentMonth = parseInt(document.getElementById('monthSelect').value);
loadAttendanceData();
});
// 탭 전환
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const tabName = e.currentTarget.dataset.tab;
switchTab(tabName);
});
});
// 달력 네비게이션
document.getElementById('prevMonth').addEventListener('click', () => {
changeMonth(-1);
});
document.getElementById('nextMonth').addEventListener('click', () => {
changeMonth(1);
});
}
/**
* 출근 데이터 로드
*/
async function loadAttendanceData() {
try {
showLoading();
// 병렬로 데이터 로드
const [attendanceRes, vacationRes, statsRes] = await Promise.all([
window.apiGet(`/users/me/attendance-records?year=${currentYear}&month=${currentMonth}`),
window.apiGet(`/users/me/vacation-balance?year=${currentYear}`),
window.apiGet(`/users/me/monthly-stats?year=${currentYear}&month=${currentMonth}`)
]);
attendanceData = attendanceRes.data || attendanceRes || [];
vacationBalance = vacationRes.data || vacationRes;
monthlyStats = statsRes.data || statsRes;
// UI 업데이트
updateStats();
renderTable();
renderCalendar();
} catch (error) {
console.error('출근 데이터 로드 실패:', error);
showError('출근 데이터를 불러오는데 실패했습니다.');
}
}
/**
* 통계 업데이트
*/
function updateStats() {
// 총 근무시간 (API는 month_hours 반환)
const totalHours = monthlyStats?.month_hours || monthlyStats?.total_work_hours || 0;
document.getElementById('totalHours').textContent = `${totalHours}시간`;
// 근무일수
const totalDays = monthlyStats?.work_days || 0;
document.getElementById('totalDays').textContent = `${totalDays}`;
// 잔여 연차
const remaining = vacationBalance?.remaining_annual_leave ||
(vacationBalance?.total_annual_leave || 0) - (vacationBalance?.used_annual_leave || 0);
document.getElementById('remainingLeave').textContent = `${remaining}`;
}
/**
* 테이블 렌더링
*/
function renderTable() {
const tbody = document.getElementById('attendanceTableBody');
tbody.innerHTML = '';
if (!attendanceData || attendanceData.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="empty-cell">출근 기록이 없습니다.</td></tr>';
return;
}
attendanceData.forEach(record => {
const tr = document.createElement('tr');
tr.className = `attendance-row ${getStatusClass(record.attendance_type_code || record.type_code)}`;
tr.onclick = () => showDetailModal(record);
const date = new Date(record.record_date);
const dayOfWeek = ['일', '월', '화', '수', '목', '금', '토'][date.getDay()];
tr.innerHTML = `
<td>${formatDate(record.record_date)}</td>
<td>${dayOfWeek}</td>
<td>${record.check_in_time || '-'}</td>
<td>${record.check_out_time || '-'}</td>
<td>${record.total_work_hours ? `${record.total_work_hours}h` : '-'}</td>
<td><span class="status-badge ${getStatusClass(record.attendance_type_code || record.type_code)}">${getStatusText(record)}</span></td>
<td class="notes-cell">${record.notes || '-'}</td>
`;
tbody.appendChild(tr);
});
}
/**
* 달력 렌더링
*/
function renderCalendar() {
const calendarTitle = document.getElementById('calendarTitle');
const calendarGrid = document.getElementById('calendarGrid');
calendarTitle.textContent = `${currentYear}${currentMonth}`;
// 달력 그리드 초기화
calendarGrid.innerHTML = '';
// 요일 헤더
const weekdays = ['일', '월', '화', '수', '목', '금', '토'];
weekdays.forEach(day => {
const dayHeader = document.createElement('div');
dayHeader.className = 'calendar-day-header';
dayHeader.textContent = day;
calendarGrid.appendChild(dayHeader);
});
// 해당 월의 첫날과 마지막 날
const firstDay = new Date(currentYear, currentMonth - 1, 1);
const lastDay = new Date(currentYear, currentMonth, 0);
const daysInMonth = lastDay.getDate();
const startDayOfWeek = firstDay.getDay();
// 출근 데이터를 날짜별로 매핑
const attendanceMap = {};
if (attendanceData) {
attendanceData.forEach(record => {
const date = new Date(record.record_date);
const day = date.getDate();
attendanceMap[day] = record;
});
}
// 빈 칸 (이전 달)
for (let i = 0; i < startDayOfWeek; i++) {
const emptyCell = document.createElement('div');
emptyCell.className = 'calendar-day empty';
calendarGrid.appendChild(emptyCell);
}
// 날짜 칸
for (let day = 1; day <= daysInMonth; day++) {
const dayCell = document.createElement('div');
dayCell.className = 'calendar-day';
const record = attendanceMap[day];
if (record) {
dayCell.classList.add('has-record', getStatusClass(record.attendance_type_code || record.type_code));
dayCell.onclick = () => showDetailModal(record);
}
dayCell.innerHTML = `
<div class="calendar-day-number">${day}</div>
${record ? `<div class="calendar-day-status">${getStatusIcon(record)}</div>` : ''}
`;
calendarGrid.appendChild(dayCell);
}
}
/**
* 탭 전환
*/
function switchTab(tabName) {
// 탭 버튼 활성화 토글
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === tabName);
});
// 탭 컨텐츠 토글
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.remove('active');
});
if (tabName === 'list') {
document.getElementById('listView').classList.add('active');
} else if (tabName === 'calendar') {
document.getElementById('calendarView').classList.add('active');
}
}
/**
* 월 변경
*/
function changeMonth(offset) {
currentMonth += offset;
if (currentMonth < 1) {
currentMonth = 12;
currentYear--;
} else if (currentMonth > 12) {
currentMonth = 1;
currentYear++;
}
// Select 박스 업데이트
document.getElementById('yearSelect').value = currentYear;
document.getElementById('monthSelect').value = currentMonth;
loadAttendanceData();
}
/**
* 상세 모달 표시
*/
function showDetailModal(record) {
const modal = document.getElementById('detailModal');
const modalBody = document.getElementById('modalBody');
const modalTitle = document.getElementById('modalTitle');
const date = new Date(record.record_date);
modalTitle.textContent = `${formatDate(record.record_date)} 출근 상세`;
modalBody.innerHTML = `
<div class="detail-grid">
<div class="detail-item">
<label>날짜</label>
<div>${formatDate(record.record_date)}</div>
</div>
<div class="detail-item">
<label>출근 상태</label>
<div><span class="status-badge ${getStatusClass(record.attendance_type_code || record.type_code)}">${getStatusText(record)}</span></div>
</div>
<div class="detail-item">
<label>출근 시간</label>
<div>${record.check_in_time || '기록 없음'}</div>
</div>
<div class="detail-item">
<label>퇴근 시간</label>
<div>${record.check_out_time || '기록 없음'}</div>
</div>
<div class="detail-item">
<label>총 근무 시간</label>
<div>${record.total_work_hours ? `${record.total_work_hours} 시간` : '계산 불가'}</div>
</div>
${record.vacation_type_name ? `
<div class="detail-item">
<label>휴가 유형</label>
<div>${record.vacation_type_name}</div>
</div>
` : ''}
${record.notes ? `
<div class="detail-item full-width">
<label>비고</label>
<div>${record.notes}</div>
</div>
` : ''}
</div>
`;
modal.style.display = 'block';
}
/**
* 모달 닫기
*/
function closeDetailModal() {
document.getElementById('detailModal').style.display = 'none';
}
// 모달 외부 클릭 시 닫기
window.onclick = function(event) {
const modal = document.getElementById('detailModal');
if (event.target === modal) {
closeDetailModal();
}
};
/**
* 유틸리티 함수들
*/
function formatDate(dateString) {
const date = new Date(dateString);
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${month}/${day}`;
}
function getStatusClass(typeCode) {
const typeMap = {
'NORMAL': 'normal',
'LATE': 'late',
'EARLY_LEAVE': 'early',
'ABSENT': 'absent',
'VACATION': 'vacation'
};
return typeMap[typeCode] || 'normal';
}
function getStatusText(record) {
if (record.vacation_type_name) {
return record.vacation_type_name;
}
return record.attendance_type_name || record.type_name || '정상';
}
function getStatusIcon(record) {
const typeCode = record.attendance_type_code || record.type_code;
const iconMap = {
'NORMAL': '✓',
'LATE': '⚠',
'EARLY_LEAVE': '⏰',
'ABSENT': '✗',
'VACATION': '🌴'
};
return iconMap[typeCode] || '✓';
}
function showLoading() {
const tbody = document.getElementById('attendanceTableBody');
tbody.innerHTML = '<tr><td colspan="7" class="loading-cell">데이터를 불러오는 중...</td></tr>';
}
function showError(message) {
const tbody = document.getElementById('attendanceTableBody');
const safeMsg = (window.escapeHtml ? window.escapeHtml(message) : message.replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m])));
tbody.innerHTML = `<tr><td colspan="7" class="error-cell">${safeMsg}</td></tr>`;
// 통계 초기화
document.getElementById('totalHours').textContent = '-';
document.getElementById('totalDays').textContent = '-';
document.getElementById('remainingLeave').textContent = '-';
}

View File

@@ -1,187 +0,0 @@
// My Dashboard - 나의 대시보드 JavaScript
import './api-config.js';
// 전역 변수
let currentYear = new Date().getFullYear();
let currentMonth = new Date().getMonth() + 1;
// 페이지 초기화
document.addEventListener('DOMContentLoaded', async () => {
await loadUserInfo();
await loadVacationBalance();
await loadMonthlyCalendar();
await loadWorkHoursStats();
await loadRecentReports();
});
// 사용자 정보 로드
async function loadUserInfo() {
try {
const response = await apiCall('/users/me', 'GET');
const user = response.data || response;
document.getElementById('userName').textContent = user.name || '사용자';
document.getElementById('department').textContent = user.department || '-';
document.getElementById('jobType').textContent = user.job_type || '-';
document.getElementById('hireDate').textContent = user.hire_date || '-';
} catch (error) {
console.error('사용자 정보 로드 실패:', error);
}
}
// 연차 정보 로드
async function loadVacationBalance() {
try {
const response = await apiCall('/users/me/vacation-balance', 'GET');
const balance = response.data || response;
const total = balance.total_annual_leave || 15;
const used = balance.used_annual_leave || 0;
const remaining = total - used;
document.getElementById('totalLeave').textContent = total;
document.getElementById('usedLeave').textContent = used;
document.getElementById('remainingLeave').textContent = remaining;
// 프로그레스 바 업데이트
const percentage = (used / total) * 100;
document.getElementById('vacationProgress').style.width = `${percentage}%`;
} catch (error) {
console.error('연차 정보 로드 실패:', error);
}
}
// 월별 캘린더 로드
async function loadMonthlyCalendar() {
try {
const response = await apiCall(
`/users/me/attendance-records?year=${currentYear}&month=${currentMonth}`,
'GET'
);
const records = response.data || response;
renderCalendar(currentYear, currentMonth, records);
document.getElementById('currentMonth').textContent = `${currentYear}${currentMonth}`;
} catch (error) {
console.error('캘린더 로드 실패:', error);
renderCalendar(currentYear, currentMonth, []);
}
}
// 캘린더 렌더링
function renderCalendar(year, month, records) {
const calendar = document.getElementById('calendar');
const firstDay = new Date(year, month - 1, 1).getDay();
const daysInMonth = new Date(year, month, 0).getDate();
let html = '';
// 요일 헤더
const weekdays = ['일', '월', '화', '수', '목', '금', '토'];
weekdays.forEach(day => {
html += `<div class="calendar-header">${day}</div>`;
});
// 빈 칸
for (let i = 0; i < firstDay; i++) {
html += '<div class="calendar-day empty"></div>';
}
// 날짜
for (let day = 1; day <= daysInMonth; day++) {
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const record = Array.isArray(records) ? records.find(r => r.record_date === dateStr) : null;
let statusClass = '';
if (record) {
const typeCode = record.attendance_type_code || record.type_code || '';
statusClass = typeCode.toLowerCase();
}
html += `
<div class="calendar-day ${statusClass}" title="${dateStr}">
<span class="day-number">${day}</span>
</div>
`;
}
calendar.innerHTML = html;
}
// 근무 시간 통계 로드
async function loadWorkHoursStats() {
try {
const response = await apiCall(
`/users/me/monthly-stats?year=${currentYear}&month=${currentMonth}`,
'GET'
);
const stats = response.data || response;
document.getElementById('monthHours').textContent = stats.month_hours || 0;
document.getElementById('workDays').textContent = stats.work_days || 0;
} catch (error) {
console.error('근무 시간 통계 로드 실패:', error);
}
}
// 최근 작업 보고서 로드
async function loadRecentReports() {
try {
const endDate = new Date().toISOString().split('T')[0];
const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
.toISOString().split('T')[0];
const response = await apiCall(
`/users/me/work-reports?startDate=${startDate}&endDate=${endDate}`,
'GET'
);
const reports = response.data || response;
const list = document.getElementById('recentReportsList');
if (!Array.isArray(reports) || reports.length === 0) {
list.innerHTML = '<p class="empty-message">최근 7일간의 작업 보고서가 없습니다.</p>';
return;
}
list.innerHTML = reports.map(r => `
<div class="report-item">
<span class="date">${r.report_date}</span>
<span class="project">${r.project_name || 'N/A'}</span>
<span class="hours">${r.work_hours}시간</span>
</div>
`).join('');
} catch (error) {
console.error('최근 작업 보고서 로드 실패:', error);
}
}
// 이전 달
function previousMonth() {
currentMonth--;
if (currentMonth < 1) {
currentMonth = 12;
currentYear--;
}
loadMonthlyCalendar();
loadWorkHoursStats();
}
// 다음 달
function nextMonth() {
currentMonth++;
if (currentMonth > 12) {
currentMonth = 1;
currentYear++;
}
loadMonthlyCalendar();
loadWorkHoursStats();
}
// 전역 함수 노출
window.previousMonth = previousMonth;
window.nextMonth = nextMonth;
window.loadMonthlyCalendar = loadMonthlyCalendar;

View File

@@ -1,400 +0,0 @@
/**
* my-monthly-confirm.js — 작업자 월간 근무 확인 (모바일 캘린더)
*/
var DAYS_KR = ['일', '월', '화', '수', '목', '금', '토'];
var currentYear, currentMonth;
var isProcessing = false;
var selectedCell = null;
var currentConfStatus = null; // 현재 confirmation 상태
var pendingChanges = {}; // 수정 내역 { 'YYYY-MM-DD': { from: '반차', to: '정시', hours: 8 } }
var loadedRecords = []; // 로드된 daily_records
// ===== Init =====
document.addEventListener('DOMContentLoaded', function() {
var now = new Date();
currentYear = now.getFullYear();
currentMonth = now.getMonth() + 1;
var params = new URLSearchParams(location.search);
if (params.get('year')) currentYear = parseInt(params.get('year'));
if (params.get('month')) currentMonth = parseInt(params.get('month'));
setTimeout(function() {
var user = typeof getCurrentUser === 'function' ? getCurrentUser() : window.currentUser;
if (!user) return;
window._mmcUser = user;
updateMonthLabel();
loadData();
}, 500);
});
function updateMonthLabel() {
document.getElementById('monthLabel').textContent = currentYear + '년 ' + currentMonth + '월';
}
function changeMonth(delta) {
currentMonth += delta;
if (currentMonth > 12) { currentMonth = 1; currentYear++; }
if (currentMonth < 1) { currentMonth = 12; currentYear--; }
selectedCell = null;
updateMonthLabel();
loadData();
}
// ===== Data Load =====
async function loadData() {
var calWrap = document.getElementById('tableWrap');
calWrap.innerHTML = '<div class="mmc-skeleton"></div><div class="mmc-skeleton"></div>';
try {
var user = window._mmcUser || (typeof getCurrentUser === 'function' ? getCurrentUser() : null) || {};
var userId = user.user_id || user.id;
var [recordsRes, balanceRes] = await Promise.all([
window.apiCall('/monthly-comparison/my-records?year=' + currentYear + '&month=' + currentMonth),
window.apiCall('/vacation-balances/worker/' + userId + '/year/' + currentYear).catch(function() { return { success: true, data: [] }; })
]);
if (!recordsRes || !recordsRes.success) {
calWrap.innerHTML = '<div class="mmc-empty"><i class="fas fa-calendar-xmark text-2xl text-gray-300"></i><p>데이터가 없습니다</p></div>';
document.getElementById('bottomActions').classList.add('hidden');
return;
}
var data = recordsRes.data;
renderUserInfo(data.user);
renderCalendar(data.daily_records || []);
renderSummaryCards(data.daily_records || []);
loadedRecords = data.daily_records || [];
currentConfStatus = data.confirmation ? data.confirmation.status : 'pending';
pendingChanges = {};
renderVacationBalance(balanceRes.data || []);
renderConfirmStatus(data.confirmation);
} catch (e) {
calWrap.innerHTML = '<div class="mmc-empty"><i class="fas fa-exclamation-triangle text-2xl text-red-300"></i><p>네트워크 오류</p></div>';
}
}
// ===== Render =====
function renderUserInfo(user) {
if (!user) return;
document.getElementById('userName').textContent = user.worker_name || user.name || '-';
document.getElementById('userDept').textContent =
(user.job_type ? user.job_type + ' · ' : '') + (user.department_name || '');
}
// 셀 텍스트 판정
// 8h 기준 고정 (scheduled_hours 미존재 — 단축근무 미대응)
function getCellInfo(r) {
var hrs = r.attendance ? parseFloat(r.attendance.total_work_hours) || 0 : 0;
var vacType = r.attendance ? r.attendance.vacation_type : null;
var isHoliday = r.is_holiday;
if (vacType) return { text: vacType, cls: 'vac', detail: vacType };
if (isHoliday && hrs <= 0) return { text: '휴무', cls: 'off', detail: r.holiday_name || '휴무' };
if (isHoliday && hrs > 0) return { text: '특 ' + hrs + 'h', cls: 'special', detail: '특근 ' + hrs + '시간' };
if (hrs === 8) return { text: '정시', cls: 'normal', detail: '정시근로 8시간' };
if (hrs > 8) return { text: '+' + (hrs - 8) + 'h', cls: 'overtime', detail: '연장근로 ' + hrs + '시간 (+' + (hrs - 8) + ')' };
if (hrs > 0) return { text: hrs + 'h', cls: 'partial', detail: hrs + '시간 근무' };
return { text: '-', cls: 'none', detail: '미입력' };
}
function renderCalendar(records) {
var el = document.getElementById('tableWrap');
if (!records.length) {
el.innerHTML = '<div class="mmc-empty"><p>해당 월 데이터가 없습니다</p></div>';
return;
}
// 날짜별 맵
var recMap = {};
records.forEach(function(r) { recMap[parseInt(r.date.substring(8))] = r; });
var firstDay = new Date(currentYear, currentMonth - 1, 1).getDay();
var daysInMonth = new Date(currentYear, currentMonth, 0).getDate();
// 헤더
var html = '<div class="cal-grid">';
html += '<div class="cal-header">';
DAYS_KR.forEach(function(d, i) {
var cls = i === 0 ? ' sun' : i === 6 ? ' sat' : '';
html += '<div class="cal-dow' + cls + '">' + d + '</div>';
});
html += '</div>';
// 셀
html += '<div class="cal-body">';
// 빈 셀 (월 시작 전)
for (var i = 0; i < firstDay; i++) {
html += '<div class="cal-cell empty"></div>';
}
for (var day = 1; day <= daysInMonth; day++) {
var r = recMap[day];
var info = r ? getCellInfo(r) : { text: '-', cls: 'none', detail: '데이터 없음' };
var dow = (firstDay + day - 1) % 7;
var dowCls = dow === 0 ? ' sun' : dow === 6 ? ' sat' : '';
html += '<div class="cal-cell ' + info.cls + dowCls + '" onclick="selectDay(' + day + ')">';
html += '<span class="cal-day">' + day + '</span>';
html += '<span class="cal-val">' + escHtml(info.text) + '</span>';
html += '</div>';
}
html += '</div></div>';
// 상세 영역
html += '<div class="cal-detail" id="calDetail"></div>';
el.innerHTML = html;
}
function selectDay(day) {
selectedCell = day;
var el = document.getElementById('calDetail');
var cells = document.querySelectorAll('.cal-cell');
cells.forEach(function(c) { c.classList.remove('selected'); });
var allCells = document.querySelectorAll('.cal-cell:not(.empty)');
if (allCells[day - 1]) allCells[day - 1].classList.add('selected');
var dateStr = currentYear + '-' + String(currentMonth).padStart(2, '0') + '-' + String(day).padStart(2, '0');
var d = new Date(currentYear, currentMonth - 1, day);
var dow = DAYS_KR[d.getDay()];
var record = loadedRecords.find(function(r) { return parseInt(r.date.substring(8)) === day; });
var currentVal = record ? getCellInfo(record).text : '-';
var html = '<div class="cal-detail-inner">';
html += '<strong>' + currentMonth + '/' + day + ' (' + dow + ')</strong> — ' + escHtml(currentVal);
// review_sent 상태에서만 수정 드롭다운 표시
if (currentConfStatus === 'review_sent') {
var changed = pendingChanges[dateStr];
html += '<div class="cal-edit-row">';
html += '<select id="editType-' + day + '" onchange="onCellChange(' + day + ')" class="cal-edit-select">';
html += '<option value="">변경 없음</option>';
html += '<option value="정시"' + (changed && changed.to === '정시' ? ' selected' : '') + '>정시 (8h)</option>';
html += '<option value="연차"' + (changed && changed.to === '연차' ? ' selected' : '') + '>연차 (0h)</option>';
html += '<option value="반차"' + (changed && changed.to === '반차' ? ' selected' : '') + '>반차 (4h)</option>';
html += '<option value="반반차"' + (changed && changed.to === '반반차' ? ' selected' : '') + '>반반차 (6h)</option>';
html += '<option value="조퇴"' + (changed && changed.to === '조퇴' ? ' selected' : '') + '>조퇴 (2h)</option>';
html += '<option value="휴무"' + (changed && changed.to === '휴무' ? ' selected' : '') + '>휴무 (0h)</option>';
html += '</select>';
if (changed) html += ' <span class="cal-changed-badge">수정</span>';
html += '</div>';
}
html += '</div>';
el.innerHTML = html;
el.style.display = 'block';
updateChangeRequestBtn();
}
function onCellChange(day) {
var dateStr = currentYear + '-' + String(currentMonth).padStart(2, '0') + '-' + String(day).padStart(2, '0');
var sel = document.getElementById('editType-' + day);
var newType = sel ? sel.value : '';
var record = loadedRecords.find(function(r) { return parseInt(r.date.substring(8)) === day; });
var currentType = record ? getCellInfo(record).text : '-';
if (newType && newType !== currentType) {
var hoursMap = { '정시': 8, '연차': 0, '반차': 4, '반반차': 6, '조퇴': 2, '휴무': 0 };
pendingChanges[dateStr] = { from: currentType, to: newType, hours: hoursMap[newType] || 0 };
// 셀에 수정 뱃지
var allCells = document.querySelectorAll('.cal-cell:not(.empty)');
if (allCells[day - 1]) allCells[day - 1].classList.add('changed');
} else {
delete pendingChanges[dateStr];
var allCells2 = document.querySelectorAll('.cal-cell:not(.empty)');
if (allCells2[day - 1]) allCells2[day - 1].classList.remove('changed');
}
updateChangeRequestBtn();
// 상세 영역 재렌더
selectDay(day);
}
function updateChangeRequestBtn() {
var rejectBtn = document.getElementById('rejectBtn');
if (!rejectBtn) return;
var changeCount = Object.keys(pendingChanges).length;
if (currentConfStatus === 'review_sent' && changeCount > 0) {
rejectBtn.disabled = false;
rejectBtn.innerHTML = '<i class="fas fa-edit mr-2"></i>수정요청 (' + changeCount + '건)';
} else if (currentConfStatus === 'review_sent') {
rejectBtn.disabled = true;
rejectBtn.innerHTML = '<i class="fas fa-edit mr-2"></i>수정요청';
}
}
function renderSummaryCards(records) {
var workDays = 0, overtimeHours = 0, vacDays = 0;
records.forEach(function(r) {
var hrs = r.attendance ? parseFloat(r.attendance.total_work_hours) || 0 : 0;
var vacType = r.attendance ? r.attendance.vacation_type : null;
var isHoliday = r.is_holiday;
if (!isHoliday && (hrs > 0 || vacType)) workDays++;
if (hrs > 8) overtimeHours += (hrs - 8);
if (vacType) {
var vd = r.attendance.vacation_days ? parseFloat(r.attendance.vacation_days) : 0;
if (vd > 0) { vacDays += vd; }
else {
// fallback: vacation_type 이름으로 차감일수 매핑
var deductMap = { '연차': 1, '반차': 0.5, '반반차': 0.25, '조퇴': 0.75, '병가': 1 };
vacDays += deductMap[vacType] || 1;
}
}
});
var el = document.getElementById('summaryCards');
if (!el) return;
el.innerHTML =
'<div class="mmc-sum-card"><div class="mmc-sum-num">' + workDays + '</div><div class="mmc-sum-label">근무일</div></div>' +
'<div class="mmc-sum-card"><div class="mmc-sum-num ot">' + fmtNum(overtimeHours) + 'h</div><div class="mmc-sum-label">연장근로</div></div>' +
'<div class="mmc-sum-card"><div class="mmc-sum-num vac">' + fmtNum(vacDays) + '일</div><div class="mmc-sum-label">연차</div></div>';
}
function renderVacationBalance(balances) {
var el = document.getElementById('vacationCards');
var total = 0, used = 0;
if (Array.isArray(balances)) {
balances.forEach(function(b) {
total += parseFloat(b.total_days || 0);
used += parseFloat(b.used_days || 0);
});
}
var remaining = total - used;
el.innerHTML =
'<div class="mmc-vac-title">연차 현황</div>' +
'<div class="mmc-vac-grid">' +
'<div class="mmc-vac-card"><div class="mmc-vac-num">' + fmtNum(total) + '</div><div class="mmc-vac-label">부여</div></div>' +
'<div class="mmc-vac-card"><div class="mmc-vac-num used">' + fmtNum(used) + '</div><div class="mmc-vac-label">사용</div></div>' +
'<div class="mmc-vac-card"><div class="mmc-vac-num remain">' + fmtNum(remaining) + '</div><div class="mmc-vac-label">잔여</div></div>' +
'</div>';
}
function renderConfirmStatus(conf) {
var actions = document.getElementById('bottomActions');
var statusEl = document.getElementById('confirmedStatus');
var badge = document.getElementById('statusBadge');
var confirmBtn = document.getElementById('confirmBtn');
var rejectBtn = document.getElementById('rejectBtn');
var status = conf ? conf.status : 'pending';
// 기본: 버튼 숨김 + 상태 숨김
actions.classList.add('hidden');
statusEl.classList.add('hidden');
if (status === 'pending') {
badge.textContent = '검토대기';
badge.className = 'mmc-status-badge pending';
statusEl.classList.remove('hidden');
document.getElementById('confirmedText').textContent = '관리자 검토 대기 중입니다';
} else if (status === 'review_sent') {
badge.textContent = '확인요청';
badge.className = 'mmc-status-badge review_sent';
actions.classList.remove('hidden');
confirmBtn.innerHTML = '<i class="fas fa-check-circle mr-2"></i>확인 완료';
rejectBtn.innerHTML = '<i class="fas fa-edit mr-2"></i>수정요청';
rejectBtn.disabled = true; // 수정 내역 없으면 비활성화
rejectBtn.onclick = function() { submitChangeRequest(); };
} else if (status === 'confirmed') {
badge.textContent = '확인완료';
badge.className = 'mmc-status-badge confirmed';
statusEl.classList.remove('hidden');
var dt = conf.confirmed_at ? new Date(conf.confirmed_at).toLocaleDateString('ko') : '';
document.getElementById('confirmedText').textContent = dt + ' 확인 완료';
} else if (status === 'change_request') {
badge.textContent = '수정요청';
badge.className = 'mmc-status-badge change_request';
statusEl.classList.remove('hidden');
document.getElementById('confirmedText').textContent = '수정요청이 제출되었습니다. 관리자 확인 대기 중';
} else if (status === 'rejected') {
badge.textContent = '반려';
badge.className = 'mmc-status-badge rejected';
actions.classList.remove('hidden');
confirmBtn.innerHTML = '<i class="fas fa-check-circle mr-2"></i>동의(재확인)';
rejectBtn.classList.add('hidden');
statusEl.classList.remove('hidden');
document.getElementById('confirmedText').textContent = '반려 사유: ' + (conf.reject_reason || '-') + '\n반려 사유를 확인하고 동의하시면 확인 완료 버튼을 눌러주세요.';
}
}
function openChangeRequestModal() {
document.getElementById('rejectReason').value = '';
document.getElementById('rejectModal').classList.remove('hidden');
// 모달 제목/버튼 수정요청용으로 변경
var header = document.querySelector('.mmc-modal-header span');
if (header) header.innerHTML = '<i class="fas fa-edit text-blue-500 mr-2"></i>수정요청';
var submitBtn = document.querySelector('.mmc-modal-submit');
if (submitBtn) submitBtn.textContent = '수정요청 제출';
var desc = document.querySelector('.mmc-modal-desc');
if (desc) desc.textContent = '수정이 필요한 내용을 입력해주세요:';
var note = document.querySelector('.mmc-modal-note');
if (note) note.innerHTML = '<i class="fas fa-info-circle text-blue-400 mr-1"></i>수정요청 시 관리자에게 알림이 전달됩니다.';
}
// ===== Actions =====
async function confirmMonth() {
if (isProcessing) return;
if (!confirm(currentYear + '년 ' + currentMonth + '월 근무 내역을 확인하시겠습니까?')) return;
isProcessing = true;
try {
var res = await window.apiCall('/monthly-comparison/confirm', 'POST', {
year: currentYear, month: currentMonth, status: 'confirmed'
});
if (res && res.success) { showToast(res.message || '확인 완료', 'success'); loadData(); }
else { showToast(res && res.message || '처리 실패', 'error'); }
} catch (e) { showToast('네트워크 오류', 'error'); }
finally { isProcessing = false; }
}
async function submitChangeRequest() {
if (isProcessing) return;
var changeCount = Object.keys(pendingChanges).length;
if (changeCount === 0) { showToast('수정 내역이 없습니다', 'error'); return; }
if (!confirm(changeCount + '건의 수정요청을 제출하시겠습니까?')) return;
isProcessing = true;
try {
var changes = Object.keys(pendingChanges).map(function(date) {
return { date: date, from: pendingChanges[date].from, to: pendingChanges[date].to };
});
var res = await window.apiCall('/monthly-comparison/confirm', 'POST', {
year: currentYear, month: currentMonth, status: 'change_request',
change_details: { changes: changes }
});
if (res && res.success) { showToast(res.message || '수정요청 완료', 'success'); loadData(); }
else { showToast(res && res.message || '처리 실패', 'error'); }
} catch (e) { showToast('네트워크 오류', 'error'); }
finally { isProcessing = false; }
}
function openRejectModal() {
document.getElementById('rejectReason').value = '';
document.getElementById('rejectModal').classList.remove('hidden');
}
function closeRejectModal() { document.getElementById('rejectModal').classList.add('hidden'); }
async function submitReject() {
if (isProcessing) return;
var reason = document.getElementById('rejectReason').value.trim();
if (!reason) { showToast('수정 내용을 입력해주세요', 'error'); return; }
isProcessing = true;
try {
var res = await window.apiCall('/monthly-comparison/confirm', 'POST', {
year: currentYear, month: currentMonth, status: 'change_request',
change_details: { description: reason }
});
if (res && res.success) { showToast(res.message || '수정요청 완료', 'success'); closeRejectModal(); loadData(); }
else { showToast(res && res.message || '처리 실패', 'error'); }
} catch (e) { showToast('네트워크 오류', 'error'); }
finally { isProcessing = false; }
}
// ===== Helpers =====
function fmtNum(v) { var n = parseFloat(v) || 0; return n % 1 === 0 ? n.toString() : n.toFixed(1); }
function escHtml(s) { return (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
// showToast — tkfb-core.js의 전역 showToast 사용 (재정의 불필요)
function handleEscKey(e) { if (e.key === 'Escape') closeRejectModal(); }
document.addEventListener('keydown', handleEscKey);
window.addEventListener('beforeunload', function() { document.removeEventListener('keydown', handleEscKey); });

View File

@@ -1,121 +0,0 @@
// js/my-profile.js
// 내 프로필 페이지 JavaScript
import { API, getAuthHeaders, ensureAuthenticated } from '/js/api-config.js';
// 인증 확인
const token = ensureAuthenticated();
// 권한 레벨 한글 매핑
const accessLevelMap = {
worker: '작업자',
group_leader: '그룹장',
support_team: '지원팀',
admin: '관리자',
system: '시스템 관리자'
};
// 프로필 데이터 로드
async function loadProfile() {
try {
// 먼저 로컬 스토리지에서 기본 정보 표시
const storedUser = JSON.parse(localStorage.getItem('sso_user') || '{}');
if (storedUser) {
updateProfileUI(storedUser);
}
// API에서 최신 정보 가져오기
const res = await fetch(`${API}/auth/me`, {
headers: getAuthHeaders()
});
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`);
}
const userData = await res.json();
// 로컬 스토리지 업데이트
const updatedUser = {
...storedUser,
...userData
};
localStorage.setItem('sso_user', JSON.stringify(updatedUser));
// UI 업데이트
updateProfileUI(userData);
} catch (error) {
console.error('프로필 로딩 실패:', error);
showError('프로필 정보를 불러오는데 실패했습니다.');
}
}
// 프로필 UI 업데이트
function updateProfileUI(user) {
// 헤더 정보
const avatar = document.getElementById('profileAvatar');
if (avatar && user.name) {
// 이름의 첫 글자를 아바타로 사용
const initial = user.name.charAt(0).toUpperCase();
if (initial.match(/[A-Z가-힣]/)) {
avatar.textContent = initial;
}
}
document.getElementById('profileName').textContent = user.name || user.username || '사용자';
document.getElementById('profileRole').textContent = accessLevelMap[user.access_level] || user.access_level || '역할 미지정';
// 기본 정보
document.getElementById('userId').textContent = user.user_id || '-';
document.getElementById('username').textContent = user.username || '-';
document.getElementById('fullName').textContent = user.name || '-';
document.getElementById('accessLevel').textContent = accessLevelMap[user.access_level] || user.access_level || '-';
document.getElementById('workerId').textContent = user.user_id || '연결되지 않음';
// 날짜 포맷팅
if (user.created_at) {
const createdDate = new Date(user.created_at);
document.getElementById('createdAt').textContent = formatDate(createdDate);
}
if (user.last_login_at) {
const lastLoginDate = new Date(user.last_login_at);
document.getElementById('lastLogin').textContent = formatDateTime(lastLoginDate);
} else {
document.getElementById('lastLogin').textContent = '첫 로그인';
}
// 이메일
document.getElementById('email').textContent = user.email || '등록되지 않음';
}
// 날짜 포맷팅 함수
function formatDate(date) {
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
}
function formatDateTime(date) {
return date.toLocaleString('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
// 에러 표시
function showError(message) {
// 간단한 알림으로 처리
alert('❌ ' + message);
}
// 페이지 로드 시 실행
document.addEventListener('DOMContentLoaded', () => {
loadProfile();
});

View File

@@ -1,14 +0,0 @@
// js/navigation.js — ES6 모듈
// tkfb-core.js (non-module)에서 window에 등록한 함수들에 위임
export function redirectToLogin() {
if (window.getLoginUrl) {
window.location.href = window.getLoginUrl();
} else {
window.location.href = '/login.html';
}
}
export function redirectToDefaultDashboard(redirectUrl) {
window.location.href = redirectUrl || '/';
}

View File

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

View File

@@ -1,245 +0,0 @@
/**
* 생산팀 대시보드 — Sprint 003
*/
const PAGE_ICONS = {
'dashboard': 'fa-home',
'work.tbm': 'fa-clipboard-list',
'work.report_create': 'fa-file-alt',
'work.analysis': 'fa-chart-bar',
'work.nonconformity': 'fa-exclamation-triangle',
'work.schedule': 'fa-calendar-alt',
'work.meetings': 'fa-users',
'work.daily_status': 'fa-chart-bar',
'work.proxy_input': 'fa-user-edit',
'factory.repair_management': 'fa-tools',
'inspection.daily_patrol': 'fa-route',
'inspection.checkin': 'fa-user-check',
'inspection.work_status': 'fa-briefcase',
'purchase.request': 'fa-shopping-cart',
'purchase.analysis': 'fa-chart-line',
'attendance.monthly': 'fa-calendar',
'attendance.vacation_request': 'fa-paper-plane',
'attendance.vacation_management': 'fa-cog',
'attendance.vacation_allocation': 'fa-plus-circle',
'attendance.annual_overview': 'fa-chart-pie',
'attendance.monthly_comparison': 'fa-scale-balanced',
'admin.user_management': 'fa-users-cog',
'admin.projects': 'fa-project-diagram',
'admin.tasks': 'fa-tasks',
'admin.workplaces': 'fa-building',
'admin.equipments': 'fa-cogs',
'admin.departments': 'fa-sitemap',
'admin.notifications': 'fa-bell',
'admin.attendance_report': 'fa-clipboard-check',
};
// 내 메뉴에서 제외 (대시보드에서 직접 확인)
const HIDDEN_PAGES = ['dashboard', 'attendance.my_vacation_info'];
const CATEGORY_COLORS = {
'작업 관리': '#3b82f6',
'공장 관리': '#f59e0b',
'소모품 관리': '#10b981',
'근태 관리': '#8b5cf6',
'시스템 관리': '#6b7280',
};
const DEFAULT_COLOR = '#06b6d4';
function isExpired(expiresAt) {
if (!expiresAt) return false;
const today = new Date(); today.setHours(0,0,0,0);
const exp = new Date(expiresAt); exp.setHours(0,0,0,0);
return today > exp;
}
function escHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function fmtDays(n) { return n % 1 === 0 ? n.toString() : n.toFixed(1); }
let _dashboardData = null;
async function initDashboard() {
showSkeleton();
try {
const result = await api('/dashboard/my-summary');
if (!result.success) throw new Error(result.message || '데이터 로드 실패');
_dashboardData = result.data;
renderDashboard(result.data);
} catch (err) {
showError(err.message);
}
}
function renderDashboard(data) {
const { user, vacation, overtime, quick_access } = data;
const card = document.getElementById('profileCard');
const initial = (user.worker_name || user.name || '?').charAt(0);
const vacRemaining = vacation.remaining_days;
const vacTotal = vacation.total_days;
const vacUsed = vacation.used_days;
const vacPct = vacTotal > 0 ? Math.round((vacUsed / Math.max(vacTotal, 1)) * 100) : 0;
const vacColor = vacRemaining >= 5 ? 'green' : vacRemaining >= 3 ? 'yellow' : 'red';
const otHours = overtime.total_overtime_hours;
const otDays = overtime.overtime_days;
card.innerHTML = `
<button onclick="doLogout()" class="pd-logout-btn" title="로그아웃">
<i class="fas fa-sign-out-alt"></i>
</button>
<div class="pd-profile-header">
<div class="pd-avatar">${escHtml(initial)}</div>
<div>
<div class="pd-profile-name">${escHtml(user.worker_name || user.name)}</div>
<div class="pd-profile-sub">${escHtml(user.job_type || '')}${user.job_type ? ' · ' : ''}${escHtml(user.department_name)}
<a href="/pages/profile/password.html" style="margin-left:8px;color:rgba(255,255,255,0.7);font-size:11px;text-decoration:underline" title="비밀번호 변경"><i class="fas fa-key" style="margin-right:2px"></i>비밀번호 변경</a>
</div>
</div>
</div>
<div class="pd-info-list">
<div class="pd-info-row" onclick="openVacDetailModal()">
<div class="pd-info-left">
<i class="fas fa-umbrella-beach pd-info-icon"></i>
<span class="pd-info-label">연차</span>
</div>
${vacTotal > 0 ? `
<div class="pd-info-right">
<span class="pd-info-value">잔여 <strong>${fmtDays(vacRemaining)}일</strong></span>
<span class="pd-info-sub">/ ${fmtDays(vacTotal)}일</span>
<i class="fas fa-chevron-right pd-info-arrow"></i>
</div>
` : `
<div class="pd-info-right">
<span class="pd-info-sub">미등록</span>
<i class="fas fa-chevron-right pd-info-arrow"></i>
</div>
`}
</div>
${vacTotal > 0 ? `<div class="pd-progress-bar" style="margin:0 12px 8px"><div class="pd-progress-fill pd-progress-${vacColor}" style="width:${Math.min(vacPct, 100)}%"></div></div>` : ''}
<div class="pd-info-row">
<div class="pd-info-left">
<i class="fas fa-clock pd-info-icon"></i>
<span class="pd-info-label">연장근로</span>
</div>
<div class="pd-info-right">
<span class="pd-info-value"><strong>${otHours.toFixed(1)}h</strong></span>
<span class="pd-info-sub">이번달 ${otDays}일</span>
</div>
</div>
</div>
`;
renderGrid('deptPagesGrid', 'deptPagesSection', quick_access.department_pages);
renderGrid('personalPagesGrid', 'personalPagesSection', quick_access.personal_pages);
renderGrid('adminPagesGrid', 'adminPagesSection', quick_access.admin_pages);
}
function openVacDetailModal() {
if (!_dashboardData) return;
const { vacation } = _dashboardData;
const details = vacation.details || [];
const groups = {};
details.forEach(d => {
const bt = d.balance_type || 'AUTO';
if (!groups[bt]) groups[bt] = { total: 0, used: 0, remaining: 0, expires_at: d.expires_at, items: [] };
groups[bt].total += d.total;
groups[bt].used += d.used;
groups[bt].remaining += d.remaining;
if (d.expires_at) groups[bt].expires_at = d.expires_at;
groups[bt].items.push(d);
});
const LABELS = { CARRY_OVER: '이월연차', AUTO: '정기연차', MANUAL: '추가부여', LONG_SERVICE: '장기근속', COMPANY_GRANT: '경조사/특별' };
const ORDER = ['CARRY_OVER', 'AUTO', 'MANUAL', 'LONG_SERVICE', 'COMPANY_GRANT'];
let html = '';
ORDER.forEach(bt => {
const g = groups[bt];
if (!g || (g.total === 0 && g.used === 0)) return;
const label = LABELS[bt] || bt;
const expired = bt === 'CARRY_OVER' && isExpired(g.expires_at);
const lapsed = expired ? Math.max(0, g.total - g.used) : 0;
html += `<div class="pd-detail-row">
<span class="pd-detail-label">${label}</span>
<span class="pd-detail-value">
${g.total !== 0 ? `배정 ${fmtDays(g.total)}` : ''}
${g.used > 0 ? ` · 사용 ${fmtDays(g.used)}` : ''}
${expired && lapsed > 0 ? ` · <span style="color:#9ca3af;text-decoration:line-through">만료 ${fmtDays(lapsed)}</span>` : ''}
${!expired && g.remaining !== 0 ? ` · 잔여 <strong>${fmtDays(g.remaining)}</strong>` : ''}
</span>
</div>`;
});
if (!html) html = '<div style="text-align:center;padding:20px;color:rgba(255,255,255,0.6)">연차 정보가 없습니다</div>';
html += `<div class="pd-detail-total">
<span>합계</span>
<span>배정 ${fmtDays(vacation.total_days)} · 사용 ${fmtDays(vacation.used_days)} · 잔여 <strong>${fmtDays(vacation.remaining_days)}</strong></span>
</div>`;
document.getElementById('vacDetailContent').innerHTML = html;
document.getElementById('vacDetailModal').classList.add('active');
}
function closeVacDetail() {
document.getElementById('vacDetailModal').classList.remove('active');
}
function renderGrid(gridId, sectionId, pages) {
const grid = document.getElementById(gridId);
const section = document.getElementById(sectionId);
if (!pages || pages.length === 0) { section.classList.add('hidden'); return; }
section.classList.remove('hidden');
const filtered = pages.filter(p => !HIDDEN_PAGES.includes(p.page_key));
if (filtered.length === 0) { section.classList.add('hidden'); return; }
grid.innerHTML = filtered.map(p => {
const icon = PAGE_ICONS[p.page_key] || p.icon || 'fa-circle';
const color = CATEGORY_COLORS[p.category] || DEFAULT_COLOR;
return `<a href="${escHtml(p.page_path)}" class="pd-grid-item">
<div class="pd-grid-icon" style="background:${color}">
<i class="fas ${icon}"></i>
</div>
<span class="pd-grid-label">${escHtml(p.page_name)}</span>
</a>`;
}).join('');
}
function showSkeleton() {
const card = document.getElementById('profileCard');
card.innerHTML = `
<div class="pd-profile-header">
<div class="pd-skeleton" style="width:48px;height:48px;border-radius:50%"></div>
<div style="flex:1">
<div class="pd-skeleton" style="width:100px;height:18px;margin-bottom:6px"></div>
<div class="pd-skeleton" style="width:140px;height:14px"></div>
</div>
</div>
<div class="pd-skeleton" style="height:50px;margin-top:12px"></div>
<div class="pd-skeleton" style="height:50px;margin-top:6px"></div>
`;
['deptPagesGrid'].forEach(id => {
const g = document.getElementById(id);
if (g) g.innerHTML = Array(8).fill('<div style="display:flex;flex-direction:column;align-items:center;gap:6px"><div class="pd-skeleton" style="width:52px;height:52px;border-radius:14px"></div><div class="pd-skeleton" style="width:40px;height:12px"></div></div>').join('');
});
}
function showError(msg) {
document.getElementById('profileCard').innerHTML = `
<div class="pd-error">
<i class="fas fa-exclamation-circle"></i>
<p>${escHtml(msg || '정보를 불러올 수 없습니다.')}</p>
<button class="pd-error-btn" onclick="initDashboard()"><i class="fas fa-redo mr-1"></i>새로고침</button>
</div>
`;
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => setTimeout(initDashboard, 300));
} else {
setTimeout(initDashboard, 300);
}

View File

@@ -1,43 +0,0 @@
// /js/project-analysis-api.js
import { apiGet } from './api-helper.js';
/**
* 분석 페이지에 필요한 모든 초기 데이터(마스터 데이터)를 병렬로 가져옵니다.
* 이 데이터는 필터 옵션을 채우는 데 사용됩니다.
* @returns {Promise<{workers: Array, projects: Array, tasks: Array}>}
*/
export async function getMasterData() {
try {
const [allWorkers, projects, tasks] = await Promise.all([
apiGet('/workers'),
apiGet('/projects'),
apiGet('/tasks')
]);
// 활성화된 작업자만 필터링
const workers = allWorkers.filter(worker => {
return worker.status === 'active' || worker.is_active === 1 || worker.is_active === true;
});
return { workers, projects, tasks };
} catch (error) {
console.error('마스터 데이터 로딩 실패:', error);
throw new Error('필터링에 필요한 데이터를 불러오는 데 실패했습니다.');
}
}
/**
* 지정된 기간의 모든 분석 데이터를 백엔드에서 직접 가져옵니다.
* @param {string} startDate - 시작일 (YYYY-MM-DD)
* @param {string} endDate - 종료일 (YYYY-MM-DD)
* @returns {Promise<object>} - 요약, 집계, 상세 데이터가 모두 포함된 분석 결과 객체
*/
export async function getAnalysisReport(startDate, endDate) {
try {
const analysisData = await apiGet(`/analysis?startDate=${startDate}&endDate=${endDate}`);
return analysisData;
} catch (error) {
console.error('분석 보고서 데이터 로딩 실패:', error);
throw new Error(`분석 데이터를 불러오는 데 실패했습니다: ${error.message}`);
}
}

View File

@@ -1,170 +0,0 @@
// /js/project-analysis-ui.js
const DOM = {
// 기간 설정
startDate: document.getElementById('startDate'),
endDate: document.getElementById('endDate'),
// 카드 및 필터
analysisCard: document.getElementById('analysisCard'),
summaryCards: document.getElementById('summaryCards'),
projectFilter: document.getElementById('projectFilter'),
workerFilter: document.getElementById('workerFilter'),
taskFilter: document.getElementById('taskFilter'),
// 탭
tabButtons: document.querySelectorAll('.tab-button'),
tabContents: document.querySelectorAll('.analysis-content'),
// 테이블 본문
projectTableBody: document.getElementById('projectTableBody'),
workerTableBody: document.getElementById('workerTableBody'),
taskTableBody: document.getElementById('taskTableBody'),
detailTableBody: document.getElementById('detailTableBody'),
};
/**
* 날짜 input 값을 YYYY-MM-DD 형식의 문자열로 반환
* @param {Date} date - 날짜 객체
* @returns {string} - 포맷된 날짜 문자열
*/
const formatDate = (date) => date.toISOString().split('T')[0];
/**
* UI상의 날짜 선택기를 기본값(이번 달)으로 설정합니다.
*/
export function setDefaultDates() {
const now = new Date();
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0);
DOM.startDate.value = formatDate(firstDay);
DOM.endDate.value = formatDate(lastDay);
}
/**
* 분석 실행 전후의 UI 상태를 관리합니다 (로딩 표시 등)
* @param {'loading' | 'data' | 'no-data' | 'error'} state - UI 상태
*/
export function setUIState(state) {
const projectCols = 5;
const detailCols = 8;
const messages = {
loading: '📊 데이터 분석 중...',
'no-data': '해당 기간에 분석할 데이터가 없습니다.',
error: '오류가 발생했습니다. 다시 시도해주세요.',
};
if (state === 'data') {
DOM.analysisCard.style.display = 'block';
} else {
const message = messages[state];
const html = `<tr><td colspan="${projectCols}" class="${state}">${message}</td></tr>`;
const detailHtml = `<tr><td colspan="${detailCols}" class="${state}">${message}</td></tr>`;
DOM.projectTableBody.innerHTML = html;
DOM.workerTableBody.innerHTML = html;
DOM.taskTableBody.innerHTML = html;
DOM.detailTableBody.innerHTML = detailHtml;
DOM.summaryCards.innerHTML = '';
DOM.analysisCard.style.display = 'block';
}
}
/**
* 마스터 데이터를 기반으로 필터 옵션을 채웁니다.
* @param {{workers: Array, projects: Array, tasks: Array}} masterData - 마스터 데이터
*/
export function updateFilterOptions(masterData) {
const createOptions = (items, key, value) => {
let html = '<option value="">전체</option>';
items.forEach(item => {
html += `<option value="${item[key]}">${item[value]}</option>`;
});
return html;
};
DOM.projectFilter.innerHTML = createOptions(masterData.projects, 'project_id', 'project_name');
DOM.workerFilter.innerHTML = createOptions(masterData.workers, 'user_id', 'worker_name');
DOM.taskFilter.innerHTML = createOptions(masterData.tasks, 'task_id', 'category');
}
/**
* 요약 카드 데이터를 렌더링합니다.
* @param {object} summary - 요약 데이터
*/
export function renderSummary(summary) {
DOM.summaryCards.innerHTML = `
<div class="summary-card"><h4>총 투입 시간</h4><div class="value">${(summary.totalHours || 0).toFixed(1)}h</div></div>
<div class="summary-card"><h4>참여 프로젝트</h4><div class="value">${summary.totalProjects || 0}개</div></div>
<div class="summary-card"><h4>참여 인원</h4><div class="value">${summary.totalWorkers || 0}명</div></div>
<div class="summary-card"><h4>작업 분류</h4><div class="value">${summary.totalTasks || 0}개</div></div>
`;
}
/**
* 집계된 데이터를 받아 테이블을 렌더링하는 범용 함수
* @param {HTMLElement} tableBodyEl - 렌더링할 테이블의 tbody 요소
* @param {Array} data - 집계된 데이터 배열
* @param {function} rowRenderer - 각 행을 렌더링하는 함수
*/
function renderTable(tableBodyEl, data, rowRenderer) {
if (!data || data.length === 0) {
tableBodyEl.innerHTML = '<tr><td colspan="5" class="no-data">데이터가 없습니다</td></tr>';
return;
}
tableBodyEl.innerHTML = data.map(rowRenderer).join('');
}
/**
* 집계된 데이터를 기반으로 모든 분석 테이블을 렌더링합니다.
* @param {object} analysis - 프로젝트/작업자/작업별 집계 데이터
*/
export function renderAnalysisTables(analysis) {
renderTable(DOM.projectTableBody, analysis.byProject, (p, i) => `
<tr><td>${i + 1}</td><td class="project-col" title="${p.name}">${p.name}</td><td class="hours-col">${p.hours}h</td>
<td>${p.percentage}%</td><td>${p.participants}명</td></tr>`);
renderTable(DOM.workerTableBody, analysis.byWorker, (w, i) => `
<tr><td>${i + 1}</td><td class="worker-col">${w.name}</td><td class="hours-col">${w.hours}h</td>
<td>${w.percentage}%</td><td>${w.participants}개</td></tr>`);
renderTable(DOM.taskTableBody, analysis.byTask, (t, i) => `
<tr><td>${i + 1}</td><td class="task-col" title="${t.name}">${t.name}</td><td class="hours-col">${t.hours}h</td>
<td>${t.percentage}%</td><td>${t.participants}명</td></tr>`);
}
/**
* 상세 내역 테이블을 렌더링합니다.
* @param {Array} detailData - 필터링된 상세 데이터
*/
export function renderDetailTable(detailData) {
if (!detailData || detailData.length === 0) {
DOM.detailTableBody.innerHTML = '<tr><td colspan="8" class="no-data">데이터가 없습니다</td></tr>';
return;
}
DOM.detailTableBody.innerHTML = detailData.map((item, index) => `
<tr><td>${index + 1}</td><td>${formatDate(new Date(item.date))}</td>
<td class="project-col" title="${item.project_name}">${item.project_name}</td>
<td class="worker-col">${item.worker_name}</td><td class="task-col" title="${item.task_category}">${item.task_category}</td>
<td>${item.work_details || '정상근무'}</td>
<td class="hours-col">${item.work_hours}h</td>
<td title="${item.memo || '-'}">${(item.memo || '-').substring(0, 20)}</td></tr>`
).join('');
}
/**
* 탭 UI를 제어합니다.
* @param {string} tabName - 활성화할 탭의 이름
*/
export function switchTab(tabName) {
DOM.tabButtons.forEach(btn => btn.classList.toggle('active', btn.dataset.tab === tabName));
DOM.tabContents.forEach(content => content.classList.toggle('active', content.id === `${tabName}Tab`));
}
/**
* 사용자로부터 현재 필터 값을 가져옵니다.
* @returns {{project: string, worker: string, task: string}}
*/
export function getCurrentFilters() {
return {
project: DOM.projectFilter.value,
worker: DOM.workerFilter.value,
task: DOM.taskFilter.value,
};
}

View File

@@ -1,106 +0,0 @@
// /js/project-analysis.js
import { getMasterData, getAnalysisReport } from './project-analysis-api.js';
import {
setDefaultDates,
setUIState,
updateFilterOptions,
renderSummary,
renderAnalysisTables,
renderDetailTable,
switchTab,
} from './project-analysis-ui.js';
// DOM 요소 참조 (이벤트 리스너 설정용)
const DOM = {
startDate: document.getElementById('startDate'),
endDate: document.getElementById('endDate'),
analyzeBtn: document.getElementById('analyzeBtn'),
quickMonthBtn: document.getElementById('quickMonth'),
quickLastMonthBtn: document.getElementById('quickLastMonth'),
// 필터 버튼은 현재 아무 기능도 하지 않으므로 주석 처리 또는 제거 가능
// applyFilterBtn: document.getElementById('applyFilter'),
tabButtons: document.querySelectorAll('.tab-button'),
};
/**
* 분석 실행 버튼 클릭 이벤트 핸들러
*/
async function handleAnalysis() {
const startDate = DOM.startDate.value;
const endDate = DOM.endDate.value;
if (!startDate || !endDate || startDate > endDate) {
alert('올바른 분석 기간을 설정해주세요.');
return;
}
setUIState('loading');
try {
const analysisResult = await getAnalysisReport(startDate, endDate);
if (!analysisResult.summary.totalHours) {
setUIState('no-data');
return;
}
renderSummary(analysisResult.summary);
renderAnalysisTables(analysisResult);
renderDetailTable(analysisResult.details);
setUIState('data');
} catch (error) {
console.error('분석 처리 중 오류:', error);
setUIState('error');
alert(error.message);
}
}
/**
* 빠른 날짜 설정 버튼 핸들러
*/
function handleQuickDate(monthType) {
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
const firstDay = monthType === 'this' ? new Date(year, month, 1) : new Date(year, month - 1, 1);
const lastDay = monthType === 'this' ? new Date(year, month + 1, 0) : new Date(year, month, 0);
DOM.startDate.value = firstDay.toISOString().split('T')[0];
DOM.endDate.value = lastDay.toISOString().split('T')[0];
}
/**
* 이벤트 리스너 설정
*/
function setupEventListeners() {
DOM.analyzeBtn.addEventListener('click', handleAnalysis);
DOM.quickMonthBtn.addEventListener('click', () => handleQuickDate('this'));
DOM.quickLastMonthBtn.addEventListener('click', () => handleQuickDate('last'));
DOM.tabButtons.forEach(btn => {
btn.addEventListener('click', () => switchTab(btn.dataset.tab));
});
// 프론트엔드 필터링은 제거되었으므로 관련 이벤트 리스너는 주석 처리합니다.
// DOM.applyFilterBtn.addEventListener('click', ...);
}
/**
* 페이지 초기화 함수
*/
async function initialize() {
setDefaultDates();
setupEventListeners();
try {
const masterData = await getMasterData();
updateFilterOptions(masterData);
await handleAnalysis();
} catch (error) {
alert(error.message);
setUIState('error');
}
}
// 초기화 실행
document.addEventListener('DOMContentLoaded', initialize);

View File

@@ -1,546 +0,0 @@
// 프로젝트 관리 페이지 JavaScript
// 전역 변수
let allProjects = [];
let filteredProjects = [];
let currentEditingProject = null;
let currentStatusFilter = 'all'; // 'all', 'active', 'inactive'
// 페이지 초기화
document.addEventListener('DOMContentLoaded', function() {
initializePage();
loadProjects();
});
// 페이지 초기화
function initializePage() {
// 시간 업데이트 시작
updateCurrentTime();
setInterval(updateCurrentTime, 1000);
// 사용자 정보 업데이트
updateUserInfo();
// 프로필 메뉴 토글
setupProfileMenu();
// 로그아웃 버튼
setupLogoutButton();
// 검색 입력 이벤트
setupSearchInput();
}
// 현재 시간 업데이트 (시 분 초 형식으로 고정)
function updateCurrentTime() {
const now = new Date();
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
const timeString = `${hours}${minutes}${seconds}`;
const timeElement = document.getElementById('timeValue');
if (timeElement) {
timeElement.textContent = timeString;
}
}
// navbar/sidebar는 app-init.js에서 공통 처리
function updateUserInfo() {
// app-init.js가 navbar 사용자 정보를 처리
}
// 프로필 메뉴 설정
function setupProfileMenu() {
const userProfile = document.getElementById('userProfile');
const profileMenu = document.getElementById('profileMenu');
if (userProfile && profileMenu) {
userProfile.addEventListener('click', function(e) {
e.stopPropagation();
const isVisible = profileMenu.style.display === 'block';
profileMenu.style.display = isVisible ? 'none' : 'block';
});
// 외부 클릭 시 메뉴 닫기
document.addEventListener('click', function() {
profileMenu.style.display = 'none';
});
}
}
// 로그아웃 버튼 설정
function setupLogoutButton() {
const logoutBtn = document.getElementById('logoutBtn');
if (logoutBtn) {
logoutBtn.addEventListener('click', function() {
if (confirm('로그아웃 하시겠습니까?')) {
if (window.clearSSOAuth) window.clearSSOAuth();
window.location.href = window.getLoginUrl ? window.getLoginUrl() : '/login';
}
});
}
}
// 검색 입력 설정
function setupSearchInput() {
const searchInput = document.getElementById('searchInput');
if (searchInput) {
searchInput.addEventListener('input', function() {
searchProjects();
});
searchInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
searchProjects();
}
});
}
}
// 프로젝트 목록 로드
async function loadProjects() {
try {
const response = await apiCall('/projects', 'GET');
// API 응답이 { success: true, data: [...] } 형태인 경우 처리
let projectData = [];
if (response && response.success && Array.isArray(response.data)) {
projectData = response.data;
} else if (Array.isArray(response)) {
projectData = response;
} else {
console.warn('프로젝트 데이터가 배열이 아닙니다:', response);
projectData = [];
}
allProjects = projectData;
// 초기 필터 적용
applyAllFilters();
updateStatCardActiveState();
} catch (error) {
console.error('프로젝트 로딩 오류:', error);
showToast('프로젝트 목록을 불러오는데 실패했습니다.', 'error');
allProjects = [];
filteredProjects = [];
renderProjects();
}
}
// 프로젝트 목록 렌더링
function renderProjects() {
const projectsGrid = document.getElementById('projectsGrid');
const emptyState = document.getElementById('emptyState');
if (!projectsGrid || !emptyState) return;
if (filteredProjects.length === 0) {
projectsGrid.style.display = 'none';
emptyState.style.display = 'block';
return;
}
projectsGrid.style.display = 'grid';
emptyState.style.display = 'none';
const projectsHtml = filteredProjects.map(project => {
// 프로젝트 상태 아이콘 및 텍스트
const statusMap = {
'planning': { icon: '', text: '계획', color: '#6b7280' },
'active': { icon: '', text: '진행중', color: '#10b981' },
'completed': { icon: '', text: '완료', color: '#3b82f6' },
'cancelled': { icon: '', text: '취소', color: '#ef4444' }
};
const validStatuses = ['planning', 'active', 'completed', 'cancelled'];
const safeProjectStatus = validStatuses.includes(project.project_status) ? project.project_status : 'active';
const status = statusMap[safeProjectStatus];
// is_active 값 처리 (DB에서 0/1로 오는 경우 대비)
const isInactive = project.is_active === 0 || project.is_active === false || project.is_active === 'false';
// XSS 방지를 위한 안전한 값
const safeProjectId = parseInt(project.project_id) || 0;
const safeJobNo = escapeHtml(project.job_no || 'Job No. 없음');
const safeProjectName = escapeHtml(project.project_name || '-');
const safePm = escapeHtml(project.pm || '-');
const safeSite = escapeHtml(project.site || '-');
return `
<div class="project-card ${isInactive ? 'inactive' : ''}" onclick="editProject(${safeProjectId})">
${isInactive ? '<div class="inactive-overlay"><span class="inactive-badge"> 비활성화됨</span></div>' : ''}
<div class="project-header">
<div class="project-info">
<div class="project-job-no">${safeJobNo}</div>
<h3 class="project-name">
${safeProjectName}
${isInactive ? '<span class="inactive-label">(비활성)</span>' : ''}
</h3>
<div class="project-meta">
<div class="meta-row">
<span class="meta-label">상태</span>
<span class="meta-value" style="color: ${status.color}; font-weight: 600;">${status.icon} ${status.text}</span>
</div>
<div class="meta-row">
<span class="meta-label">계약일</span>
<span class="meta-value">${project.contract_date ? formatDate(project.contract_date) : '-'}</span>
</div>
<div class="meta-row">
<span class="meta-label">납기일</span>
<span class="meta-value">${project.due_date ? formatDate(project.due_date) : '-'}</span>
</div>
<div class="meta-row">
<span class="meta-label">PM</span>
<span class="meta-value">${safePm}</span>
</div>
<div class="meta-row">
<span class="meta-label">현장</span>
<span class="meta-value">${safeSite}</span>
</div>
${isInactive ? '<div class="inactive-notice"> 작업보고서에서 숨김</div>' : ''}
</div>
</div>
<div class="project-actions">
<button class="btn-edit" onclick="event.stopPropagation(); editProject(${safeProjectId})" title="수정">
수정
</button>
<button class="btn-delete" onclick="event.stopPropagation(); confirmDeleteProject(${safeProjectId})" title="삭제">
삭제
</button>
</div>
</div>
</div>
`;
}).join('');
projectsGrid.innerHTML = projectsHtml;
}
// 프로젝트 통계 업데이트
function updateProjectStats() {
const activeProjects = filteredProjects.filter(p => p.is_active === 1 || p.is_active === true);
const inactiveProjects = filteredProjects.filter(p => p.is_active === 0 || p.is_active === false);
const activeProjectsElement = document.getElementById('activeProjects');
const inactiveProjectsElement = document.getElementById('inactiveProjects');
const totalProjectsElement = document.getElementById('totalProjects');
if (activeProjectsElement) {
activeProjectsElement.textContent = activeProjects.length;
}
if (inactiveProjectsElement) {
inactiveProjectsElement.textContent = inactiveProjects.length;
}
if (totalProjectsElement) {
totalProjectsElement.textContent = filteredProjects.length;
}
}
// 날짜 포맷팅
function formatDate(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
}
// 상태별 필터링
function filterByStatus(status) {
currentStatusFilter = status;
// 통계 카드 활성화 상태 업데이트
updateStatCardActiveState();
// 필터링 적용
applyAllFilters();
}
// 통계 카드 활성화 상태 업데이트
function updateStatCardActiveState() {
// 모든 통계 카드에서 active 클래스 제거
document.querySelectorAll('.stat-item').forEach(item => {
item.classList.remove('active');
});
// 현재 선택된 필터에 active 클래스 추가
const activeCard = document.querySelector(`.${currentStatusFilter === 'active' ? 'active-stat' :
currentStatusFilter === 'inactive' ? 'inactive-stat' : 'total-stat'}`);
if (activeCard) {
activeCard.classList.add('active');
}
}
// 모든 필터 적용 (검색 + 상태)
function applyAllFilters() {
const searchInput = document.getElementById('searchInput');
const searchTerm = searchInput ? searchInput.value.toLowerCase().trim() : '';
// 1단계: 상태 필터링
let statusFiltered = [...allProjects];
if (currentStatusFilter === 'active') {
statusFiltered = allProjects.filter(p => p.is_active === 1 || p.is_active === true);
} else if (currentStatusFilter === 'inactive') {
statusFiltered = allProjects.filter(p => p.is_active === 0 || p.is_active === false);
}
// 2단계: 검색 필터링
if (!searchTerm) {
filteredProjects = statusFiltered;
} else {
filteredProjects = statusFiltered.filter(project =>
project.project_name.toLowerCase().includes(searchTerm) ||
(project.job_no && project.job_no.toLowerCase().includes(searchTerm)) ||
(project.pm && project.pm.toLowerCase().includes(searchTerm)) ||
(project.site && project.site.toLowerCase().includes(searchTerm))
);
}
renderProjects();
updateProjectStats();
}
// 프로젝트 검색 (기존 함수 수정)
function searchProjects() {
applyAllFilters();
}
// 프로젝트 필터링
function filterProjects() {
const statusFilter = document.getElementById('statusFilter');
const selectedStatus = statusFilter ? statusFilter.value : '';
// 현재는 상태 필드가 없으므로 기본 필터링만 적용
searchProjects();
}
// 프로젝트 정렬
function sortProjects() {
const sortBy = document.getElementById('sortBy');
const sortField = sortBy ? sortBy.value : 'created_at';
filteredProjects.sort((a, b) => {
switch (sortField) {
case 'project_name':
return a.project_name.localeCompare(b.project_name);
case 'due_date':
if (!a.due_date && !b.due_date) return 0;
if (!a.due_date) return 1;
if (!b.due_date) return -1;
return new Date(a.due_date) - new Date(b.due_date);
case 'created_at':
default:
return new Date(b.created_at || 0) - new Date(a.created_at || 0);
}
});
renderProjects();
}
// 프로젝트 목록 새로고침
async function refreshProjectList() {
const refreshBtn = document.querySelector('.btn-secondary');
if (refreshBtn) {
const originalText = refreshBtn.innerHTML;
refreshBtn.innerHTML = '<span class="btn-icon"></span>새로고침 중...';
refreshBtn.disabled = true;
await loadProjects();
refreshBtn.innerHTML = originalText;
refreshBtn.disabled = false;
} else {
await loadProjects();
}
showToast('프로젝트 목록이 새로고침되었습니다.', 'success');
}
// 프로젝트 모달 열기
function openProjectModal(project = null) {
const modal = document.getElementById('projectModal');
const modalTitle = document.getElementById('modalTitle');
const deleteBtn = document.getElementById('deleteProjectBtn');
if (!modal) return;
currentEditingProject = project;
if (project) {
// 수정 모드
modalTitle.textContent = '프로젝트 수정';
deleteBtn.style.display = 'inline-flex';
// 폼에 데이터 채우기
document.getElementById('projectId').value = project.project_id;
document.getElementById('jobNo').value = project.job_no || '';
document.getElementById('projectName').value = project.project_name || '';
document.getElementById('contractDate').value = project.contract_date || '';
document.getElementById('dueDate').value = project.due_date || '';
document.getElementById('deliveryMethod').value = project.delivery_method || '';
document.getElementById('site').value = project.site || '';
document.getElementById('pm').value = project.pm || '';
document.getElementById('projectStatus').value = project.project_status || 'active';
document.getElementById('completedDate').value = project.completed_date || '';
// is_active 값 처리 (DB에서 0/1로 오는 경우 대비)
const isActiveValue = project.is_active === 1 || project.is_active === true || project.is_active === 'true';
document.getElementById('isActive').checked = isActiveValue;
} else {
// 신규 등록 모드
modalTitle.textContent = '새 프로젝트 등록';
deleteBtn.style.display = 'none';
// 폼 초기화
document.getElementById('projectForm').reset();
document.getElementById('projectId').value = '';
}
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
// 첫 번째 입력 필드에 포커스
setTimeout(() => {
const firstInput = document.getElementById('jobNo');
if (firstInput) firstInput.focus();
}, 100);
}
// 프로젝트 모달 닫기
function closeProjectModal() {
const modal = document.getElementById('projectModal');
if (modal) {
modal.style.display = 'none';
document.body.style.overflow = '';
currentEditingProject = null;
}
}
// 프로젝트 편집
function editProject(projectId) {
const project = allProjects.find(p => p.project_id === projectId);
if (project) {
openProjectModal(project);
} else {
showToast('프로젝트를 찾을 수 없습니다.', 'error');
}
}
// 프로젝트 저장
async function saveProject() {
try {
const form = document.getElementById('projectForm');
const formData = new FormData(form);
const projectData = {
job_no: document.getElementById('jobNo').value.trim(),
project_name: document.getElementById('projectName').value.trim(),
contract_date: document.getElementById('contractDate').value || null,
due_date: document.getElementById('dueDate').value || null,
delivery_method: document.getElementById('deliveryMethod').value || null,
site: document.getElementById('site').value.trim() || null,
pm: document.getElementById('pm').value.trim() || null,
project_status: document.getElementById('projectStatus').value || 'active',
completed_date: document.getElementById('completedDate').value || null,
is_active: document.getElementById('isActive').checked ? 1 : 0
};
// 필수 필드 검증
if (!projectData.job_no || !projectData.project_name) {
showToast('Job No.와 프로젝트명은 필수 입력 항목입니다.', 'error');
return;
}
const projectId = document.getElementById('projectId').value;
let response;
if (projectId) {
// 수정
response = await apiCall(`/projects/${projectId}`, 'PUT', projectData);
} else {
// 신규 등록
response = await apiCall('/projects', 'POST', projectData);
}
if (response && (response.success || response.project_id)) {
const action = projectId ? '수정' : '등록';
showToast(`프로젝트가 성공적으로 ${action}되었습니다.`, 'success');
closeProjectModal();
await loadProjects();
} else {
throw new Error(response?.message || '저장에 실패했습니다.');
}
} catch (error) {
console.error('프로젝트 저장 오류:', error);
showToast(error.message || '프로젝트 저장 중 오류가 발생했습니다.', 'error');
}
}
// 프로젝트 삭제 확인
function confirmDeleteProject(projectId) {
const project = allProjects.find(p => p.project_id === projectId);
if (!project) {
showToast('프로젝트를 찾을 수 없습니다.', 'error');
return;
}
if (confirm(`"${project.project_name}" 프로젝트를 정말 삭제하시겠습니까?\n\n 삭제된 프로젝트는 복구할 수 없습니다.`)) {
deleteProjectById(projectId);
}
}
// 프로젝트 삭제 (수정 모드에서)
function deleteProject() {
if (currentEditingProject) {
confirmDeleteProject(currentEditingProject.project_id);
}
}
// 프로젝트 삭제 실행
async function deleteProjectById(projectId) {
try {
const response = await apiCall(`/projects/${projectId}`, 'DELETE');
if (response && response.success) {
showToast('프로젝트가 성공적으로 삭제되었습니다.', 'success');
closeProjectModal();
await loadProjects();
} else {
throw new Error(response?.message || '삭제에 실패했습니다.');
}
} catch (error) {
console.error('프로젝트 삭제 오류:', error);
showToast(error.message || '프로젝트 삭제 중 오류가 발생했습니다.', 'error');
}
}
// showToast → api-base.js 전역 사용
// 전역 함수로 노출
window.openProjectModal = openProjectModal;
window.closeProjectModal = closeProjectModal;
window.editProject = editProject;
window.saveProject = saveProject;
window.deleteProject = deleteProject;
window.confirmDeleteProject = confirmDeleteProject;
window.searchProjects = searchProjects;
window.filterProjects = filterProjects;
window.sortProjects = sortProjects;
window.refreshProjectList = refreshProjectList;
window.filterByStatus = filterByStatus;

View File

@@ -1,262 +0,0 @@
/**
* proxy-input.js — 대리입력 리뉴얼
* Step 1: 날짜 선택 → 작업자 목록 (체크박스)
* Step 2: 공통 입력 1개 → 선택된 전원 일괄 적용
*/
let currentDate = '';
let allWorkers = [];
let selectedIds = new Set();
let projects = [];
let workTypes = [];
let defectCategories = []; // { category_id, category_name, items: [{ item_id, item_name }] }
// ===== Init =====
document.addEventListener('DOMContentLoaded', () => {
currentDate = new Date().toISOString().substring(0, 10);
document.getElementById('dateInput').value = currentDate;
setTimeout(async () => {
await loadDropdownData();
await loadWorkers();
}, 500);
});
async function loadDropdownData() {
try {
const [pRes, wRes] = await Promise.all([
window.apiCall('/projects'),
window.apiCall('/daily-work-reports/work-types')
]);
projects = (pRes.data || pRes || []).filter(p => p.is_active !== 0);
workTypes = (wRes.data || wRes || []).map(w => ({ id: w.id || w.work_type_id, name: w.name || w.work_type_name, ...w }));
// 부적합 대분류/소분류 로드
const cRes = await window.apiCall('/work-issues/categories/type/nonconformity');
const cats = cRes.data || cRes || [];
for (const c of cats) {
const iRes = await window.apiCall('/work-issues/items/category/' + c.category_id);
defectCategories.push({
category_id: c.category_id,
category_name: c.category_name,
items: (iRes.data || iRes || [])
});
}
} catch (e) { console.warn('드롭다운 로드 실패:', e); }
}
// ===== Step 1: Worker List =====
async function loadWorkers() {
currentDate = document.getElementById('dateInput').value;
if (!currentDate) return;
const list = document.getElementById('workerList');
list.innerHTML = '<div class="pi-skeleton"></div><div class="pi-skeleton"></div>';
selectedIds.clear();
updateEditButton();
try {
const res = await window.apiCall('/proxy-input/daily-status?date=' + currentDate);
if (!res.success) throw new Error(res.message);
allWorkers = res.data.workers || [];
const s = res.data.summary || {};
document.getElementById('totalNum').textContent = s.total_active_workers || allWorkers.length;
document.getElementById('doneNum').textContent = s.report_completed || 0;
document.getElementById('missingNum').textContent = s.report_missing || 0;
document.getElementById('vacNum').textContent = allWorkers.filter(w => w.vacation_type_code === 'ANNUAL_FULL').length;
renderWorkerList();
} catch (e) {
list.innerHTML = '<div class="pi-empty"><i class="fas fa-exclamation-triangle text-2xl text-red-300"></i><p>데이터 로드 실패</p></div>';
}
}
function renderWorkerList() {
const list = document.getElementById('workerList');
if (!allWorkers.length) {
list.innerHTML = '<div class="pi-empty"><p>작업자가 없습니다</p></div>';
return;
}
// 부서별 그룹핑
const byDept = {};
allWorkers.forEach(w => {
const dept = w.department_name || '미배정';
if (!byDept[dept]) byDept[dept] = [];
byDept[dept].push(w);
});
let html = '';
Object.keys(byDept).sort().forEach(dept => {
html += `<div class="pi-dept-label">${esc(dept)}</div>`;
byDept[dept].forEach(w => {
const isFullVac = w.vacation_type_code === 'ANNUAL_FULL';
const hasVac = !!w.vacation_type_code;
const vacBadge = isFullVac ? '<span class="pi-badge vac">연차</span>'
: hasVac ? `<span class="pi-badge vac-half">${esc(w.vacation_type_name)}</span>` : '';
const doneBadge = w.has_report ? `<span class="pi-badge done">${w.total_report_hours}h</span>` : '<span class="pi-badge missing">미입력</span>';
html += `
<label class="pi-worker ${isFullVac ? 'disabled' : ''}">
<input type="checkbox" class="pi-check" value="${w.user_id}"
${isFullVac ? 'disabled' : ''}
onchange="onWorkerCheck(${w.user_id}, this.checked)">
<div class="pi-worker-info">
<span class="pi-worker-name">${esc(w.worker_name)}</span>
<span class="pi-worker-job">${esc(w.job_type || '')}</span>
</div>
<div class="pi-worker-badges">${vacBadge}${doneBadge}</div>
</label>`;
});
});
list.innerHTML = html;
}
function onWorkerCheck(userId, checked) {
if (checked) selectedIds.add(userId);
else selectedIds.delete(userId);
updateEditButton();
}
function toggleSelectAll(checked) {
allWorkers.forEach(w => {
if (w.vacation_type_code === 'ANNUAL_FULL') return;
const cb = document.querySelector(`.pi-check[value="${w.user_id}"]`);
if (cb) { cb.checked = checked; onWorkerCheck(w.user_id, checked); }
});
}
function updateEditButton() {
const btn = document.getElementById('editBtn');
const n = selectedIds.size;
btn.disabled = n === 0;
document.getElementById('editBtnText').textContent = n > 0 ? `선택 작업자 편집 (${n}명)` : '작업자를 선택하세요';
}
// ===== Step 2: Bulk Edit (공통 입력 1개) =====
function openEditMode() {
if (selectedIds.size === 0) return;
const selected = allWorkers.filter(w => selectedIds.has(w.user_id));
document.getElementById('editTitle').textContent = `일괄 편집 (${selected.length}명)`;
// 프로젝트/공종 드롭다운 채우기
const projSel = document.getElementById('bulkProject');
projSel.innerHTML = '<option value="">프로젝트 선택 *</option>' + projects.map(p => `<option value="${p.project_id}">${esc(p.project_name)}</option>`).join('');
const typeSel = document.getElementById('bulkWorkType');
typeSel.innerHTML = '<option value="">공종 선택 *</option>' + workTypes.map(t => `<option value="${t.id}">${esc(t.name)}</option>`).join('');
// 적용 대상 목록
document.getElementById('targetWorkers').innerHTML = selected.map(w =>
`<span class="pi-target-chip">${esc(w.worker_name)}</span>`
).join('');
// 기본값
document.getElementById('bulkHours').value = '8';
document.getElementById('bulkDefect').value = '0';
document.getElementById('bulkNote').value = '';
document.getElementById('step1').classList.add('hidden');
document.getElementById('step2').classList.remove('hidden');
}
function closeEditMode() {
document.getElementById('step2').classList.add('hidden');
document.getElementById('step1').classList.remove('hidden');
}
// ===== Save =====
async function saveAll() {
const projId = document.getElementById('bulkProject').value;
const wtypeId = document.getElementById('bulkWorkType').value;
const hours = parseFloat(document.getElementById('bulkHours').value) || 0;
const defect = parseFloat(document.getElementById('bulkDefect').value) || 0;
const note = document.getElementById('bulkNote').value.trim();
if (!projId || !wtypeId) {
showToast('프로젝트와 공종을 선택하세요', 'error');
return;
}
if (hours <= 0) {
showToast('근무시간을 입력하세요', 'error');
return;
}
if (defect > hours) {
showToast('부적합 시간이 근무시간을 초과합니다', 'error');
return;
}
const defectCategoryId = defect > 0 ? (parseInt(document.getElementById('bulkDefectCategory').value) || null) : null;
const defectItemId = defect > 0 ? (parseInt(document.getElementById('bulkDefectItem').value) || null) : null;
if (defect > 0 && !defectCategoryId) {
showToast('부적합 대분류를 선택하세요', 'error');
return;
}
const btn = document.getElementById('saveBtn');
btn.disabled = true;
document.getElementById('saveBtnText').textContent = '저장 중...';
const entries = Array.from(selectedIds).map(uid => ({
user_id: uid,
project_id: parseInt(projId),
work_type_id: parseInt(wtypeId),
work_hours: hours,
defect_hours: defect,
defect_category_id: defectCategoryId,
defect_item_id: defectItemId,
note: note,
start_time: '08:00',
end_time: '17:00',
work_status_id: defect > 0 ? 2 : 1
}));
try {
const res = await window.apiCall('/proxy-input', 'POST', {
session_date: currentDate,
entries
});
if (res.success) {
showToast(res.message || `${entries.length}명 저장 완료`, 'success');
closeEditMode();
selectedIds.clear();
updateEditButton();
await loadWorkers();
} else {
showToast(res.message || '저장 실패', 'error');
}
} catch (e) {
showToast('저장 실패: ' + (e.message || e), 'error');
}
btn.disabled = false;
document.getElementById('saveBtnText').textContent = '전체 저장';
}
// ===== Defect Category/Item =====
function onDefectChange() {
const val = parseFloat(document.getElementById('bulkDefect').value) || 0;
const row = document.getElementById('defectCategoryRow');
if (val > 0) {
row.classList.remove('hidden');
const catSel = document.getElementById('bulkDefectCategory');
if (catSel.options.length <= 1) {
catSel.innerHTML = '<option value="">부적합 대분류 *</option>' +
defectCategories.map(c => `<option value="${c.category_id}">${esc(c.category_name)}</option>`).join('');
}
} else {
row.classList.add('hidden');
}
}
function onDefectCategoryChange() {
const catId = parseInt(document.getElementById('bulkDefectCategory').value);
const itemSel = document.getElementById('bulkDefectItem');
const cat = defectCategories.find(c => c.category_id === catId);
itemSel.innerHTML = '<option value="">소분류 *</option>' +
(cat ? cat.items.map(i => `<option value="${i.item_id}">${esc(i.item_name)}</option>`).join('') : '');
}
function esc(s) { return (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }

View File

@@ -1,701 +0,0 @@
/* schedule.js — Gantt chart with row virtualization */
let ganttData = { entries: [], dependencies: [], milestones: [] };
let projects = [];
let phases = [];
let templates = [];
let allRows = []; // flat row data for virtualization
let collapseState = {}; // { projectCode: bool }
let ncCache = {}; // { projectId: [issues] }
const ROW_HEIGHT = 32;
const BUFFER_ROWS = 5;
const DAY_WIDTHS = { month: 24, quarter: 12, year: 3 };
let currentZoom = 'quarter';
let currentYear = new Date().getFullYear();
let canEdit = false;
/* ===== Init ===== */
document.addEventListener('DOMContentLoaded', async () => {
const ok = await initAuth();
if (!ok) return;
document.querySelector('.fade-in').classList.add('visible');
// Check edit permission (support_team+)
const role = currentUser?.role || '';
canEdit = ['support_team', 'admin', 'system', 'system admin'].includes(role);
if (canEdit) {
document.getElementById('btnAddEntry').classList.remove('hidden');
document.getElementById('btnBatchAdd').classList.remove('hidden');
document.getElementById('btnAddMilestone').classList.remove('hidden');
const btnGen = document.getElementById('btnGenTemplate');
if (btnGen) btnGen.classList.remove('hidden');
}
// Load collapse state
try {
const saved = localStorage.getItem('gantt_collapse');
if (saved) collapseState = JSON.parse(saved);
} catch {}
// Year select
const sel = document.getElementById('yearSelect');
for (let y = currentYear - 2; y <= currentYear + 2; y++) {
const opt = document.createElement('option');
opt.value = y; opt.textContent = y;
if (y === currentYear) opt.selected = true;
sel.appendChild(opt);
}
sel.addEventListener('change', () => { currentYear = parseInt(sel.value); loadGantt(); });
// Zoom buttons
document.querySelectorAll('.zoom-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.zoom-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentZoom = btn.dataset.zoom;
renderGantt();
});
});
// Toolbar buttons
document.getElementById('btnAddEntry').addEventListener('click', () => openEntryModal());
document.getElementById('btnBatchAdd').addEventListener('click', () => openBatchModal());
document.getElementById('btnAddMilestone').addEventListener('click', () => openMilestoneModal());
await loadMasterData();
await loadGantt();
});
async function loadMasterData() {
try {
const [projRes, phaseRes, tmplRes] = await Promise.all([
api('/projects'), api('/schedule/phases'), api('/schedule/templates')
]);
projects = projRes.data || [];
phases = phaseRes.data || [];
templates = tmplRes.data || [];
} catch (err) { showToast('마스터 데이터 로드 실패', 'error'); }
}
async function loadGantt() {
try {
const res = await api(`/schedule/entries/gantt?year=${currentYear}`);
ganttData = res.data;
// Preload NC data
const projectIds = [...new Set(ganttData.entries.map(e => e.project_id))];
await Promise.all(projectIds.map(async pid => {
try {
const ncRes = await api(`/schedule/nonconformance?project_id=${pid}`);
ncCache[pid] = ncRes.data || [];
} catch { ncCache[pid] = []; }
}));
renderGantt();
} catch (err) { showToast('공정표 데이터 로드 실패: ' + err.message, 'error'); }
}
/* ===== Build flat row array ===== */
function buildRows() {
allRows = [];
// Group entries by project, then by phase
const byProject = {};
ganttData.entries.forEach(e => {
if (!byProject[e.project_code]) byProject[e.project_code] = { project_id: e.project_id, project_name: e.project_name, code: e.project_code, phases: {} };
const p = byProject[e.project_code];
if (!p.phases[e.phase_name]) p.phases[e.phase_name] = { phase_id: e.phase_id, color: e.phase_color, order: e.phase_order, entries: [] };
p.phases[e.phase_name].entries.push(e);
});
// Also add milestones-only projects
ganttData.milestones.forEach(m => {
if (!byProject[m.project_code]) byProject[m.project_code] = { project_id: m.project_id, project_name: m.project_name, code: m.project_code, phases: {} };
});
const sortedProjects = Object.values(byProject).sort((a, b) => a.code.localeCompare(b.code));
for (const proj of sortedProjects) {
const collapsed = collapseState[proj.code] === true;
allRows.push({ type: 'project', code: proj.code, label: `${proj.code} ${proj.project_name}`, project_id: proj.project_id, collapsed });
if (!collapsed) {
const sortedPhases = Object.entries(proj.phases).sort((a, b) => a[1].order - b[1].order);
for (const [phaseName, phaseData] of sortedPhases) {
allRows.push({ type: 'phase', label: phaseName, color: phaseData.color });
for (const entry of phaseData.entries) {
allRows.push({ type: 'task', entry, color: phaseData.color });
}
}
// Milestones for this project
const projMilestones = ganttData.milestones.filter(m => m.project_id === proj.project_id);
if (projMilestones.length > 0) {
allRows.push({ type: 'milestone-header', label: '◆ 마일스톤', milestones: projMilestones });
}
// NC row
const ncList = ncCache[proj.project_id] || [];
if (ncList.length > 0) {
allRows.push({ type: 'nc', label: `⚠ 부적합 (${ncList.length})`, project_id: proj.project_id, count: ncList.length });
}
}
}
}
/* ===== Render ===== */
function renderGantt() {
buildRows();
const container = document.getElementById('ganttContainer');
const wrapper = document.getElementById('ganttWrapper');
const dayWidth = DAY_WIDTHS[currentZoom];
// Calculate total days in year
const yearStart = new Date(currentYear, 0, 1);
const yearEnd = new Date(currentYear, 11, 31);
const totalDays = Math.ceil((yearEnd - yearStart) / 86400000) + 1;
const timelineWidth = totalDays * dayWidth;
container.style.setProperty('--day-width', dayWidth + 'px');
container.style.width = (250 + timelineWidth) + 'px';
// Build month header
let headerHtml = '<div class="gantt-month-header"><div class="gantt-label"><div class="label-content font-semibold text-sm text-gray-600">프로젝트 / 단계 / 작업</div></div><div class="gantt-timeline">';
for (let m = 0; m < 12; m++) {
const daysInMonth = new Date(currentYear, m + 1, 0).getDate();
const monthWidth = daysInMonth * dayWidth;
const monthNames = ['1월','2월','3월','4월','5월','6월','7월','8월','9월','10월','11월','12월'];
headerHtml += `<div class="gantt-day month-label" style="flex: 0 0 ${monthWidth}px;">${monthNames[m]}</div>`;
}
headerHtml += '</div></div>';
// Virtual scroll container
const totalHeight = allRows.length * ROW_HEIGHT;
let rowsHtml = `<div style="height:${totalHeight}px;position:relative;" id="ganttVirtualBody"></div>`;
container.innerHTML = headerHtml + rowsHtml;
// Today marker
const today = new Date();
if (today.getFullYear() === currentYear) {
const todayOffset = dayOfYear(today) - 1;
const markerLeft = 250 + todayOffset * dayWidth;
const marker = document.createElement('div');
marker.className = 'today-marker';
marker.style.left = markerLeft + 'px';
container.appendChild(marker);
}
// Setup virtual scroll
const virtualBody = document.getElementById('ganttVirtualBody');
const onScroll = () => renderVisibleRows(wrapper, virtualBody, dayWidth, totalDays);
wrapper.addEventListener('scroll', onScroll);
renderVisibleRows(wrapper, virtualBody, dayWidth, totalDays);
// Scroll to today
if (today.getFullYear() === currentYear) {
const todayOffset = dayOfYear(today) - 1;
const scrollTo = Math.max(0, todayOffset * dayWidth - wrapper.clientWidth / 2 + 250);
wrapper.scrollLeft = scrollTo;
}
}
function renderVisibleRows(wrapper, virtualBody, dayWidth, totalDays) {
const scrollTop = wrapper.scrollTop - 30; // account for header
const viewHeight = wrapper.clientHeight;
const startIdx = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - BUFFER_ROWS);
const endIdx = Math.min(allRows.length, Math.ceil((scrollTop + viewHeight) / ROW_HEIGHT) + BUFFER_ROWS);
let html = '';
for (let i = startIdx; i < endIdx; i++) {
const row = allRows[i];
const top = i * ROW_HEIGHT;
html += renderRow(row, top, dayWidth, totalDays);
}
virtualBody.innerHTML = html;
}
function renderRow(row, top, dayWidth, totalDays) {
const style = `position:absolute;top:${top}px;width:100%;height:${ROW_HEIGHT}px;`;
if (row.type === 'project') {
const arrowClass = row.collapsed ? 'collapsed' : '';
return `<div class="gantt-row project-row" style="${style}">
<div class="gantt-label"><div class="label-content collapse-toggle ${arrowClass}" onclick="toggleProject('${row.code}')">
<span class="arrow">▼</span>${escapeHtml(row.label)}
</div></div>
<div class="gantt-timeline" style="width:${totalDays * dayWidth}px;position:relative;"></div>
</div>`;
}
if (row.type === 'phase') {
return `<div class="gantt-row phase-row" style="${style}">
<div class="gantt-label"><div class="label-content"><span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:${row.color};margin-right:6px;"></span>${escapeHtml(row.label)}</div></div>
<div class="gantt-timeline" style="width:${totalDays * dayWidth}px;"></div>
</div>`;
}
if (row.type === 'task') {
const e = row.entry;
const bar = calcBar(e.start_date, e.end_date, dayWidth);
const statusColors = { planned: '0.6', in_progress: '0.85', completed: '1', delayed: '0.9' };
const opacity = statusColors[e.status] || '0.7';
const barHtml = bar ? `<div class="gantt-bar" style="left:${bar.left}px;width:${bar.width}px;background:${row.color};opacity:${opacity};"
onclick="showBarDetail(${e.entry_id})" title="${escapeHtml(e.task_name)}&#10;${formatDate(e.start_date)}~${formatDate(e.end_date)}&#10;진행률: ${e.progress}%">
<div class="gantt-bar-progress" style="width:${e.progress}%;background:#fff;"></div>
${bar.width > 50 ? `<div class="gantt-bar-label">${escapeHtml(e.task_name)}</div>` : ''}
</div>` : '';
return `<div class="gantt-row task-row" style="${style}">
<div class="gantt-label"><div class="label-content" title="${escapeHtml(e.task_name)}">${escapeHtml(e.task_name)}${e.assignee ? ` <span class="text-gray-400 text-xs">(${escapeHtml(e.assignee)})</span>` : ''}</div></div>
<div class="gantt-timeline" style="width:${totalDays * dayWidth}px;position:relative;">${barHtml}</div>
</div>`;
}
if (row.type === 'milestone-header') {
let markers = '';
for (const m of row.milestones) {
const pos = calcPos(m.milestone_date, dayWidth);
if (pos !== null) {
const mColor = m.status === 'completed' ? '#10B981' : m.status === 'missed' ? '#EF4444' : '#7C3AED';
markers += `<div class="milestone-marker" style="left:${pos - 7}px;background:${mColor};" title="${escapeHtml(m.milestone_name)}&#10;${formatDate(m.milestone_date)}" onclick="showMilestoneDetail(${m.milestone_id})"></div>`;
}
}
return `<div class="gantt-row milestone-row" style="${style}">
<div class="gantt-label"><div class="label-content">${escapeHtml(row.label)}</div></div>
<div class="gantt-timeline" style="width:${totalDays * dayWidth}px;position:relative;">${markers}</div>
</div>`;
}
if (row.type === 'nc') {
// Place NC badges by date
const ncList = ncCache[row.project_id] || [];
let badges = '';
const byMonth = {};
ncList.forEach(nc => {
const d = nc.report_date ? new Date(nc.report_date) : null;
if (d && d.getFullYear() === currentYear) {
const m = d.getMonth();
byMonth[m] = (byMonth[m] || 0) + 1;
}
});
for (const [m, cnt] of Object.entries(byMonth)) {
const monthStart = new Date(currentYear, parseInt(m), 15);
const pos = calcPos(monthStart, dayWidth);
if (pos !== null) {
badges += `<div class="nc-badge" style="left:${pos - 10}px;" onclick="showNcPopup(${row.project_id})">${cnt}</div>`;
}
}
return `<div class="gantt-row nc-row" style="${style}">
<div class="gantt-label"><div class="label-content" style="cursor:pointer;" onclick="showNcPopup(${row.project_id})">${escapeHtml(row.label)}</div></div>
<div class="gantt-timeline" style="width:${totalDays * dayWidth}px;position:relative;">${badges}</div>
</div>`;
}
return '';
}
/* ===== Helpers ===== */
function dayOfYear(d) {
const start = new Date(d.getFullYear(), 0, 1);
return Math.ceil((d - start) / 86400000) + 1;
}
function calcBar(startStr, endStr, dayWidth) {
const s = new Date(startStr);
const e = new Date(endStr);
if (s.getFullYear() > currentYear || e.getFullYear() < currentYear) return null;
const yearStart = new Date(currentYear, 0, 1);
const yearEnd = new Date(currentYear, 11, 31);
const clampStart = s < yearStart ? yearStart : s;
const clampEnd = e > yearEnd ? yearEnd : e;
const startDay = Math.ceil((clampStart - yearStart) / 86400000);
const endDay = Math.ceil((clampEnd - yearStart) / 86400000) + 1;
return { left: startDay * dayWidth, width: Math.max((endDay - startDay) * dayWidth, 4) };
}
function calcPos(dateStr, dayWidth) {
const d = new Date(dateStr);
if (d.getFullYear() !== currentYear) return null;
const yearStart = new Date(currentYear, 0, 1);
const offset = Math.ceil((d - yearStart) / 86400000);
return offset * dayWidth;
}
/* ===== Interactions ===== */
function toggleProject(code) {
collapseState[code] = !collapseState[code];
localStorage.setItem('gantt_collapse', JSON.stringify(collapseState));
renderGantt();
}
function showBarDetail(entryId) {
const entry = ganttData.entries.find(e => e.entry_id === entryId);
if (!entry) return;
const popup = document.getElementById('barDetailPopup');
document.getElementById('barDetailTitle').textContent = entry.task_name;
const statusLabels = { planned: '계획', in_progress: '진행중', completed: '완료', delayed: '지연', cancelled: '취소' };
document.getElementById('barDetailContent').innerHTML = `
<div class="space-y-2 text-sm">
<div class="flex justify-between"><span class="text-gray-500">프로젝트</span><span>${escapeHtml(entry.project_code)} ${escapeHtml(entry.project_name)}</span></div>
<div class="flex justify-between"><span class="text-gray-500">공정 단계</span><span>${escapeHtml(entry.phase_name)}</span></div>
<div class="flex justify-between"><span class="text-gray-500">기간</span><span>${formatDate(entry.start_date)} ~ ${formatDate(entry.end_date)}</span></div>
<div class="flex justify-between"><span class="text-gray-500">진행률</span><span>${entry.progress}%</span></div>
<div class="flex justify-between"><span class="text-gray-500">상태</span><span>${statusLabels[entry.status] || entry.status}</span></div>
${entry.assignee ? `<div class="flex justify-between"><span class="text-gray-500">담당자</span><span>${escapeHtml(entry.assignee)}</span></div>` : ''}
${entry.notes ? `<div><span class="text-gray-500">메모:</span> ${escapeHtml(entry.notes)}</div>` : ''}
</div>
`;
let actions = '';
if (canEdit) {
actions = `<button onclick="document.getElementById('barDetailPopup').classList.add('hidden');openEntryModal(${entryId})" class="px-3 py-1.5 text-sm bg-orange-600 text-white rounded-lg hover:bg-orange-700">수정</button>`;
}
document.getElementById('barDetailActions').innerHTML = actions;
popup.classList.remove('hidden');
}
function showMilestoneDetail(milestoneId) {
const m = ganttData.milestones.find(ms => ms.milestone_id === milestoneId);
if (!m) return;
const popup = document.getElementById('barDetailPopup');
const typeLabels = { deadline: '납기', review: '검토', inspection: '검사', delivery: '출하', meeting: '회의', other: '기타' };
const statusLabels = { upcoming: '예정', completed: '완료', missed: '미달성' };
document.getElementById('barDetailTitle').textContent = '◆ ' + m.milestone_name;
document.getElementById('barDetailContent').innerHTML = `
<div class="space-y-2 text-sm">
<div class="flex justify-between"><span class="text-gray-500">프로젝트</span><span>${escapeHtml(m.project_code)} ${escapeHtml(m.project_name)}</span></div>
<div class="flex justify-between"><span class="text-gray-500">날짜</span><span>${formatDate(m.milestone_date)}</span></div>
<div class="flex justify-between"><span class="text-gray-500">유형</span><span>${typeLabels[m.milestone_type] || m.milestone_type}</span></div>
<div class="flex justify-between"><span class="text-gray-500">상태</span><span>${statusLabels[m.status] || m.status}</span></div>
${m.notes ? `<div><span class="text-gray-500">메모:</span> ${escapeHtml(m.notes)}</div>` : ''}
</div>
`;
let actions = '';
if (canEdit) {
actions = `<button onclick="document.getElementById('barDetailPopup').classList.add('hidden');openMilestoneModal(${milestoneId})" class="px-3 py-1.5 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700">수정</button>`;
}
document.getElementById('barDetailActions').innerHTML = actions;
popup.classList.remove('hidden');
}
function showNcPopup(projectId) {
const list = ncCache[projectId] || [];
const proj = projects.find(p => p.project_id === projectId);
document.getElementById('ncPopupTitle').textContent = `부적합 현황 - ${proj ? proj.job_no : ''}`;
const statusLabels = { reported: '신고', received: '접수', reviewing: '검토중', in_progress: '처리중', completed: '완료' };
let html = '';
if (list.length === 0) {
html = '<p class="text-gray-500 text-sm">부적합 내역이 없습니다.</p>';
} else {
html = '<table class="data-table"><thead><tr><th>일자</th><th>분류</th><th>내용</th><th>상태</th></tr></thead><tbody>';
for (const nc of list) {
html += `<tr>
<td>${formatDate(nc.report_date)}</td>
<td>${escapeHtml(nc.category || '-')}</td>
<td class="max-w-[200px] truncate">${escapeHtml(nc.description || '-')}</td>
<td><span class="badge ${nc.review_status === 'completed' ? 'badge-green' : 'badge-amber'}">${statusLabels[nc.review_status] || nc.review_status}</span></td>
</tr>`;
}
html += '</tbody></table>';
}
document.getElementById('ncPopupContent').innerHTML = html;
document.getElementById('ncPopup').classList.remove('hidden');
}
/* ===== Entry Modal ===== */
function openEntryModal(editId) {
const modal = document.getElementById('entryModal');
const isEdit = !!editId;
document.getElementById('entryModalTitle').textContent = isEdit ? '공정표 항목 수정' : '공정표 항목 추가';
// Populate dropdowns
populateSelect('entryProject', projects, 'project_id', p => `${p.job_no} ${p.project_name}`);
populateSelect('entryPhase', phases, 'phase_id', p => p.phase_name);
// Template select (populated on phase change)
const phaseSelect = document.getElementById('entryPhase');
phaseSelect.addEventListener('change', () => loadTemplateOptions('entryTemplate', phaseSelect.value));
if (phases.length > 0) loadTemplateOptions('entryTemplate', phaseSelect.value);
// Template → task name
document.getElementById('entryTemplate').addEventListener('change', function() {
if (this.value) {
const tmpl = templates.find(t => t.template_id === parseInt(this.value));
if (tmpl) {
document.getElementById('entryTaskName').value = tmpl.task_name;
// Auto-fill duration
const startDate = document.getElementById('entryStartDate').value;
if (startDate && tmpl.default_duration_days) {
const end = new Date(startDate);
end.setDate(end.getDate() + tmpl.default_duration_days);
document.getElementById('entryEndDate').value = end.toISOString().split('T')[0];
}
}
}
});
// Dependencies (all entries for the selected project)
const depSelect = document.getElementById('entryDependencies');
depSelect.innerHTML = '';
if (isEdit) {
const entry = ganttData.entries.find(e => e.entry_id === editId);
if (!entry) return;
document.getElementById('entryId').value = editId;
document.getElementById('entryProject').value = entry.project_id;
document.getElementById('entryPhase').value = entry.phase_id;
document.getElementById('entryTaskName').value = entry.task_name;
document.getElementById('entryStartDate').value = formatDate(entry.start_date);
document.getElementById('entryEndDate').value = formatDate(entry.end_date);
document.getElementById('entryAssignee').value = entry.assignee || '';
document.getElementById('entryProgress').value = entry.progress;
document.getElementById('entryStatus').value = entry.status;
document.getElementById('entryNotes').value = entry.notes || '';
// Load dependencies
const projEntries = ganttData.entries.filter(e => e.project_id === entry.project_id && e.entry_id !== editId);
const deps = ganttData.dependencies.filter(d => d.entry_id === editId).map(d => d.depends_on_entry_id);
projEntries.forEach(e => {
const opt = document.createElement('option');
opt.value = e.entry_id;
opt.textContent = `${e.phase_name} > ${e.task_name}`;
opt.selected = deps.includes(e.entry_id);
depSelect.appendChild(opt);
});
} else {
document.getElementById('entryId').value = '';
document.getElementById('entryTaskName').value = '';
document.getElementById('entryStartDate').value = '';
document.getElementById('entryEndDate').value = '';
document.getElementById('entryAssignee').value = '';
document.getElementById('entryProgress').value = '0';
document.getElementById('entryStatus').value = 'planned';
document.getElementById('entryNotes').value = '';
}
modal.classList.remove('hidden');
}
function closeEntryModal() { document.getElementById('entryModal').classList.add('hidden'); }
async function saveEntry() {
const entryId = document.getElementById('entryId').value;
const taskName = document.getElementById('entryTaskName').value.trim();
if (!taskName) { showToast('작업명을 입력해주세요.', 'error'); return; }
const data = {
project_id: document.getElementById('entryProject').value,
phase_id: document.getElementById('entryPhase').value,
task_name: taskName,
start_date: document.getElementById('entryStartDate').value,
end_date: document.getElementById('entryEndDate').value,
assignee: document.getElementById('entryAssignee').value || null,
progress: parseInt(document.getElementById('entryProgress').value) || 0,
status: document.getElementById('entryStatus').value,
notes: document.getElementById('entryNotes').value || null
};
try {
if (entryId) {
await api(`/schedule/entries/${entryId}`, { method: 'PUT', body: JSON.stringify(data) });
// Update dependencies
const depSelect = document.getElementById('entryDependencies');
const selectedDeps = Array.from(depSelect.selectedOptions).map(o => parseInt(o.value));
const existingDeps = ganttData.dependencies.filter(d => d.entry_id === parseInt(entryId)).map(d => d.depends_on_entry_id);
// Add new
for (const depId of selectedDeps) {
if (!existingDeps.includes(depId)) {
await api(`/schedule/entries/${entryId}/dependencies`, { method: 'POST', body: JSON.stringify({ depends_on_entry_id: depId }) });
}
}
// Remove old
for (const depId of existingDeps) {
if (!selectedDeps.includes(depId)) {
await api(`/schedule/entries/${entryId}/dependencies/${depId}`, { method: 'DELETE' });
}
}
showToast('공정표 항목이 수정되었습니다.');
} else {
const res = await api('/schedule/entries', { method: 'POST', body: JSON.stringify(data) });
// Add dependencies for new entry
const depSelect = document.getElementById('entryDependencies');
const selectedDeps = Array.from(depSelect.selectedOptions).map(o => parseInt(o.value));
for (const depId of selectedDeps) {
await api(`/schedule/entries/${res.data.entry_id}/dependencies`, { method: 'POST', body: JSON.stringify({ depends_on_entry_id: depId }) });
}
showToast('공정표 항목이 추가되었습니다.');
}
closeEntryModal();
await loadGantt();
} catch (err) { showToast(err.message, 'error'); }
}
/* ===== Batch Modal ===== */
function openBatchModal() {
populateSelect('batchProject', projects, 'project_id', p => `${p.job_no} ${p.project_name}`);
populateSelect('batchPhase', phases, 'phase_id', p => p.phase_name);
document.getElementById('batchStartDate').value = '';
document.getElementById('batchTemplateList').innerHTML = '';
loadBatchTemplates();
document.getElementById('batchModal').classList.remove('hidden');
}
function closeBatchModal() { document.getElementById('batchModal').classList.add('hidden'); }
function loadBatchTemplates() {
const phaseId = parseInt(document.getElementById('batchPhase').value);
const filtered = templates.filter(t => t.phase_id === phaseId);
const list = document.getElementById('batchTemplateList');
if (filtered.length === 0) { list.innerHTML = '<p class="text-gray-500 text-sm">해당 단계에 템플릿이 없습니다.</p>'; return; }
list.innerHTML = filtered.map((t, i) => `
<div class="flex items-center gap-3 p-2 bg-gray-50 rounded-lg">
<input type="checkbox" id="btmpl_${t.template_id}" checked class="w-4 h-4">
<span class="flex-1 text-sm">${escapeHtml(t.task_name)}</span>
<span class="text-xs text-gray-400">${t.default_duration_days}일</span>
<input type="date" id="btmpl_start_${t.template_id}" class="input-field rounded px-2 py-1 text-xs w-32">
<span class="text-xs text-gray-400">~</span>
<input type="date" id="btmpl_end_${t.template_id}" class="input-field rounded px-2 py-1 text-xs w-32">
</div>
`).join('');
recalcBatchDates();
}
function recalcBatchDates() {
const baseStart = document.getElementById('batchStartDate').value;
if (!baseStart) return;
const phaseId = parseInt(document.getElementById('batchPhase').value);
const filtered = templates.filter(t => t.phase_id === phaseId);
let cursor = new Date(baseStart);
for (const t of filtered) {
const startEl = document.getElementById(`btmpl_start_${t.template_id}`);
const endEl = document.getElementById(`btmpl_end_${t.template_id}`);
if (startEl && endEl) {
startEl.value = cursor.toISOString().split('T')[0];
const endDate = new Date(cursor);
endDate.setDate(endDate.getDate() + t.default_duration_days);
endEl.value = endDate.toISOString().split('T')[0];
cursor = new Date(endDate);
}
}
}
async function saveBatchEntries() {
const projectId = document.getElementById('batchProject').value;
const phaseId = document.getElementById('batchPhase').value;
const filtered = templates.filter(t => t.phase_id === parseInt(phaseId));
const entries = [];
for (const t of filtered) {
const cb = document.getElementById(`btmpl_${t.template_id}`);
if (!cb || !cb.checked) continue;
const startDate = document.getElementById(`btmpl_start_${t.template_id}`)?.value;
const endDate = document.getElementById(`btmpl_end_${t.template_id}`)?.value;
if (!startDate || !endDate) { showToast(`${t.task_name}의 날짜를 입력해주세요.`, 'error'); return; }
entries.push({ task_name: t.task_name, start_date: startDate, end_date: endDate, display_order: t.display_order });
}
if (entries.length === 0) { showToast('추가할 항목이 없습니다.', 'error'); return; }
try {
await api('/schedule/entries/batch', { method: 'POST', body: JSON.stringify({ project_id: projectId, phase_id: phaseId, entries }) });
showToast(`${entries.length}개 항목이 일괄 추가되었습니다.`);
closeBatchModal();
await loadGantt();
} catch (err) { showToast(err.message, 'error'); }
}
/* ===== Milestone Modal ===== */
function openMilestoneModal(editId) {
const modal = document.getElementById('milestoneModal');
const isEdit = !!editId;
document.getElementById('milestoneModalTitle').textContent = isEdit ? '마일스톤 수정' : '마일스톤 추가';
populateSelect('milestoneProject', projects, 'project_id', p => `${p.job_no} ${p.project_name}`);
if (isEdit) {
const m = ganttData.milestones.find(ms => ms.milestone_id === editId);
if (!m) return;
document.getElementById('milestoneId').value = editId;
document.getElementById('milestoneProject').value = m.project_id;
document.getElementById('milestoneName').value = m.milestone_name;
document.getElementById('milestoneDate').value = formatDate(m.milestone_date);
document.getElementById('milestoneType').value = m.milestone_type;
document.getElementById('milestoneStatus').value = m.status;
document.getElementById('milestoneNotes').value = m.notes || '';
// Load entry options for project
loadMilestoneEntries(m.project_id, m.entry_id);
} else {
document.getElementById('milestoneId').value = '';
document.getElementById('milestoneName').value = '';
document.getElementById('milestoneDate').value = '';
document.getElementById('milestoneType').value = 'deadline';
document.getElementById('milestoneStatus').value = 'upcoming';
document.getElementById('milestoneNotes').value = '';
document.getElementById('milestoneEntry').innerHTML = '<option value="">없음</option>';
}
// Update entry list on project change
document.getElementById('milestoneProject').onchange = function() { loadMilestoneEntries(this.value); };
modal.classList.remove('hidden');
}
function loadMilestoneEntries(projectId, selectedEntryId) {
const sel = document.getElementById('milestoneEntry');
sel.innerHTML = '<option value="">없음</option>';
const projEntries = ganttData.entries.filter(e => e.project_id === parseInt(projectId));
projEntries.forEach(e => {
const opt = document.createElement('option');
opt.value = e.entry_id;
opt.textContent = `${e.phase_name} > ${e.task_name}`;
if (selectedEntryId && e.entry_id === selectedEntryId) opt.selected = true;
sel.appendChild(opt);
});
}
function closeMilestoneModal() { document.getElementById('milestoneModal').classList.add('hidden'); }
async function saveMilestone() {
const milestoneId = document.getElementById('milestoneId').value;
const data = {
project_id: document.getElementById('milestoneProject').value,
milestone_name: document.getElementById('milestoneName').value.trim(),
milestone_date: document.getElementById('milestoneDate').value,
milestone_type: document.getElementById('milestoneType').value,
status: document.getElementById('milestoneStatus').value,
entry_id: document.getElementById('milestoneEntry').value || null,
notes: document.getElementById('milestoneNotes').value || null
};
if (!data.milestone_name || !data.milestone_date) { showToast('마일스톤명과 날짜를 입력해주세요.', 'error'); return; }
try {
if (milestoneId) {
await api(`/schedule/milestones/${milestoneId}`, { method: 'PUT', body: JSON.stringify(data) });
showToast('마일스톤이 수정되었습니다.');
} else {
await api('/schedule/milestones', { method: 'POST', body: JSON.stringify(data) });
showToast('마일스톤이 추가되었습니다.');
}
closeMilestoneModal();
await loadGantt();
} catch (err) { showToast(err.message, 'error'); }
}
/* ===== Utility ===== */
function populateSelect(selectId, items, valueField, labelFn) {
const sel = document.getElementById(selectId);
const oldVal = sel.value;
sel.innerHTML = items.map(item => `<option value="${item[valueField]}">${escapeHtml(labelFn(item))}</option>`).join('');
if (oldVal && sel.querySelector(`option[value="${oldVal}"]`)) sel.value = oldVal;
}
function loadTemplateOptions(selectId, phaseId) {
const sel = document.getElementById(selectId);
sel.innerHTML = '<option value="">직접 입력</option>';
templates.filter(t => t.phase_id === parseInt(phaseId)).forEach(t => {
sel.innerHTML += `<option value="${t.template_id}">${escapeHtml(t.task_name)} (${t.default_duration_days}일)</option>`;
});
}

View File

@@ -1,39 +0,0 @@
/**
* SSO Token Relay — 인앱 브라우저(카카오톡 등) 서브도메인 쿠키 미공유 대응
*
* Canonical source: shared/frontend/sso-relay.js
* 전 서비스 동일 코드 — 수정 시 아래 파일 <20><><EFBFBD>체 갱신 필요:
* system1-factory/web/js/sso-relay.js
* system2-report/web/js/sso-relay.js
* system3-nonconformance/web/static/js/sso-relay.js
* user-management/web/static/js/sso-relay.js
* tkpurchase/web/static/js/sso-relay.js
* tksafety/web/static/js/sso-relay.js
* tksupport/web/static/js/sso-relay.js
*
* 동작: URL hash에 _sso= 파라미터가 있으면 토큰을 로컬 쿠키+localStorage에 설정하고 hash를 제거.
* gateway/dashboard.html에서 로그인 성공 후 redirect URL에 #_sso=<token>을 붙여 전달.
*/
(function() {
var hash = location.hash;
if (!hash || hash.indexOf('_sso=') === -1) return;
var match = hash.match(/[#&]_sso=([^&]*)/);
if (!match) return;
var token = decodeURIComponent(match[1]);
if (!token) return;
// 로컬(1st-party) 쿠키 설정
var cookie = 'sso_token=' + encodeURIComponent(token) + '; path=/; max-age=604800';
if (location.hostname.indexOf('technicalkorea.net') !== -1) {
cookie += '; domain=.technicalkorea.net; secure; samesite=lax';
}
document.cookie = cookie;
// localStorage 폴백
try { localStorage.setItem('sso_token', token); } catch (e) {}
// URL에서 hash 제거
history.replaceState(null, '', location.pathname + location.search);
})();

View File

@@ -1,885 +0,0 @@
// System Dashboard JavaScript
import { apiRequest } from './api-helper.js';
import { getCurrentUser } from './auth.js';
// 전역 변수
let systemData = {
users: [],
logs: [],
systemStatus: {}
};
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
initializeSystemDashboard();
setupEventListeners();
});
// Setup event listeners
function setupEventListeners() {
// Add event listeners to all data-action buttons
const actionButtons = document.querySelectorAll('[data-action]');
actionButtons.forEach(button => {
const action = button.getAttribute('data-action');
switch(action) {
case 'account-management':
button.addEventListener('click', openAccountManagement);
break;
case 'system-logs':
button.addEventListener('click', openSystemLogs);
break;
case 'database-management':
button.addEventListener('click', openDatabaseManagement);
break;
case 'system-settings':
button.addEventListener('click', openSystemSettings);
break;
case 'backup-management':
button.addEventListener('click', openBackupManagement);
break;
case 'monitoring':
button.addEventListener('click', openMonitoring);
break;
case 'close-modal':
button.addEventListener('click', () => closeModal('account-modal'));
break;
}
});
}
// Initialize system dashboard
async function initializeSystemDashboard() {
try {
// Load user info
await loadUserInfo();
// Load system status
await loadSystemStatus();
// Load user statistics
await loadUserStats();
// Load recent activities
await loadRecentActivities();
// Setup auto-refresh (every 30 seconds)
setInterval(refreshSystemStatus, 30000);
} catch (error) {
console.error(' System dashboard initialization error:', error);
showNotification('Error loading system dashboard', 'error');
}
}
// 사용자 정보 로드
async function loadUserInfo() {
try {
const user = getCurrentUser();
if (user && user.name) {
document.getElementById('user-name').textContent = user.name;
}
} catch (error) {
console.error('사용자 정보 로드 오류:', error);
}
}
// 시스템 상태 로드
async function loadSystemStatus() {
try {
// 서버 상태 확인
const serverStatus = await checkServerStatus();
updateServerStatus(serverStatus);
// 데이터베이스 상태 확인
const dbStatus = await checkDatabaseStatus();
updateDatabaseStatus(dbStatus);
// 시스템 알림 확인
const alerts = await getSystemAlerts();
updateSystemAlerts(alerts);
} catch (error) {
console.error('시스템 상태 로드 오류:', error);
}
}
// 서버 상태 확인
async function checkServerStatus() {
try {
const response = await apiRequest('/api/system/status', 'GET');
return response.success ? 'online' : 'offline';
} catch (error) {
return 'offline';
}
}
// 데이터베이스 상태 확인
async function checkDatabaseStatus() {
try {
const response = await apiRequest('/api/system/db-status', 'GET');
return response;
} catch (error) {
return { status: 'error', connections: 0 };
}
}
// 시스템 알림 가져오기
async function getSystemAlerts() {
try {
const response = await apiRequest('/api/system/alerts', 'GET');
return response.alerts || [];
} catch (error) {
return [];
}
}
// 서버 상태 업데이트
function updateServerStatus(status) {
const serverCheckTime = document.getElementById('server-check-time');
const statusElements = document.querySelectorAll('.status-value');
if (serverCheckTime) {
serverCheckTime.textContent = new Date().toLocaleTimeString('ko-KR');
}
// 서버 상태 표시 업데이트 로직 추가
}
// 데이터베이스 상태 업데이트
function updateDatabaseStatus(dbStatus) {
const dbConnections = document.getElementById('db-connections');
if (dbConnections && dbStatus.connections !== undefined) {
dbConnections.textContent = dbStatus.connections;
}
}
// 시스템 알림 업데이트
function updateSystemAlerts(alerts) {
const systemAlerts = document.getElementById('system-alerts');
if (systemAlerts) {
systemAlerts.textContent = alerts.length;
systemAlerts.className = `status-value ${alerts.length > 0 ? 'warning' : 'online'}`;
}
}
// 사용자 통계 로드
async function loadUserStats() {
try {
const response = await apiRequest('/api/system/users/stats', 'GET');
if (response.success) {
const activeUsers = document.getElementById('active-users');
const totalUsers = document.getElementById('total-users');
if (activeUsers) activeUsers.textContent = response.data.active || 0;
if (totalUsers) totalUsers.textContent = response.data.total || 0;
}
} catch (error) {
console.error('사용자 통계 로드 오류:', error);
}
}
// 최근 활동 로드
async function loadRecentActivities() {
try {
const response = await apiRequest('/api/system/recent-activities', 'GET');
if (response.success && response.data) {
displayRecentActivities(response.data);
}
} catch (error) {
console.error('최근 활동 로드 오류:', error);
displayDefaultActivities();
}
}
// 최근 활동 표시
function displayRecentActivities(activities) {
const container = document.getElementById('recent-activities');
if (!container) return;
if (!activities || activities.length === 0) {
container.innerHTML = '<p style="text-align: center; color: #7f8c8d; padding: 2rem;">최근 활동이 없습니다.</p>';
return;
}
const html = activities.map(activity => `
<div class="activity-item">
<div class="activity-icon">
<i class="fas ${getActivityIcon(activity.type)}"></i>
</div>
<div class="activity-info">
<h4>${activity.title}</h4>
<p>${activity.description}</p>
</div>
<div class="activity-time">
${formatTimeAgo(activity.created_at)}
</div>
</div>
`).join('');
container.innerHTML = html;
}
// 기본 활동 표시 (데이터 로드 실패 시)
function displayDefaultActivities() {
const container = document.getElementById('recent-activities');
if (!container) return;
const defaultActivities = [
{
type: 'system',
title: '시스템 시작',
description: '시스템이 정상적으로 시작되었습니다.',
created_at: new Date().toISOString()
}
];
displayRecentActivities(defaultActivities);
}
// 활동 타입에 따른 아이콘 반환
function getActivityIcon(type) {
const icons = {
'login': 'fa-sign-in-alt',
'user_create': 'fa-user-plus',
'user_update': 'fa-user-edit',
'user_delete': 'fa-user-minus',
'system': 'fa-cog',
'database': 'fa-database',
'backup': 'fa-download',
'error': 'fa-exclamation-triangle'
};
return icons[type] || 'fa-info-circle';
}
// 시간 포맷팅 (몇 분 전, 몇 시간 전 등)
function formatTimeAgo(dateString) {
const now = new Date();
const date = new Date(dateString);
const diffInSeconds = Math.floor((now - date) / 1000);
if (diffInSeconds < 60) {
return '방금 전';
} else if (diffInSeconds < 3600) {
return `${Math.floor(diffInSeconds / 60)}분 전`;
} else if (diffInSeconds < 86400) {
return `${Math.floor(diffInSeconds / 3600)}시간 전`;
} else {
return `${Math.floor(diffInSeconds / 86400)}일 전`;
}
}
// 시스템 상태 새로고침
async function refreshSystemStatus() {
try {
await loadSystemStatus();
await loadUserStats();
} catch (error) {
console.error('시스템 상태 새로고침 오류:', error);
}
}
// Open account management
function openAccountManagement() {
const modal = document.getElementById('account-modal');
const content = document.getElementById('account-management-content');
console.log('Modal element:', modal);
console.log('Content element:', content);
if (modal && content) {
// Load account management content
loadAccountManagementContent(content);
modal.style.display = 'block';
} else {
console.error(' Modal or content element not found');
}
}
// 계정 관리 컨텐츠 로드
async function loadAccountManagementContent(container) {
try {
container.innerHTML = `
<div class="loading-spinner">
<i class="fas fa-spinner fa-spin"></i> 로딩 중...
</div>
`;
// 사용자 목록 로드
const response = await apiRequest('/api/system/users', 'GET');
if (response.success) {
displayAccountManagement(container, response.data);
} else {
throw new Error(response.error || '사용자 목록을 불러올 수 없습니다.');
}
} catch (error) {
console.error('계정 관리 컨텐츠 로드 오류:', error);
container.innerHTML = `
<div class="error-message">
<i class="fas fa-exclamation-triangle"></i>
<p>계정 정보를 불러오는 중 오류가 발생했습니다.</p>
<button class="btn btn-primary" onclick="loadAccountManagementContent(document.getElementById('account-management-content'))">
다시 시도
</button>
</div>
`;
}
}
// 계정 관리 화면 표시
function displayAccountManagement(container, users) {
const html = `
<div class="account-management">
<div class="account-header">
<h4><i class="fas fa-users"></i> 사용자 계정 관리</h4>
<button class="btn btn-primary" onclick="openCreateUserForm()">
<i class="fas fa-plus"></i> 새 사용자
</button>
</div>
<div class="account-filters">
<input type="text" id="user-search" placeholder="사용자 검색..." onkeyup="filterUsers()">
<select id="role-filter" onchange="filterUsers()">
<option value="">모든 권한</option>
<option value="system">시스템</option>
<option value="admin">관리자</option>
<option value="leader">그룹장</option>
<option value="user">사용자</option>
</select>
</div>
<div class="users-table">
<table>
<thead>
<tr>
<th>ID</th>
<th>사용자명</th>
<th>이름</th>
<th>권한</th>
<th>상태</th>
<th>마지막 로그인</th>
<th>작업</th>
</tr>
</thead>
<tbody id="users-tbody">
${generateUsersTableRows(users)}
</tbody>
</table>
</div>
</div>
`;
container.innerHTML = html;
systemData.users = users;
}
// 사용자 테이블 행 생성
function generateUsersTableRows(users) {
if (!users || users.length === 0) {
return '<tr><td colspan="7" style="text-align: center; padding: 2rem;">등록된 사용자가 없습니다.</td></tr>';
}
return users.map(user => `
<tr data-user-id="${user.user_id}">
<td>${user.user_id}</td>
<td>${user.username}</td>
<td>${user.name || '-'}</td>
<td>
<span class="role-badge role-${user.role}">
${getRoleDisplayName(user.role)}
</span>
</td>
<td>
<span class="status-badge ${user.is_active ? 'active' : 'inactive'}">
${user.is_active ? '활성' : '비활성'}
</span>
</td>
<td>${user.last_login_at ? formatDate(user.last_login_at) : '없음'}</td>
<td class="action-buttons">
<button class="btn-small btn-edit" onclick="editUser(${user.user_id})" title="수정">
<i class="fas fa-edit"></i>
</button>
<button class="btn-small btn-delete" onclick="deleteUser(${user.user_id})" title="삭제">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
`).join('');
}
// 권한 표시명 반환
function getRoleDisplayName(role) {
const roleNames = {
'system': '시스템',
'admin': '관리자',
'leader': '그룹장',
'user': '사용자'
};
return roleNames[role] || role;
}
// 날짜 포맷팅
function formatDate(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleString('ko-KR');
}
// 시스템 로그 열기
function openSystemLogs() {
console.log('시스템 로그 버튼 클릭됨');
const modal = document.getElementById('account-modal');
const content = document.getElementById('account-management-content');
if (modal && content) {
loadSystemLogsContent(content);
modal.style.display = 'block';
}
}
// 시스템 로그 컨텐츠 로드
async function loadSystemLogsContent(container) {
try {
container.innerHTML = `
<div class="system-logs">
<h4><i class="fas fa-file-alt"></i> 시스템 로그</h4>
<div class="log-filters">
<select id="log-type-filter">
<option value="">모든 로그</option>
<option value="login">로그인</option>
<option value="activity">활동</option>
<option value="error">오류</option>
</select>
<input type="date" id="log-date-filter">
<button class="btn btn-primary" onclick="filterLogs()">
<i class="fas fa-search"></i> 검색
</button>
</div>
<div class="logs-container">
<div class="loading-spinner">
<i class="fas fa-spinner fa-spin"></i> 로그 로딩 중...
</div>
</div>
</div>
`;
// 로그 데이터 로드
await loadLogsData();
} catch (error) {
console.error('시스템 로그 로드 오류:', error);
container.innerHTML = `
<div class="error-message">
<i class="fas fa-exclamation-triangle"></i>
<p>시스템 로그를 불러오는 중 오류가 발생했습니다.</p>
</div>
`;
}
}
// 로그 데이터 로드
async function loadLogsData() {
try {
const response = await apiRequest('/api/system/logs/activity', 'GET');
const logsContainer = document.querySelector('.logs-container');
if (response.success && response.data) {
displayLogs(response.data, logsContainer);
} else {
logsContainer.innerHTML = '<p>로그 데이터가 없습니다.</p>';
}
} catch (error) {
console.error('로그 데이터 로드 오류:', error);
document.querySelector('.logs-container').innerHTML = '<p>로그 데이터를 불러올 수 없습니다.</p>';
}
}
// 로그 표시
function displayLogs(logs, container) {
if (!logs || logs.length === 0) {
container.innerHTML = '<p>표시할 로그가 없습니다.</p>';
return;
}
const html = `
<table class="logs-table">
<thead>
<tr>
<th>시간</th>
<th>유형</th>
<th>사용자</th>
<th>내용</th>
</tr>
</thead>
<tbody>
${logs.map(log => `
<tr>
<td>${formatDate(log.created_at)}</td>
<td><span class="log-type ${log.type}">${log.type}</span></td>
<td>${log.username || '-'}</td>
<td>${log.description}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
container.innerHTML = html;
}
// 로그 필터링
function filterLogs() {
console.log('로그 필터링 실행');
// 실제 구현은 추후 추가
showNotification('로그 필터링 기능은 개발 중입니다.', 'info');
}
// 데이터베이스 관리 열기
function openDatabaseManagement() {
console.log('데이터베이스 관리 버튼 클릭됨');
showNotification('데이터베이스 관리 기능은 개발 중입니다.', 'info');
}
// 시스템 설정 열기
function openSystemSettings() {
console.log('시스템 설정 버튼 클릭됨');
showNotification('시스템 설정 기능은 개발 중입니다.', 'info');
}
// 백업 관리 열기
function openBackupManagement() {
console.log('백업 관리 버튼 클릭됨');
showNotification('백업 관리 기능은 개발 중입니다.', 'info');
}
// 모니터링 열기
function openMonitoring() {
console.log('모니터링 버튼 클릭됨');
showNotification('모니터링 기능은 개발 중입니다.', 'info');
}
// 모달 닫기
function closeModal(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.style.display = 'none';
}
}
// 로그아웃
function logout() {
if (confirm('로그아웃 하시겠습니까?')) {
localStorage.removeItem('sso_token');
localStorage.removeItem('sso_user');
window.location.href = '/';
}
}
// 알림 표시
function showNotification(message, type = 'info') {
// 간단한 알림 표시 (나중에 토스트 라이브러리로 교체 가능)
const notification = document.createElement('div');
notification.className = `notification notification-${type}`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
notification.remove();
}, 5000);
}
// 사용자 편집
async function editUser(userId) {
try {
// 사용자 정보 가져오기
const response = await apiRequest(`/api/system/users`, 'GET');
if (!response.success) {
throw new Error('사용자 정보를 가져올 수 없습니다.');
}
const user = response.data.find(u => u.user_id === userId);
if (!user) {
throw new Error('해당 사용자를 찾을 수 없습니다.');
}
// 편집 폼 표시
showUserEditForm(user);
} catch (error) {
console.error('사용자 편집 오류:', error);
showNotification('사용자 정보를 불러오는 중 오류가 발생했습니다.', 'error');
}
}
// 사용자 편집 폼 표시
function showUserEditForm(user) {
const formHtml = `
<div class="user-edit-form">
<h4><i class="fas fa-user-edit"></i> 사용자 정보 수정</h4>
<form id="edit-user-form">
<div class="form-group">
<label for="edit-username">사용자명</label>
<input type="text" id="edit-username" value="${user.username}" disabled>
</div>
<div class="form-group">
<label for="edit-name">이름</label>
<input type="text" id="edit-name" value="${user.name || ''}" required>
</div>
<div class="form-group">
<label for="edit-email">이메일</label>
<input type="email" id="edit-email" value="${user.email || ''}">
</div>
<div class="form-group">
<label for="edit-role">권한</label>
<select id="edit-role" required>
<option value="system" ${user.role === 'system' ? 'selected' : ''}>시스템</option>
<option value="admin" ${user.role === 'admin' ? 'selected' : ''}>관리자</option>
<option value="leader" ${user.role === 'leader' ? 'selected' : ''}>그룹장</option>
<option value="user" ${user.role === 'user' ? 'selected' : ''}>사용자</option>
</select>
</div>
<div class="form-group">
<label for="edit-is-active">상태</label>
<select id="edit-is-active" required>
<option value="1" ${user.is_active ? 'selected' : ''}>활성</option>
<option value="0" ${!user.is_active ? 'selected' : ''}>비활성</option>
</select>
</div>
<div class="form-group">
<label for="edit-worker-id">작업자 ID</label>
<input type="number" id="edit-worker-id" value="${user.user_id || ''}">
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> 저장
</button>
<button type="button" class="btn btn-secondary" onclick="closeModal('account-modal')">
<i class="fas fa-times"></i> 취소
</button>
</div>
</form>
</div>
`;
const container = document.getElementById('account-management-content');
container.innerHTML = formHtml;
// 폼 제출 이벤트 리스너
document.getElementById('edit-user-form').addEventListener('submit', async (e) => {
e.preventDefault();
await updateUser(user.user_id);
});
}
// 사용자 정보 업데이트
async function updateUser(userId) {
try {
const formData = {
name: document.getElementById('edit-name').value,
email: document.getElementById('edit-email').value || null,
role: document.getElementById('edit-role').value,
access_level: document.getElementById('edit-role').value,
is_active: parseInt(document.getElementById('edit-is-active').value),
user_id: document.getElementById('edit-worker-id').value || null
};
const response = await apiRequest(`/api/system/users/${userId}`, 'PUT', formData);
if (response.success) {
showNotification('사용자 정보가 성공적으로 업데이트되었습니다.', 'success');
closeModal('account-modal');
// 계정 관리 다시 로드
setTimeout(() => openAccountManagement(), 500);
} else {
throw new Error(response.error || '업데이트에 실패했습니다.');
}
} catch (error) {
console.error('사용자 업데이트 오류:', error);
showNotification('사용자 정보 업데이트 중 오류가 발생했습니다.', 'error');
}
}
// 사용자 삭제
async function deleteUser(userId) {
try {
// 사용자 정보 가져오기
const response = await apiRequest(`/api/system/users`, 'GET');
if (!response.success) {
throw new Error('사용자 정보를 가져올 수 없습니다.');
}
const user = response.data.find(u => u.user_id === userId);
if (!user) {
throw new Error('해당 사용자를 찾을 수 없습니다.');
}
// 삭제 확인
if (!confirm(`정말로 사용자 '${user.username}'를 삭제하시겠습니까?\n\n이 작업은 되돌릴 수 없습니다.`)) {
return;
}
// 사용자 삭제 요청
const deleteResponse = await apiRequest(`/api/system/users/${userId}`, 'DELETE');
if (deleteResponse.success) {
showNotification('사용자가 성공적으로 삭제되었습니다.', 'success');
// 계정 관리 다시 로드
setTimeout(() => {
const container = document.getElementById('account-management-content');
if (container) {
loadAccountManagementContent(container);
}
}, 500);
} else {
throw new Error(deleteResponse.error || '삭제에 실패했습니다.');
}
} catch (error) {
console.error('사용자 삭제 오류:', error);
showNotification('사용자 삭제 중 오류가 발생했습니다.', 'error');
}
}
// 새 사용자 생성 폼 열기
function openCreateUserForm() {
const formHtml = `
<div class="user-create-form">
<h4><i class="fas fa-user-plus"></i> 새 사용자 생성</h4>
<form id="create-user-form">
<div class="form-group">
<label for="create-username">사용자명 *</label>
<input type="text" id="create-username" required>
</div>
<div class="form-group">
<label for="create-password">비밀번호 *</label>
<input type="password" id="create-password" required minlength="6">
</div>
<div class="form-group">
<label for="create-name">이름 *</label>
<input type="text" id="create-name" required>
</div>
<div class="form-group">
<label for="create-email">이메일</label>
<input type="email" id="create-email">
</div>
<div class="form-group">
<label for="create-role">권한 *</label>
<select id="create-role" required>
<option value="">권한 선택</option>
<option value="system">시스템</option>
<option value="admin">관리자</option>
<option value="leader">그룹장</option>
<option value="user">사용자</option>
</select>
</div>
<div class="form-group">
<label for="create-worker-id">작업자 ID</label>
<input type="number" id="create-worker-id">
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
<i class="fas fa-plus"></i> 생성
</button>
<button type="button" class="btn btn-secondary" onclick="loadAccountManagementContent(document.getElementById('account-management-content'))">
<i class="fas fa-arrow-left"></i> 돌아가기
</button>
</div>
</form>
</div>
`;
const container = document.getElementById('account-management-content');
container.innerHTML = formHtml;
// 폼 제출 이벤트 리스너
document.getElementById('create-user-form').addEventListener('submit', async (e) => {
e.preventDefault();
await createUser();
});
}
// 새 사용자 생성
async function createUser() {
try {
const formData = {
username: document.getElementById('create-username').value,
password: document.getElementById('create-password').value,
name: document.getElementById('create-name').value,
email: document.getElementById('create-email').value || null,
role: document.getElementById('create-role').value,
access_level: document.getElementById('create-role').value,
user_id: document.getElementById('create-worker-id').value || null
};
const response = await apiRequest('/api/system/users', 'POST', formData);
if (response.success) {
showNotification('새 사용자가 성공적으로 생성되었습니다.', 'success');
// 계정 관리 목록으로 돌아가기
setTimeout(() => {
const container = document.getElementById('account-management-content');
loadAccountManagementContent(container);
}, 500);
} else {
throw new Error(response.error || '사용자 생성에 실패했습니다.');
}
} catch (error) {
console.error('사용자 생성 오류:', error);
showNotification('사용자 생성 중 오류가 발생했습니다.', 'error');
}
}
// 사용자 필터링
function filterUsers() {
const searchTerm = document.getElementById('user-search').value.toLowerCase();
const roleFilter = document.getElementById('role-filter').value;
const rows = document.querySelectorAll('#users-tbody tr');
rows.forEach(row => {
const username = row.cells[1].textContent.toLowerCase();
const name = row.cells[2].textContent.toLowerCase();
const role = row.querySelector('.role-badge').textContent.toLowerCase();
const matchesSearch = username.includes(searchTerm) || name.includes(searchTerm);
const matchesRole = !roleFilter || role.includes(roleFilter);
row.style.display = matchesSearch && matchesRole ? '' : 'none';
});
}
// 모달 관련 함수들만 전역으로 노출 (동적으로 생성되는 HTML에서 사용)
window.closeModal = closeModal;
window.editUser = editUser;
window.deleteUser = deleteUser;
window.openCreateUserForm = openCreateUserForm;
window.filterUsers = filterUsers;
window.filterLogs = filterLogs;
// 테스트용 전역 함수
window.testFunction = function() {
alert('테스트 함수가 정상적으로 작동합니다!');
};
// 모달 외부 클릭 시 닫기
window.onclick = function(event) {
const modals = document.querySelectorAll('.modal');
modals.forEach(modal => {
if (event.target === modal) {
modal.style.display = 'none';
}
});
};

View File

@@ -1,481 +0,0 @@
// task-management.js - 작업 관리 페이지 JavaScript
// 전역 변수
let workTypes = []; // 공정 목록
let tasks = []; // 작업 목록
let currentWorkTypeId = ''; // 현재 선택된 공정 ID
let currentEditingTask = null;
// 페이지 초기화
document.addEventListener('DOMContentLoaded', async () => {
// API 함수가 로드될 때까지 대기
let retryCount = 0;
while (!window.apiCall && retryCount < 50) {
await new Promise(resolve => setTimeout(resolve, 100));
retryCount++;
}
if (!window.apiCall) {
showToast('시스템을 초기화할 수 없습니다. 페이지를 새로고침해주세요.', 'error');
return;
}
await loadAllData();
});
// 전체 데이터 로드
async function loadAllData() {
try {
// 공정 목록 로드 (work_types 조회 - 코드 관리 API 사용)
await loadWorkTypes();
// 작업 목록 로드
await loadTasks();
} catch (error) {
console.error(' 데이터 로드 오류:', error);
showToast('데이터를 불러오는 중 오류가 발생했습니다.', 'error');
}
}
window.loadAllData = loadAllData;
// 공정 목록 로드
async function loadWorkTypes() {
try {
// 작업 유형(공정) 목록 조회
const response = await window.apiCall('/daily-work-reports/work-types');
if (response && response.success) {
workTypes = response.data || [];
} else {
workTypes = [];
}
renderWorkTypeTabs();
populateWorkTypeSelect();
} catch (error) {
console.error(' 공정 목록 조회 오류:', error);
// API 오류 시에도 빈 배열로 처리
workTypes = [];
renderWorkTypeTabs();
}
}
// 작업 목록 로드
async function loadTasks() {
try {
const response = await window.apiCall('/tasks');
if (response && response.success) {
tasks = response.data || [];
} else {
tasks = [];
}
renderTasks();
updateStatistics();
} catch (error) {
console.error(' 작업 목록 조회 오류:', error);
showToast('작업 목록을 불러오는 중 오류가 발생했습니다.', 'error');
tasks = [];
renderTasks();
}
}
// 공정 탭 렌더링
function renderWorkTypeTabs() {
const tabsContainer = document.getElementById('workTypeTabs');
let tabsHtml = `
<button class="tab-btn ${currentWorkTypeId === '' ? 'active' : ''}"
data-work-type="" onclick="switchWorkType('')">
<span class="tab-icon">📋</span>
전체 (${tasks.length})
</button>
`;
workTypes.forEach(workType => {
const count = tasks.filter(t => t.work_type_id === workType.id).length;
const isActive = currentWorkTypeId === workType.id;
const safeId = parseInt(workType.id) || 0;
tabsHtml += `
<button class="tab-btn ${isActive ? 'active' : ''}"
data-work-type="${safeId}"
onclick="switchWorkType(${safeId})"
style="position: relative; padding-right: 3rem;">
<span class="tab-icon">🔧</span>
${escapeHtml(workType.name)} (${parseInt(count) || 0})
<span onclick="event.stopPropagation(); editWorkType(${safeId});"
style="position: absolute; right: 0.5rem; padding: 0.25rem 0.5rem; opacity: 0.7; cursor: pointer; font-size: 0.75rem;"
title="공정 수정">
✏️
</span>
</button>
`;
});
tabsContainer.innerHTML = tabsHtml;
}
// 공정 전환
function switchWorkType(workTypeId) {
currentWorkTypeId = workTypeId === '' ? '' : parseInt(workTypeId);
renderWorkTypeTabs();
renderTasks();
updateStatistics();
}
window.switchWorkType = switchWorkType;
// 작업 목록 렌더링
function renderTasks() {
const grid = document.getElementById('taskGrid');
// 현재 선택된 공정으로 필터링
let filteredTasks = tasks;
if (currentWorkTypeId !== '') {
filteredTasks = tasks.filter(t => t.work_type_id === currentWorkTypeId);
}
if (filteredTasks.length === 0) {
grid.innerHTML = `
<div class="empty-state" style="grid-column: 1 / -1;">
<div class="empty-icon">📋</div>
<h3>등록된 작업이 없습니다</h3>
<p>"작업 추가" 버튼을 눌러 새로운 작업을 등록하세요</p>
</div>
`;
return;
}
grid.innerHTML = filteredTasks.map(task => createTaskCard(task)).join('');
}
// 작업 카드 생성
function createTaskCard(task) {
const statusBadge = task.is_active
? '<span class="badge" style="background: #dcfce7; color: #166534;">활성</span>'
: '<span class="badge" style="background: #f3f4f6; color: #6b7280;">비활성</span>';
const safeTaskId = parseInt(task.task_id) || 0;
return `
<div class="code-card" onclick="editTask(${safeTaskId})">
<div class="code-card-header">
<h3 class="code-name">${escapeHtml(task.task_name)}</h3>
${statusBadge}
</div>
<div class="code-info">
<div class="info-item">
<span class="info-label">소속 공정</span>
<span class="info-value">${escapeHtml(task.work_type_name || '-')}</span>
</div>
${task.category ? `
<div class="info-item">
<span class="info-label">카테고리</span>
<span class="info-value">${escapeHtml(task.category)}</span>
</div>
` : ''}
</div>
${task.description ? `
<div class="code-description">
${escapeHtml(task.description)}
</div>
` : ''}
<div class="code-meta">
<span>등록: ${escapeHtml(formatDate(task.created_at))}</span>
</div>
</div>
`;
}
// 통계 업데이트
function updateStatistics() {
let filteredTasks = tasks;
if (currentWorkTypeId !== '') {
filteredTasks = tasks.filter(t => t.work_type_id === currentWorkTypeId);
}
const activeCount = filteredTasks.filter(t => t.is_active).length;
document.getElementById('totalCount').textContent = filteredTasks.length;
document.getElementById('activeCount').textContent = activeCount;
}
// 새로고침
function refreshTasks() {
loadAllData();
showToast('데이터를 새로고침했습니다.', 'success');
}
window.refreshTasks = refreshTasks;
// ==================== 작업 모달 ====================
// 작업 모달 열기 (신규)
function openTaskModal() {
currentEditingTask = null;
document.getElementById('taskModalTitle').textContent = '작업 추가';
document.getElementById('taskForm').reset();
document.getElementById('taskId').value = '';
document.getElementById('taskIsActive').checked = true;
// 공정 선택 드롭다운 채우기
populateWorkTypeSelect();
// 현재 선택된 공정이 있으면 자동 선택
if (currentWorkTypeId !== '') {
document.getElementById('taskWorkTypeId').value = currentWorkTypeId;
}
document.getElementById('deleteTaskBtn').style.display = 'none';
document.getElementById('taskModal').style.display = 'flex';
document.body.style.overflow = 'hidden';
}
window.openTaskModal = openTaskModal;
// 작업 편집
async function editTask(taskId) {
try {
const response = await window.apiCall(`/tasks/${taskId}`);
if (response && response.success) {
currentEditingTask = response.data;
document.getElementById('taskModalTitle').textContent = '작업 수정';
document.getElementById('taskId').value = currentEditingTask.task_id;
document.getElementById('taskWorkTypeId').value = currentEditingTask.work_type_id || '';
document.getElementById('taskName').value = currentEditingTask.task_name;
document.getElementById('taskDescription').value = currentEditingTask.description || '';
document.getElementById('taskIsActive').checked = currentEditingTask.is_active;
document.getElementById('deleteTaskBtn').style.display = 'block';
document.getElementById('taskModal').style.display = 'flex';
document.body.style.overflow = 'hidden';
}
} catch (error) {
console.error(' 작업 조회 오류:', error);
showToast('작업 정보를 불러올 수 없습니다.', 'error');
}
}
window.editTask = editTask;
// 작업 모달 닫기
function closeTaskModal() {
document.getElementById('taskModal').style.display = 'none';
document.body.style.overflow = 'auto';
currentEditingTask = null;
}
window.closeTaskModal = closeTaskModal;
// 공정 선택 드롭다운 채우기
function populateWorkTypeSelect() {
const select = document.getElementById('taskWorkTypeId');
select.innerHTML = '<option value="">공정 선택...</option>' +
workTypes.map(wt => `
<option value="${escapeHtml(String(wt.id))}">${escapeHtml(wt.name)}${wt.category ? ' (' + escapeHtml(wt.category) + ')' : ''}</option>
`).join('');
}
// 작업 저장
async function saveTask() {
const taskId = document.getElementById('taskId').value;
const taskData = {
work_type_id: parseInt(document.getElementById('taskWorkTypeId').value) || null,
task_name: document.getElementById('taskName').value.trim(),
description: document.getElementById('taskDescription').value.trim() || null,
is_active: document.getElementById('taskIsActive').checked ? 1 : 0
};
if (!taskData.task_name) {
showToast('작업명을 입력해주세요.', 'error');
return;
}
try {
let response;
if (taskId) {
// 수정
response = await window.apiCall(`/tasks/${taskId}`, 'PUT', taskData);
} else {
// 신규
response = await window.apiCall('/tasks', 'POST', taskData);
}
if (response && response.success) {
showToast(taskId ? '작업이 수정되었습니다.' : '작업이 추가되었습니다.', 'success');
closeTaskModal();
await loadAllData();
} else {
throw new Error(response.message || '저장에 실패했습니다.');
}
} catch (error) {
console.error(' 작업 저장 오류:', error);
showToast('작업 저장 중 오류가 발생했습니다.', 'error');
}
}
window.saveTask = saveTask;
// 작업 삭제
async function deleteTask() {
if (!currentEditingTask) return;
if (!confirm(`"${currentEditingTask.task_name}" 작업을 삭제하시겠습니까?`)) {
return;
}
try {
const response = await window.apiCall(`/tasks/${currentEditingTask.task_id}`, 'DELETE');
if (response && response.success) {
showToast('작업이 삭제되었습니다.', 'success');
closeTaskModal();
await loadAllData();
} else {
throw new Error(response.message || '삭제에 실패했습니다.');
}
} catch (error) {
console.error(' 작업 삭제 오류:', error);
showToast('작업 삭제 중 오류가 발생했습니다.', 'error');
}
}
window.deleteTask = deleteTask;
// ==================== 유틸리티 ====================
// 날짜 포맷
function formatDate(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
}
// showToast → api-base.js 전역 사용
// ==================== 공정 관리 ====================
let currentEditingWorkType = null;
// 공정 모달 열기 (신규)
function openWorkTypeModal() {
currentEditingWorkType = null;
document.getElementById('workTypeModalTitle').textContent = '공정 추가';
document.getElementById('workTypeId').value = '';
document.getElementById('workTypeName').value = '';
document.getElementById('workTypeCategory').value = '';
document.getElementById('workTypeDescription').value = '';
document.getElementById('deleteWorkTypeBtn').style.display = 'none';
document.getElementById('workTypeModal').style.display = 'flex';
document.body.style.overflow = 'hidden';
}
window.openWorkTypeModal = openWorkTypeModal;
// 공정 수정 모달 열기
async function editWorkType(workTypeId) {
try {
const workType = workTypes.find(wt => wt.id === workTypeId);
if (!workType) {
showToast('공정 정보를 찾을 수 없습니다.', 'error');
return;
}
currentEditingWorkType = workType;
document.getElementById('workTypeModalTitle').textContent = '공정 수정';
document.getElementById('workTypeId').value = workType.id;
document.getElementById('workTypeName').value = workType.name || '';
document.getElementById('workTypeCategory').value = workType.category || '';
document.getElementById('workTypeDescription').value = workType.description || '';
document.getElementById('deleteWorkTypeBtn').style.display = 'block';
document.getElementById('workTypeModal').style.display = 'flex';
document.body.style.overflow = 'hidden';
} catch (error) {
console.error(' 공정 조회 오류:', error);
showToast('공정 정보를 불러올 수 없습니다.', 'error');
}
}
window.editWorkType = editWorkType;
// 공정 모달 닫기
function closeWorkTypeModal() {
document.getElementById('workTypeModal').style.display = 'none';
document.body.style.overflow = 'auto';
currentEditingWorkType = null;
}
window.closeWorkTypeModal = closeWorkTypeModal;
// 공정 저장
async function saveWorkType() {
const workTypeId = document.getElementById('workTypeId').value;
const workTypeData = {
name: document.getElementById('workTypeName').value.trim(),
category: document.getElementById('workTypeCategory').value.trim() || null,
description: document.getElementById('workTypeDescription').value.trim() || null
};
if (!workTypeData.name) {
showToast('공정명을 입력해주세요.', 'error');
return;
}
try {
let response;
if (workTypeId) {
// 수정
response = await window.apiCall(`/daily-work-reports/work-types/${workTypeId}`, 'PUT', workTypeData);
} else {
// 신규
response = await window.apiCall('/daily-work-reports/work-types', 'POST', workTypeData);
}
if (response && response.success) {
showToast(workTypeId ? '공정이 수정되었습니다.' : '공정이 추가되었습니다.', 'success');
closeWorkTypeModal();
await loadAllData();
} else {
throw new Error(response.message || '저장에 실패했습니다.');
}
} catch (error) {
console.error(' 공정 저장 오류:', error);
showToast('공정 저장 중 오류가 발생했습니다.', 'error');
}
}
window.saveWorkType = saveWorkType;
// 공정 삭제
async function deleteWorkType() {
if (!currentEditingWorkType) return;
// 이 공정에 속한 작업이 있는지 확인
const relatedTasks = tasks.filter(t => t.work_type_id === currentEditingWorkType.id);
if (relatedTasks.length > 0) {
showToast(`이 공정에 ${relatedTasks.length}개의 작업이 연결되어 있어 삭제할 수 없습니다.`, 'error');
return;
}
if (!confirm(`"${currentEditingWorkType.name}" 공정을 삭제하시겠습니까?`)) {
return;
}
try {
const response = await window.apiCall(`/daily-work-reports/work-types/${currentEditingWorkType.id}`, 'DELETE');
if (response && response.success) {
showToast('공정이 삭제되었습니다.', 'success');
closeWorkTypeModal();
await loadAllData();
} else {
throw new Error(response.message || '삭제에 실패했습니다.');
}
} catch (error) {
console.error(' 공정 삭제 오류:', error);
showToast('공정 삭제 중 오류가 발생했습니다.', 'error');
}
}
window.deleteWorkType = deleteWorkType;

View File

@@ -1,625 +0,0 @@
/**
* TBM 모바일 위자드 - tbm-create.js
* 3단계 위자드로 TBM 세션을 생성하는 모바일 전용 페이지 로직
* Step 1: 작업자 선택, Step 2: 프로젝트+공정 선택, Step 3: 확인
* (작업/작업장은 생성 후 세부 편집 단계에서 입력)
*/
(function() {
'use strict';
// ==================== 위자드 상태 ====================
const W = {
step: 1,
totalSteps: 3,
sessionDate: null,
leaderId: null,
leaderName: '',
workers: new Set(), // user_id Set
workerNames: {}, // { user_id: worker_name }
projectId: null,
projectName: '',
workTypeId: null,
workTypeName: '',
showAddWorkType: false,
todayAssignments: null // 당일 배정 현황 캐시
};
const esc = window.escapeHtml || function(s) { return s || ''; };
// ==================== 초기화 ====================
document.addEventListener('DOMContentLoaded', async function() {
try {
// apiCall이 준비될 때까지 대기
await waitForApi();
// 초기 데이터 로드
await window.TbmAPI.loadInitialData();
// 기본 정보 자동 설정
W.sessionDate = window.TbmUtils.getTodayKST();
var user = window.TbmState.getUser();
if (user) {
var uid = user.user_id || user.id;
if (uid) {
var worker = window.TbmState.allWorkers.find(function(w) { return String(w.user_id) === String(uid); });
if (worker) {
W.leaderId = worker.user_id;
W.leaderName = worker.worker_name;
} else {
W.leaderId = uid;
W.leaderName = user.name || '';
}
} else {
W.leaderName = user.name || '';
}
}
// 로딩 해제
document.getElementById('loadingOverlay').style.display = 'none';
// 첫 스텝 렌더링
renderStep(1);
updateIndicator();
updateNav();
} catch (error) {
console.error('초기화 오류:', error);
document.getElementById('loadingOverlay').style.display = 'none';
showToast('데이터를 불러오는 중 오류가 발생했습니다.', 'error');
}
});
// waitForApi → api-base.js 전역 사용
// ==================== 네비게이션 ====================
window.nextStep = function() {
console.log('[TBM Create] nextStep called, current step:', W.step, 'workTypeId:', W.workTypeId);
if (!validateStep(W.step)) return;
if (W.step < W.totalSteps) {
W.step++;
renderStep(W.step);
updateIndicator();
updateNav();
window.scrollTo(0, 0);
}
};
window.prevStep = function() {
console.log('[TBM Create] prevStep called, current step:', W.step);
if (W.step > 1) {
W.step--;
renderStep(W.step);
updateIndicator();
updateNav();
window.scrollTo(0, 0);
}
};
window.goBack = function() {
if (W.step > 1) {
window.prevStep();
} else {
window.location.href = '/pages/work/tbm-mobile.html';
}
};
function updateIndicator() {
var steps = document.querySelectorAll('#stepIndicator .step');
var lines = document.querySelectorAll('#stepIndicator .step-line');
steps.forEach(function(el, i) {
el.classList.remove('active', 'completed');
if (i + 1 === W.step) {
el.classList.add('active');
} else if (i + 1 < W.step) {
el.classList.add('completed');
}
});
lines.forEach(function(el, i) {
el.style.background = (i + 1 < W.step) ? '#10b981' : '#e5e7eb';
});
}
// 네비게이션 버튼: 단일 핸들러 (DOM 교체 없이 상태 기반 분기)
var _navAction = { prev: null, next: null };
function updateNav() {
var prevBtn = document.getElementById('prevBtn');
var nextBtn = document.getElementById('nextBtn');
if (W.step === 1) {
prevBtn.style.visibility = 'hidden';
_navAction.prev = null;
} else {
prevBtn.style.visibility = 'visible';
_navAction.prev = window.prevStep;
}
if (W.step === W.totalSteps) {
nextBtn.className = 'nav-btn nav-btn-save';
nextBtn.textContent = '저장';
_navAction.next = saveWizard;
} else {
nextBtn.className = 'nav-btn nav-btn-next';
nextBtn.innerHTML = '다음 &#8594;';
_navAction.next = window.nextStep;
}
nextBtn.disabled = false;
}
// 한번만 등록하는 이벤트 리스너
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('prevBtn').addEventListener('click', function(e) {
e.preventDefault();
if (_navAction.prev) _navAction.prev();
});
document.getElementById('nextBtn').addEventListener('click', function(e) {
e.preventDefault();
if (_navAction.next) _navAction.next();
});
});
// ==================== 유효성 검사 ====================
function validateStep(step) {
switch (step) {
case 1: // 작업자 선택
if (W.workers.size === 0) {
showToast('최소 1명의 작업자를 선택해주세요.', 'warning');
return false;
}
return true;
case 2: // 프로젝트 + 공정
if (!W.workTypeId) {
showToast('공정을 선택해주세요.', 'warning');
return false;
}
return true;
default:
return true;
}
}
// ==================== 스텝 렌더링 ====================
function renderStep(step) {
var container = document.getElementById('stepContainer');
switch (step) {
case 1: renderStepWorkers(container); break;
case 2: renderStepProjectAndWorkType(container); break;
case 3: renderStepConfirm(container); break;
}
}
// --- Step 1: 작업자 선택 ---
async function renderStepWorkers(container) {
var workers = window.TbmState.allWorkers;
// 당일 배정 현황 로드 (첫 로드 시)
if (!W.todayAssignments) {
try {
var today = window.TbmUtils.getTodayKST();
var res = await window.apiCall('/tbm/sessions/date/' + today + '/assignments');
if (res && res.success) {
W.todayAssignments = {};
res.data.forEach(function(a) {
if (a.sessions && a.sessions.length > 0) {
W.todayAssignments[a.user_id] = a;
}
});
} else {
W.todayAssignments = {};
}
} catch(e) {
console.error('배정 현황 로드 오류:', e);
W.todayAssignments = {};
}
}
var workerCards = workers.map(function(w) {
var selected = W.workers.has(w.user_id) ? ' selected' : '';
var assignment = W.todayAssignments[w.user_id];
var assigned = assignment && assignment.sessions && assignment.sessions.length > 0;
var badgeHtml = '';
var disabledClass = '';
var onclick = 'toggleWorker(' + w.user_id + ')';
if (assigned) {
// 이미 배정됨 - 선택 불가
var leaderNames = assignment.sessions.map(function(s) { return s.leader_name || ''; }).join(', ');
badgeHtml = '<div style="font-size:0.625rem; color:#ef4444; margin-top:0.125rem;">배정됨 - ' + esc(leaderNames) + ' TBM</div>';
disabledClass = ' disabled';
onclick = '';
}
return '<div class="worker-card' + selected + disabledClass + '"' +
(onclick ? ' onclick="' + onclick + '"' : '') +
' data-wid="' + w.user_id + '"' +
' style="' + (assigned ? 'opacity:0.5; pointer-events:none;' : '') + '">' +
'<div class="worker-check">&#10003;</div>' +
'<div class="worker-info">' +
'<div class="worker-name">' + esc(w.worker_name) + '</div>' +
'<div class="worker-type">' + esc(w.job_type || '작업자') + '</div>' +
badgeHtml +
'</div>' +
'</div>';
}).join('');
container.innerHTML =
'<div class="wizard-section">' +
'<div class="section-title"><span class="sn">1</span>작업자 선택</div>' +
'<div class="select-all-bar">' +
'<span class="count" id="workerCount">' + W.workers.size + '명 선택</span>' +
'<button type="button" class="select-all-btn" onclick="toggleAllWorkers()">' +
(W.workers.size === workers.length ? '전체 해제' : '전체 선택') +
'</button>' +
'</div>' +
'<div class="worker-grid">' + workerCards + '</div>' +
'</div>';
}
window.toggleWorker = function(workerId) {
// 이미 배정된 작업자는 선택 불가
var a = W.todayAssignments && W.todayAssignments[workerId];
if (a && a.sessions && a.sessions.length > 0) return;
if (W.workers.has(workerId)) {
W.workers.delete(workerId);
delete W.workerNames[workerId];
} else {
W.workers.add(workerId);
var w = window.TbmState.allWorkers.find(function(x) { return x.user_id === workerId; });
if (w) W.workerNames[workerId] = w.worker_name;
}
var card = document.querySelector('[data-wid="' + workerId + '"]');
if (card) card.classList.toggle('selected');
var countEl = document.getElementById('workerCount');
if (countEl) countEl.textContent = W.workers.size + '명 선택';
};
window.toggleAllWorkers = function() {
var workers = window.TbmState.allWorkers;
var availableWorkers = workers.filter(function(w) {
var a = W.todayAssignments && W.todayAssignments[w.user_id];
return !(a && a.sessions && a.sessions.length > 0);
});
if (W.workers.size === availableWorkers.length) {
W.workers.clear();
W.workerNames = {};
} else {
availableWorkers.forEach(function(w) {
W.workers.add(w.user_id);
W.workerNames[w.user_id] = w.worker_name;
});
}
renderStepWorkers(document.getElementById('stepContainer'));
};
// --- Step 2: 프로젝트 + 공정 선택 (통합) ---
function renderStepProjectAndWorkType(container) {
var projects = window.TbmState.allProjects;
var workTypes = window.TbmState.allWorkTypes;
// 프로젝트 선택 UI
var skipSelected = W.projectId === null ? ' selected' : '';
var projectItems = projects.map(function(p) {
var selected = W.projectId === p.project_id ? ' selected' : '';
return '<div class="list-item' + selected + '" data-action="selectProject" data-project-id="' + p.project_id + '" data-project-name="' + esc(p.project_name) + '">' +
'<div class="item-title">' + esc(p.project_name) + '</div>' +
'<div class="item-desc">' + esc(p.job_no || '') + '</div>' +
'</div>';
}).join('');
// 공정 pill 버튼
var pillHtml = workTypes.map(function(wt) {
var selected = W.workTypeId === wt.id ? ' selected' : '';
return '<button type="button" class="pill-btn' + selected + '" data-action="selectWorkType" data-wt-id="' + wt.id + '" data-wt-name="' + esc(wt.name) + '">' + esc(wt.name) + '</button>';
}).join('');
pillHtml += '<button type="button" class="pill-btn-add" onclick="toggleAddWorkType()">+ 추가</button>';
// 공정 인라인 추가 폼
var addWorkTypeFormHtml = '';
if (W.showAddWorkType) {
addWorkTypeFormHtml =
'<div class="inline-add-form" id="addWorkTypeForm">' +
'<input type="text" id="newWorkTypeName" placeholder="새 공정명 입력" autocomplete="off">' +
'<div class="inline-add-btns">' +
'<button type="button" class="btn-cancel" onclick="cancelAddWorkType()">취소</button>' +
'<button type="button" class="btn-save" id="btnSaveWorkType" onclick="saveNewWorkType()">저장</button>' +
'</div>' +
'</div>';
}
container.innerHTML =
'<div class="wizard-section">' +
'<div class="section-title"><span class="sn">2</span>프로젝트 선택 <span style="font-size:0.75rem;font-weight:400;color:#9ca3af;">(선택사항)</span></div>' +
'<div class="list-item-skip' + skipSelected + '" data-action="selectProject" data-project-id="" data-project-name="">' +
'선택 안함' +
'</div>' +
(projects.length > 0 ? projectItems : '<div class="empty-state">등록된 프로젝트가 없습니다</div>') +
'</div>' +
'<div class="wizard-section">' +
'<div class="section-title"><span class="sn">2</span>공정 선택 <span style="font-size:0.75rem;font-weight:400;color:#ef4444;">(필수)</span></div>' +
'<div class="pill-grid">' + pillHtml + '</div>' +
addWorkTypeFormHtml +
'</div>';
// 자동 포커스
if (W.showAddWorkType) {
var inp = document.getElementById('newWorkTypeName');
if (inp) {
setTimeout(function() { inp.focus(); }, 50);
inp.onkeydown = function(e) {
if (e.key === 'Enter') { e.preventDefault(); saveNewWorkType(); }
if (e.key === 'Escape') { cancelAddWorkType(); }
};
}
}
// Event delegation for project/workType selection
container.onclick = function(e) {
var el = e.target.closest('[data-action]');
if (!el) return;
var action = el.getAttribute('data-action');
if (action === 'selectProject') {
var pid = el.getAttribute('data-project-id');
selectProject(pid ? parseInt(pid) : null, el.getAttribute('data-project-name') || '');
} else if (action === 'selectWorkType') {
selectWorkType(parseInt(el.getAttribute('data-wt-id')), el.getAttribute('data-wt-name') || '');
}
};
}
window.selectProject = function(projectId, projectName) {
W.projectId = projectId;
W.projectName = projectName || '';
// Update project list items
document.querySelectorAll('#stepContainer .list-item, #stepContainer .list-item-skip').forEach(function(el) {
el.classList.remove('selected');
});
if (projectId === null) {
var skipEl = document.querySelector('#stepContainer .list-item-skip');
if (skipEl) skipEl.classList.add('selected');
} else {
document.querySelectorAll('#stepContainer .list-item').forEach(function(el) {
var title = el.querySelector('.item-title');
if (title && title.textContent === projectName) {
el.classList.add('selected');
}
});
}
};
window.selectWorkType = function(id, name) {
console.log('[TBM Create] selectWorkType:', id, name);
W.workTypeId = id;
W.workTypeName = name;
// Update pill buttons
document.querySelectorAll('#stepContainer .pill-btn').forEach(function(el) {
el.classList.remove('selected');
});
document.querySelectorAll('#stepContainer .pill-btn').forEach(function(el) {
if (el.textContent === name) {
el.classList.add('selected');
}
});
};
// --- Step 2: 인라인 추가 (공정) ---
window.toggleAddWorkType = function() {
W.showAddWorkType = !W.showAddWorkType;
renderStepProjectAndWorkType(document.getElementById('stepContainer'));
};
window.cancelAddWorkType = function() {
W.showAddWorkType = false;
renderStepProjectAndWorkType(document.getElementById('stepContainer'));
};
window.saveNewWorkType = async function() {
var inp = document.getElementById('newWorkTypeName');
var btn = document.getElementById('btnSaveWorkType');
if (!inp || !btn) return;
var name = inp.value.trim();
if (!name) {
showToast('공정명을 입력해주세요.', 'warning');
inp.focus();
return;
}
var exists = window.TbmState.allWorkTypes.some(function(wt) {
return wt.name.toLowerCase() === name.toLowerCase();
});
if (exists) {
showToast('이미 존재하는 공정명입니다.', 'warning');
inp.focus();
return;
}
btn.disabled = true;
btn.textContent = '저장 중...';
try {
var response = await window.apiCall('/daily-work-reports/work-types', 'POST', { name: name });
if (!response || !response.success) {
throw new Error(response?.message || '공정 추가 실패');
}
var newItem = response.data;
window.TbmState.allWorkTypes.push(newItem);
W.workTypeId = newItem.id;
W.workTypeName = newItem.name;
W.showAddWorkType = false;
renderStepProjectAndWorkType(document.getElementById('stepContainer'));
showToast('\'' + name + '\' 공정이 추가되었습니다.', 'success');
} catch (error) {
console.error('공정 추가 오류:', error);
showToast('공정 추가 중 오류: ' + error.message, 'error');
btn.disabled = false;
btn.textContent = '저장';
}
};
// --- Step 3: 확인 ---
function renderStepConfirm(container) {
var dateDisplay = window.TbmUtils.formatDateFull(W.sessionDate);
// 작업자 이름 목록
var workerNameList = [];
W.workers.forEach(function(wid) {
workerNameList.push(W.workerNames[wid] || '작업자');
});
var summaryHtml =
'<div class="summary-card">' +
'<div class="summary-row"><span class="summary-label">날짜</span><span class="summary-value">' + esc(dateDisplay) + '</span></div>' +
'<div class="summary-row"><span class="summary-label">입력자</span><span class="summary-value">' + esc(W.leaderName || '(미설정)') + '</span></div>' +
'<div class="summary-row"><span class="summary-label">프로젝트</span><span class="summary-value">' + esc(W.projectName || '선택 안함') + '</span></div>' +
'<div class="summary-row"><span class="summary-label">공정</span><span class="summary-value">' + esc(W.workTypeName) + '</span></div>' +
'<div class="summary-row"><span class="summary-label">작업자</span><span class="summary-value">' + W.workers.size + '명</span></div>' +
'</div>';
// 작업자 목록 (간단 표시)
var workerListHtml = workerNameList.map(function(name) {
return '<div style="display:flex;align-items:center;gap:0.5rem;padding:0.5rem 0.75rem;background:#f9fafb;border-radius:0.5rem;margin-bottom:0.25rem;">' +
'<span style="font-size:0.875rem;font-weight:500;color:#1f2937;">' + esc(name) + '</span>' +
'<span style="font-size:0.6875rem;color:#9ca3af;margin-left:auto;">세부 미입력</span>' +
'</div>';
}).join('');
container.innerHTML =
'<div class="wizard-section">' +
'<div class="section-title"><span class="sn">3</span>확인</div>' +
summaryHtml +
'</div>' +
'<div class="wizard-section">' +
'<div class="section-title">작업자 목록</div>' +
'<div style="padding:0.5rem;background:#fff7ed;border:1px solid #fed7aa;border-radius:0.5rem;margin-bottom:0.75rem;font-size:0.8125rem;color:#c2410c;">' +
'저장 후 TBM 카드를 탭하면 작업자별 작업/작업장을 입력할 수 있습니다.' +
'</div>' +
workerListHtml +
'</div>';
}
// ==================== 저장 ====================
var _saving = false;
async function saveWizard() {
if (_saving) return;
_saving = true;
// 로딩 오버레이 표시
var overlay = document.getElementById('loadingOverlay');
var loadingText = document.getElementById('loadingText');
if (overlay) {
if (loadingText) loadingText.textContent = '저장 중...';
overlay.style.display = 'flex';
}
// 저장 버튼 비활성화
var saveBtn = document.getElementById('nextBtn');
if (saveBtn) {
saveBtn.disabled = true;
saveBtn.textContent = '저장 중...';
}
try {
var leaderId = W.leaderId ? parseInt(W.leaderId) : null;
// 1. TBM 세션 생성
var sessionData = {
session_date: W.sessionDate,
leader_user_id: leaderId
};
var response = await window.apiCall('/tbm/sessions', 'POST', sessionData);
if (!response || !response.success) {
throw new Error(response?.message || '세션 생성 실패');
}
var sessionId = response.data.session_id;
// 2. 팀원 일괄 추가 (task_id, workplace_id = null)
var members = [];
W.workers.forEach(function(wid) {
members.push({
user_id: wid,
project_id: W.projectId,
work_type_id: W.workTypeId,
task_id: null,
workplace_category_id: null,
workplace_id: null,
work_detail: null,
is_present: true
});
});
var teamResponse = await window.apiCall(
'/tbm/sessions/' + sessionId + '/team/batch',
'POST',
{ members: members }
);
if (!teamResponse || !teamResponse.success) {
var err = new Error(teamResponse?.message || '팀원 추가 실패');
if (teamResponse && teamResponse.duplicates) err.duplicates = teamResponse.duplicates;
err._sessionId = sessionId;
throw err;
}
showToast('TBM이 생성되었습니다 (작업자 ' + members.length + '명)', 'success');
// 3. tbm-mobile.html로 이동
setTimeout(function() {
window.location.href = '/pages/work/tbm-mobile.html';
}, 1000);
} catch (error) {
console.error('TBM 저장 오류:', error);
// 409 중복 배정 에러 처리
if (error.duplicates && error.duplicates.length > 0) {
// 고아 세션 삭제
if (error._sessionId) {
try { await window.apiCall('/tbm/sessions/' + error._sessionId, 'DELETE'); } catch(e) {}
}
// 중복 작업자 자동 해제
error.duplicates.forEach(function(d) {
W.workers.delete(d.user_id);
delete W.workerNames[d.user_id];
});
// 배정 현황 캐시 갱신
W.todayAssignments = null;
// Step 1로 복귀
W.step = 1;
renderStep(1);
updateIndicator();
updateNav();
showToast(error.message, 'error');
} else {
showToast('TBM 저장 중 오류가 발생했습니다: ' + error.message, 'error');
}
if (overlay) overlay.style.display = 'none';
if (saveBtn) {
saveBtn.disabled = false;
saveBtn.textContent = '저장';
}
_saving = false;
}
}
// ==================== 토스트 (로컬) ====================
function showToast(message, type) {
if (window.showToast && typeof window.showToast === 'function') {
window.showToast(message, type);
return;
}
console.log('[Toast] ' + type + ': ' + message);
}
})();

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,624 +0,0 @@
/**
* TBM - API Client
* TBM 관련 모든 API 호출을 관리
*/
class TbmAPI {
constructor() {
this.state = window.TbmState;
this.utils = window.TbmUtils;
console.log('[TbmAPI] 초기화 완료');
}
/**
* 초기 데이터 로드 (작업자, 프로젝트, 안전 체크리스트, 공정, 작업, 작업장)
*/
async loadInitialData() {
try {
// 현재 로그인한 사용자 정보 가져오기
const userInfo = JSON.parse(localStorage.getItem('sso_user') || '{}');
this.state.currentUser = userInfo;
// 병렬로 데이터 로드
await Promise.all([
this.loadWorkers(),
this.loadProjects(),
this.loadSafetyChecks(),
this.loadWorkTypes(),
this.loadTasks(),
this.loadWorkplaces(),
this.loadWorkplaceCategories()
]);
} catch (error) {
console.error(' 초기 데이터 로드 오류:', error);
window.showToast?.('데이터를 불러오는 중 오류가 발생했습니다.', 'error');
}
}
/**
* 작업자 목록 로드 (생산팀 소속만)
*/
async loadWorkers() {
try {
const response = await window.apiCall('/workers?limit=1000&department_id=1');
if (response) {
let workers = Array.isArray(response) ? response : (response.data || []);
// 활성 상태인 작업자만 필터링
workers = workers.filter(w => w.status === 'active' && w.employment_status === 'employed');
this.state.allWorkers = workers;
return workers;
}
} catch (error) {
console.error(' 작업자 로딩 오류:', error);
throw error;
}
}
/**
* 프로젝트 목록 로드 (활성 프로젝트만)
*/
async loadProjects() {
try {
const response = await window.apiCall('/projects?is_active=1');
if (response) {
const projects = Array.isArray(response) ? response : (response.data || []);
this.state.allProjects = projects.filter(p =>
p.is_active === 1 || p.is_active === true || p.is_active === '1'
);
return this.state.allProjects;
}
} catch (error) {
console.error(' 프로젝트 로딩 오류:', error);
throw error;
}
}
/**
* 안전 체크리스트 로드
*/
async loadSafetyChecks() {
try {
const response = await window.apiCall('/tbm/safety-checks');
if (response && response.success) {
this.state.allSafetyChecks = response.data;
return this.state.allSafetyChecks;
}
} catch (error) {
console.error(' 안전 체크리스트 로딩 오류:', error);
}
}
/**
* 공정(Work Types) 목록 로드
*/
async loadWorkTypes() {
try {
const response = await window.apiCall('/daily-work-reports/work-types');
if (response && response.success) {
this.state.allWorkTypes = response.data || [];
return this.state.allWorkTypes;
}
} catch (error) {
console.error(' 공정 로딩 오류:', error);
}
}
/**
* 작업(Tasks) 목록 로드
*/
async loadTasks() {
try {
const response = await window.apiCall('/tasks/active/list');
if (response && response.success) {
this.state.allTasks = response.data || [];
return this.state.allTasks;
}
} catch (error) {
console.error(' 작업 로딩 오류:', error);
}
}
/**
* 작업장 목록 로드
*/
async loadWorkplaces() {
try {
const response = await window.apiCall('/workplaces?is_active=true');
if (response && response.success) {
this.state.allWorkplaces = response.data || [];
return this.state.allWorkplaces;
}
} catch (error) {
console.error(' 작업장 로딩 오류:', error);
}
}
/**
* 작업장 카테고리 로드
*/
async loadWorkplaceCategories() {
try {
const response = await window.apiCall('/workplaces/categories/active/list');
if (response && response.success) {
this.state.allWorkplaceCategories = response.data || [];
return this.state.allWorkplaceCategories;
}
} catch (error) {
console.error(' 작업장 카테고리 로딩 오류:', error);
}
}
/**
* 오늘의 TBM만 로드 (TBM 입력 탭용)
*/
async loadTodayOnlyTbm() {
const today = this.utils.getTodayKST();
try {
const response = await window.apiCall(`/tbm/sessions/date/${today}`);
if (response && response.success) {
this.state.todaySessions = response.data || [];
} else {
this.state.todaySessions = [];
}
return this.state.todaySessions;
} catch (error) {
console.error(' 오늘 TBM 조회 오류:', error);
window.showToast?.('오늘 TBM을 불러오는 중 오류가 발생했습니다.', 'error');
this.state.todaySessions = [];
return [];
}
}
/**
* 최근 TBM을 날짜별로 그룹화하여 로드
*/
async loadRecentTbmGroupedByDate() {
try {
const today = new Date();
const dates = [];
// 최근 N일의 날짜 생성
for (let i = 0; i < this.state.loadedDaysCount; i++) {
const date = new Date(today);
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().split('T')[0];
dates.push(dateStr);
}
// 각 날짜의 TBM 로드
this.state.dateGroupedSessions = {};
this.state.allLoadedSessions = [];
const promises = dates.map(date => window.apiCall(`/tbm/sessions/date/${date}`));
const results = await Promise.all(promises);
results.forEach((response, index) => {
const date = dates[index];
if (response && response.success && response.data && response.data.length > 0) {
const sessions = response.data;
if (sessions.length > 0) {
this.state.dateGroupedSessions[date] = sessions;
this.state.allLoadedSessions = this.state.allLoadedSessions.concat(sessions);
}
}
});
return this.state.dateGroupedSessions;
} catch (error) {
console.error(' TBM 날짜별 로드 오류:', error);
window.showToast?.('TBM을 불러오는 중 오류가 발생했습니다.', 'error');
this.state.dateGroupedSessions = {};
return {};
}
}
/**
* 특정 날짜의 TBM 세션 목록 로드
*/
async loadTbmSessionsByDate(date) {
try {
const response = await window.apiCall(`/tbm/sessions/date/${date}`);
if (response && response.success) {
this.state.allSessions = response.data || [];
} else {
this.state.allSessions = [];
}
return this.state.allSessions;
} catch (error) {
console.error(' TBM 세션 조회 오류:', error);
window.showToast?.('TBM 세션을 불러오는 중 오류가 발생했습니다.', 'error');
this.state.allSessions = [];
return [];
}
}
/**
* TBM 세션 생성
*/
async createTbmSession(sessionData) {
try {
const response = await window.apiCall('/tbm/sessions', 'POST', sessionData);
if (!response || !response.success) {
throw new Error(response?.message || '세션 생성 실패');
}
return response;
} catch (error) {
console.error(' TBM 세션 생성 오류:', error);
throw error;
}
}
/**
* TBM 세션 정보 조회
*/
async getSession(sessionId) {
try {
const response = await window.apiCall(`/tbm/sessions/${sessionId}`);
if (!response || !response.success) {
throw new Error(response?.message || '세션 조회 실패');
}
return response.data;
} catch (error) {
console.error(' TBM 세션 조회 오류:', error);
throw error;
}
}
/**
* TBM 팀원 조회
*/
async getTeamMembers(sessionId) {
try {
const response = await window.apiCall(`/tbm/sessions/${sessionId}/team`);
if (!response || !response.success) {
throw new Error(response?.message || '팀원 조회 실패');
}
return response.data || [];
} catch (error) {
console.error(' TBM 팀원 조회 오류:', error);
throw error;
}
}
/**
* TBM 팀원 일괄 추가
*/
async addTeamMembers(sessionId, members) {
try {
const response = await window.apiCall(
`/tbm/sessions/${sessionId}/team/batch`,
'POST',
{ members }
);
if (!response || !response.success) {
const err = new Error(response?.message || '팀원 추가 실패');
if (response && response.duplicates) err.duplicates = response.duplicates;
throw err;
}
return response;
} catch (error) {
console.error(' TBM 팀원 추가 오류:', error);
throw error;
}
}
/**
* TBM 팀원 전체 삭제
*/
async clearTeamMembers(sessionId) {
try {
const response = await window.apiCall(`/tbm/sessions/${sessionId}/team/clear`, 'DELETE');
return response;
} catch (error) {
console.error(' TBM 팀원 삭제 오류:', error);
throw error;
}
}
/**
* TBM 안전 체크 조회
*/
async getSafetyChecks(sessionId) {
try {
const response = await window.apiCall(`/tbm/sessions/${sessionId}/safety`);
return response?.data || [];
} catch (error) {
console.error(' 안전 체크 조회 오류:', error);
return [];
}
}
/**
* TBM 안전 체크 (필터링된) 조회
*/
async getFilteredSafetyChecks(sessionId) {
try {
const response = await window.apiCall(`/tbm/sessions/${sessionId}/safety-checks/filtered`);
if (!response || !response.success) {
throw new Error(response?.message || '체크리스트를 불러올 수 없습니다.');
}
return response.data;
} catch (error) {
console.error(' 필터링된 안전 체크 조회 오류:', error);
throw error;
}
}
/**
* TBM 안전 체크 저장
*/
async saveSafetyChecks(sessionId, records) {
try {
const response = await window.apiCall(
`/tbm/sessions/${sessionId}/safety`,
'POST',
{ records }
);
if (!response || !response.success) {
throw new Error(response?.message || '저장 실패');
}
return response;
} catch (error) {
console.error(' 안전 체크 저장 오류:', error);
throw error;
}
}
/**
* TBM 세션 완료 처리
*/
async completeTbmSession(sessionId, endTime) {
try {
const response = await window.apiCall(
`/tbm/sessions/${sessionId}/complete`,
'POST',
{ end_time: endTime }
);
if (!response || !response.success) {
throw new Error(response?.message || '완료 처리 실패');
}
return response;
} catch (error) {
console.error(' TBM 완료 처리 오류:', error);
throw error;
}
}
/**
* 작업 인계 저장
*/
async saveHandover(handoverData) {
try {
const response = await window.apiCall('/tbm/handovers', 'POST', handoverData);
if (!response || !response.success) {
throw new Error(response?.message || '인계 요청 실패');
}
return response;
} catch (error) {
console.error(' 작업 인계 저장 오류:', error);
throw error;
}
}
/**
* 카테고리별 작업장 로드
*/
async loadWorkplacesByCategory(categoryId) {
try {
const response = await window.apiCall(`/workplaces?category_id=${categoryId}`);
if (!response || !response.success || !response.data) {
return [];
}
return response.data;
} catch (error) {
console.error(' 작업장 로드 오류:', error);
return [];
}
}
/**
* 작업장 지도 영역 로드
*/
async loadMapRegions(categoryId) {
try {
const response = await window.apiCall(`/workplaces/categories/${categoryId}/map-regions`);
if (response && response.success) {
this.state.mapRegions = response.data || [];
return this.state.mapRegions;
}
return [];
} catch (error) {
console.error(' 지도 영역 로드 오류:', error);
return [];
}
}
/**
* TBM 세션 삭제
*/
async deleteSession(sessionId) {
try {
const response = await window.apiCall(`/tbm/sessions/${sessionId}`, 'DELETE');
if (!response || !response.success) {
throw new Error(response?.message || '삭제 실패');
}
return response;
} catch (error) {
console.error(' TBM 세션 삭제 오류:', error);
throw error;
}
}
/**
* TBM 팀원 분할 배정
*/
async splitAssignment(sessionId, splitData) {
try {
const response = await window.apiCall(
`/tbm/sessions/${sessionId}/team/split`,
'POST',
splitData
);
if (!response || !response.success) {
throw new Error(response?.message || '분할 실패');
}
return response;
} catch (error) {
console.error(' 분할 배정 오류:', error);
throw error;
}
}
/**
* TBM 팀원 단일 추가/수정 (POST /team)
*/
async updateTeamMember(sessionId, memberData) {
try {
const response = await window.apiCall(
`/tbm/sessions/${sessionId}/team`,
'POST',
memberData
);
if (!response || !response.success) {
throw new Error(response?.message || '팀원 수정 실패');
}
return response;
} catch (error) {
console.error(' 팀원 수정 오류:', error);
throw error;
}
}
/**
* TBM 인원 이동 (분할→이동 / 빼오기)
*/
async transfer(transferData) {
try {
const response = await window.apiCall('/tbm/transfers', 'POST', transferData);
if (!response || !response.success) {
throw new Error(response?.message || '이동 실패');
}
return response;
} catch (error) {
console.error(' TBM 이동 오류:', error);
throw error;
}
}
/**
* 작업 생성
*/
async createTask(taskData) {
try {
const response = await window.apiCall('/tasks', 'POST', taskData);
if (!response || !response.success) {
throw new Error(response?.message || '작업 생성 실패');
}
return response;
} catch (error) {
console.error(' 작업 생성 오류:', error);
throw error;
}
}
/**
* 활성 작업장 전체 목록 (active/list)
*/
async loadActiveWorkplacesList() {
try {
const response = await window.apiCall('/workplaces/active/list');
if (response && response.success) {
return response.data || [];
}
return [];
} catch (error) {
console.error(' 활성 작업장 목록 오류:', error);
return [];
}
}
/**
* 당일 배정 현황 조회
*/
async loadTodayAssignments(date) {
try {
const response = await window.apiCall(`/tbm/sessions/date/${date}/assignments`);
if (response && response.success) {
return response.data || [];
}
return [];
} catch (error) {
console.error(' 배정 현황 조회 오류:', error);
return [];
}
}
/**
* 특정 날짜의 TBM 세션 조회 (raw - 상태 변경 없음)
*/
async fetchSessionsByDate(date) {
try {
const response = await window.apiCall(`/tbm/sessions/date/${date}`);
if (response && response.success) {
return response.data || [];
}
return [];
} catch (error) {
console.error(' TBM 세션 조회 오류:', error);
return [];
}
}
/**
* TBM 세션 완료 처리 (근태 정보 포함)
*/
async completeTbmWithAttendance(sessionId, attendanceData) {
try {
const response = await window.apiCall(
`/tbm/sessions/${sessionId}/complete`,
'POST',
{ attendance: attendanceData }
);
if (!response || !response.success) {
throw new Error(response?.message || '완료 처리 실패');
}
return response;
} catch (error) {
console.error(' TBM 완료 처리 오류:', error);
throw error;
}
}
}
// 전역 인스턴스 생성
window.TbmAPI = new TbmAPI();
// 하위 호환성: 기존 함수들
window.loadInitialData = () => window.TbmAPI.loadInitialData();
window.loadTodayOnlyTbm = () => window.TbmAPI.loadTodayOnlyTbm();
window.loadTodayTbm = () => window.TbmAPI.loadRecentTbmGroupedByDate();
window.loadAllTbm = () => {
window.TbmState.loadedDaysCount = 30;
return window.TbmAPI.loadRecentTbmGroupedByDate();
};
window.loadRecentTbmGroupedByDate = () => window.TbmAPI.loadRecentTbmGroupedByDate();
window.loadTbmSessionsByDate = (date) => window.TbmAPI.loadTbmSessionsByDate(date);
window.loadWorkplaceCategories = () => window.TbmAPI.loadWorkplaceCategories();
window.loadWorkplacesByCategory = (categoryId) => window.TbmAPI.loadWorkplacesByCategory(categoryId);
// 더 많은 날짜 로드
window.loadMoreTbmDays = async function() {
window.TbmState.loadedDaysCount += 7;
await window.TbmAPI.loadRecentTbmGroupedByDate();
window.showToast?.(`최근 ${window.TbmState.loadedDaysCount}일의 TBM을 로드했습니다.`, 'success');
};
window.deleteTbmSession = (sessionId) => window.TbmAPI.deleteSession(sessionId);
window.fetchSessionsByDate = (date) => window.TbmAPI.fetchSessionsByDate(date);
console.log('[Module] tbm/api.js 로드 완료');

View File

@@ -1,322 +0,0 @@
/**
* TBM - State Manager
* TBM 페이지의 전역 상태 관리 (BaseState 상속)
*/
class TbmState extends BaseState {
constructor() {
super();
// 세션 데이터
this.allSessions = [];
this.todaySessions = [];
this.dateGroupedSessions = {};
this.allLoadedSessions = [];
this.loadedDaysCount = 7;
// 마스터 데이터
this.allWorkers = [];
this.allProjects = [];
this.allWorkTypes = [];
this.allTasks = [];
this.allSafetyChecks = [];
this.allWorkplaces = [];
this.allWorkplaceCategories = [];
// 현재 상태
this.currentUser = null;
this.currentSessionId = null;
this.currentTab = 'tbm-input';
// 작업자 관련
this.selectedWorkers = new Set();
this.workerTaskList = [];
this.selectedWorkersInModal = new Set();
this.currentEditingTaskLine = null;
// 작업장 선택 관련
this.selectedCategory = null;
this.selectedWorkplace = null;
this.selectedCategoryName = '';
this.selectedWorkplaceName = '';
// 일괄 설정 관련
this.isBulkMode = false;
this.bulkSelectedWorkers = new Set();
// 지도 관련
this.mapCanvas = null;
this.mapCtx = null;
this.mapImage = null;
this.mapRegions = [];
console.log('[TbmState] 초기화 완료');
}
/**
* Admin 여부 확인
*/
isAdminUser() {
const user = this.getUser();
if (!user) return false;
const role = (user.role || '').toLowerCase();
return role === 'admin' || role === 'system admin' || role === 'system';
}
/**
* 탭 변경
*/
setCurrentTab(tab) {
const prevTab = this.currentTab;
this.currentTab = tab;
this.notifyListeners('currentTab', tab, prevTab);
}
/**
* 작업자 목록에 추가
*/
addWorkerToList(worker) {
this.workerTaskList.push({
user_id: worker.user_id,
worker_name: worker.worker_name,
job_type: worker.job_type,
tasks: [this.createEmptyTaskLine()]
});
this.notifyListeners('workerTaskList', this.workerTaskList, null);
}
/**
* 빈 작업 라인 생성
*/
createEmptyTaskLine() {
return {
task_line_id: window.CommonUtils.generateUUID(),
project_id: null,
work_type_id: null,
task_id: null,
workplace_category_id: null,
workplace_id: null,
workplace_category_name: '',
workplace_name: '',
work_detail: null,
is_present: true
};
}
/**
* 작업자에 작업 라인 추가
*/
addTaskLineToWorker(workerIndex) {
if (this.workerTaskList[workerIndex]) {
this.workerTaskList[workerIndex].tasks.push(this.createEmptyTaskLine());
this.notifyListeners('workerTaskList', this.workerTaskList, null);
}
}
/**
* 작업 라인 제거
*/
removeTaskLine(workerIndex, taskIndex) {
if (this.workerTaskList[workerIndex]?.tasks) {
this.workerTaskList[workerIndex].tasks.splice(taskIndex, 1);
this.notifyListeners('workerTaskList', this.workerTaskList, null);
}
}
/**
* 작업자 제거
*/
removeWorkerFromList(workerIndex) {
const removed = this.workerTaskList.splice(workerIndex, 1);
this.notifyListeners('workerTaskList', this.workerTaskList, null);
return removed[0];
}
/**
* 작업장 선택 초기화
*/
resetWorkplaceSelection() {
this.selectedCategory = null;
this.selectedWorkplace = null;
this.selectedCategoryName = '';
this.selectedWorkplaceName = '';
this.mapCanvas = null;
this.mapCtx = null;
this.mapImage = null;
this.mapRegions = [];
}
/**
* 일괄 설정 초기화
*/
resetBulkSettings() {
this.isBulkMode = false;
this.bulkSelectedWorkers.clear();
}
/**
* 날짜별 세션 그룹화
*/
groupSessionsByDate(sessions) {
this.dateGroupedSessions = {};
this.allLoadedSessions = [];
sessions.forEach(session => {
const date = window.CommonUtils.formatDate(session.session_date);
if (!this.dateGroupedSessions[date]) {
this.dateGroupedSessions[date] = [];
}
this.dateGroupedSessions[date].push(session);
this.allLoadedSessions.push(session);
});
}
/**
* 상태 초기화
*/
reset() {
this.workerTaskList = [];
this.selectedWorkers.clear();
this.selectedWorkersInModal.clear();
this.currentEditingTaskLine = null;
this.resetWorkplaceSelection();
this.resetBulkSettings();
}
/**
* 디버그 출력
*/
debug() {
console.log('[TbmState] 현재 상태:', {
allSessions: this.allSessions.length,
todaySessions: this.todaySessions.length,
allWorkers: this.allWorkers.length,
allProjects: this.allProjects.length,
workerTaskList: this.workerTaskList.length,
currentTab: this.currentTab
});
}
}
// 전역 인스턴스 생성
window.TbmState = new TbmState();
// 하위 호환성을 위한 전역 변수 프록시
const tbmStateProxy = window.TbmState;
Object.defineProperties(window, {
allSessions: {
get: () => tbmStateProxy.allSessions,
set: (v) => { tbmStateProxy.allSessions = v; }
},
todaySessions: {
get: () => tbmStateProxy.todaySessions,
set: (v) => { tbmStateProxy.todaySessions = v; }
},
allWorkers: {
get: () => tbmStateProxy.allWorkers,
set: (v) => { tbmStateProxy.allWorkers = v; }
},
allProjects: {
get: () => tbmStateProxy.allProjects,
set: (v) => { tbmStateProxy.allProjects = v; }
},
allWorkTypes: {
get: () => tbmStateProxy.allWorkTypes,
set: (v) => { tbmStateProxy.allWorkTypes = v; }
},
allTasks: {
get: () => tbmStateProxy.allTasks,
set: (v) => { tbmStateProxy.allTasks = v; }
},
allSafetyChecks: {
get: () => tbmStateProxy.allSafetyChecks,
set: (v) => { tbmStateProxy.allSafetyChecks = v; }
},
allWorkplaces: {
get: () => tbmStateProxy.allWorkplaces,
set: (v) => { tbmStateProxy.allWorkplaces = v; }
},
allWorkplaceCategories: {
get: () => tbmStateProxy.allWorkplaceCategories,
set: (v) => { tbmStateProxy.allWorkplaceCategories = v; }
},
currentUser: {
get: () => tbmStateProxy.currentUser,
set: (v) => { tbmStateProxy.currentUser = v; }
},
currentSessionId: {
get: () => tbmStateProxy.currentSessionId,
set: (v) => { tbmStateProxy.currentSessionId = v; }
},
selectedWorkers: {
get: () => tbmStateProxy.selectedWorkers,
set: (v) => { tbmStateProxy.selectedWorkers = v; }
},
workerTaskList: {
get: () => tbmStateProxy.workerTaskList,
set: (v) => { tbmStateProxy.workerTaskList = v; }
},
selectedWorkersInModal: {
get: () => tbmStateProxy.selectedWorkersInModal,
set: (v) => { tbmStateProxy.selectedWorkersInModal = v; }
},
currentEditingTaskLine: {
get: () => tbmStateProxy.currentEditingTaskLine,
set: (v) => { tbmStateProxy.currentEditingTaskLine = v; }
},
selectedCategory: {
get: () => tbmStateProxy.selectedCategory,
set: (v) => { tbmStateProxy.selectedCategory = v; }
},
selectedWorkplace: {
get: () => tbmStateProxy.selectedWorkplace,
set: (v) => { tbmStateProxy.selectedWorkplace = v; }
},
selectedCategoryName: {
get: () => tbmStateProxy.selectedCategoryName,
set: (v) => { tbmStateProxy.selectedCategoryName = v; }
},
selectedWorkplaceName: {
get: () => tbmStateProxy.selectedWorkplaceName,
set: (v) => { tbmStateProxy.selectedWorkplaceName = v; }
},
isBulkMode: {
get: () => tbmStateProxy.isBulkMode,
set: (v) => { tbmStateProxy.isBulkMode = v; }
},
bulkSelectedWorkers: {
get: () => tbmStateProxy.bulkSelectedWorkers,
set: (v) => { tbmStateProxy.bulkSelectedWorkers = v; }
},
dateGroupedSessions: {
get: () => tbmStateProxy.dateGroupedSessions,
set: (v) => { tbmStateProxy.dateGroupedSessions = v; }
},
allLoadedSessions: {
get: () => tbmStateProxy.allLoadedSessions,
set: (v) => { tbmStateProxy.allLoadedSessions = v; }
},
loadedDaysCount: {
get: () => tbmStateProxy.loadedDaysCount,
set: (v) => { tbmStateProxy.loadedDaysCount = v; }
},
mapRegions: {
get: () => tbmStateProxy.mapRegions,
set: (v) => { tbmStateProxy.mapRegions = v; }
},
mapCanvas: {
get: () => tbmStateProxy.mapCanvas,
set: (v) => { tbmStateProxy.mapCanvas = v; }
},
mapCtx: {
get: () => tbmStateProxy.mapCtx,
set: (v) => { tbmStateProxy.mapCtx = v; }
},
mapImage: {
get: () => tbmStateProxy.mapImage,
set: (v) => { tbmStateProxy.mapImage = v; }
}
});
console.log('[Module] tbm/state.js 로드 완료');

View File

@@ -1,127 +0,0 @@
/**
* TBM - Utilities
* TBM 관련 유틸리티 함수들 (공통 함수는 CommonUtils에 위임)
*/
class TbmUtils {
constructor() {
this._common = window.CommonUtils;
console.log('[TbmUtils] 초기화 완료');
}
// --- CommonUtils 위임 ---
getTodayKST() { return this._common.getTodayKST(); }
formatDate(dateString) { return this._common.formatDate(dateString); }
getDayOfWeek(dateString) { return this._common.getDayOfWeek(dateString); }
isToday(dateString) { return this._common.isToday(dateString); }
generateUUID() { return this._common.generateUUID(); }
escapeHtml(text) { return this._common.escapeHtml(text); }
// --- TBM 전용 ---
/**
* 날짜 표시용 포맷 (MM월 DD일)
*/
formatDateDisplay(dateString) {
if (!dateString) return '';
const [year, month, day] = dateString.split('-');
return `${parseInt(month)}${parseInt(day)}`;
}
/**
* 날짜를 연/월/일/요일 형식으로 포맷
*/
formatDateFull(dateString) {
if (!dateString) return '';
const [year, month, day] = dateString.split('-');
const dayName = this._common.getDayOfWeek(dateString);
return `${year}${parseInt(month)}${parseInt(day)}일 (${dayName})`;
}
/**
* 현재 시간을 HH:MM 형식으로 반환
*/
getCurrentTime() {
return new Date().toTimeString().slice(0, 5);
}
/**
* 날씨 조건명 반환
*/
getWeatherConditionName(code) {
const names = {
clear: '맑음', rain: '비', snow: '눈', heat: '폭염',
cold: '한파', wind: '강풍', fog: '안개', dust: '미세먼지'
};
return names[code] || code;
}
/**
* 날씨 아이콘 반환
*/
getWeatherIcon(code) {
const icons = {
clear: '☀️', rain: '🌧️', snow: '❄️', heat: '🔥',
cold: '🥶', wind: '💨', fog: '🌫️', dust: '😷'
};
return icons[code] || '🌤️';
}
/**
* 카테고리명 반환
*/
getCategoryName(category) {
const names = {
'PPE': '개인 보호 장비', 'EQUIPMENT': '장비 점검',
'ENVIRONMENT': '작업 환경', 'EMERGENCY': '비상 대응',
'WEATHER': '날씨', 'TASK': '작업'
};
return names[category] || category;
}
/**
* 상태 배지 HTML 반환
*/
getStatusBadge(status) {
const badges = {
'draft': '<span class="tbm-card-status draft">진행중</span>',
'completed': '<span class="tbm-card-status completed">완료</span>',
'cancelled': '<span class="tbm-card-status cancelled">취소</span>'
};
return badges[status] || '';
}
}
// 전역 인스턴스 생성
window.TbmUtils = new TbmUtils();
// 하위 호환성: TBM 전용 유틸 (showToast, formatDate, waitForApi, generateUUID는 api-base.js 전역)
window.getTodayKST = () => window.TbmUtils.getTodayKST();
// 카테고리별 그룹화
window.groupChecksByCategory = function(checks) {
return checks.reduce((acc, check) => {
const category = check.check_category || 'OTHER';
if (!acc[category]) acc[category] = [];
acc[category].push(check);
return acc;
}, {});
};
// 작업별 그룹화
window.groupChecksByTask = function(checks) {
return checks.reduce((acc, check) => {
const taskId = check.task_id || 0;
const taskName = check.task_name || '기타 작업';
if (!acc[taskId]) acc[taskId] = { name: taskName, items: [] };
acc[taskId].items.push(check);
return acc;
}, {});
};
// Admin 사용자 확인
window.isAdminUser = function() {
return window.TbmState?.isAdminUser() || false;
};
console.log('[Module] tbm/utils.js 로드 완료');

View File

@@ -1,93 +0,0 @@
// /js/user-dashboard.js
import { getUser } from './auth.js';
import { apiGet } from './api-helper.js'; // 개선된 api-helper를 사용합니다.
/**
* API를 호출하여 오늘의 작업 일정을 불러와 화면에 표시합니다.
*/
async function loadTodaySchedule() {
const scheduleContainer = document.getElementById('today-schedule');
scheduleContainer.innerHTML = '<p>📅 오늘의 작업 일정을 불러오는 중...</p>';
try {
// 예시: /api/dashboard/today-schedule 엔드포인트에서 데이터를 가져옵니다.
// 실제 엔드포인트는 백엔드 구현에 따라 달라질 수 있습니다.
const scheduleData = await apiGet('/dashboard/today-schedule');
if (scheduleData && scheduleData.length > 0) {
const scheduleHtml = scheduleData.map(item => `
<div class="schedule-item">
<span class="time">${item.time}</span>
<span class="task">${item.task_name}</span>
<span class="status ${item.status}">${item.status_kor}</span>
</div>
`).join('');
scheduleContainer.innerHTML = scheduleHtml;
} else {
scheduleContainer.innerHTML = '<p>오늘 예정된 작업이 없습니다.</p>';
}
} catch (error) {
console.error('오늘의 작업 일정 로드 실패:', error);
scheduleContainer.innerHTML = '<p class="error">일정 정보를 불러오는 데 실패했습니다.</p>';
}
}
/**
* API를 호출하여 현재 사용자의 작업 통계를 불러와 화면에 표시합니다.
*/
async function loadWorkStats() {
const statsContainer = document.getElementById('work-stats');
statsContainer.innerHTML = '<p>📈 내 작업 현황을 불러오는 중...</p>';
try {
// 예시: /api/dashboard/my-stats 엔드포인트에서 데이터를 가져옵니다.
const statsData = await apiGet('/dashboard/my-stats');
if (statsData) {
const statsHtml = `
<div class="stat-item">
<span>이번 주 작업 시간:</span>
<strong>${statsData.weekly_hours || 0} 시간</strong>
</div>
<div class="stat-item">
<span>이번 달 작업 시간:</span>
<strong>${statsData.monthly_hours || 0} 시간</strong>
</div>
<div class="stat-item">
<span>완료한 작업 수:</span>
<strong>${statsData.completed_tasks || 0} 건</strong>
</div>
`;
statsContainer.innerHTML = statsHtml;
} else {
statsContainer.innerHTML = '<p>표시할 통계 정보가 없습니다.</p>';
}
} catch (error) {
console.error('작업 통계 로드 실패:', error);
statsContainer.innerHTML = '<p class="error">통계 정보를 불러오는 데 실패했습니다.</p>';
}
}
/**
* 환영 메시지를 사용자 이름으로 개인화합니다.
*/
function personalizeWelcome() {
// 전역 변수 대신 auth.js 모듈을 통해 사용자 정보를 가져옵니다.
const user = getUser();
if (user) {
const welcomeEl = document.getElementById('welcome-message');
if (welcomeEl) {
welcomeEl.textContent = `${user.name || user.username}님, 환영합니다! 오늘 하루도 안전하게 작업하세요.`;
}
}
}
// 페이지 초기화 함수
function initializeDashboard() {
personalizeWelcome();
loadTodaySchedule();
loadWorkStats();
}
// DOM이 로드되면 대시보드 초기화를 시작합니다.
document.addEventListener('DOMContentLoaded', initializeDashboard);

View File

@@ -1,855 +0,0 @@
/**
* vacation-allocation.js
* 휴가 발생 입력 페이지 로직
*/
import { API_BASE_URL } from './api-config.js';
// 전역 변수
let workers = [];
let vacationTypes = [];
let currentWorkerBalances = [];
/**
* 페이지 초기화
*/
document.addEventListener('DOMContentLoaded', async () => {
// 관리자 권한 체크
const user = JSON.parse(localStorage.getItem('sso_user') || '{}');
console.log('Current user:', user);
console.log('Role ID:', user.role_id, 'Role:', user.role);
// role이 'Admin'이거나 role_id가 1 또는 2인 경우 허용
const isAdmin = user.role === 'Admin' || [1, 2].includes(user.role_id);
if (!isAdmin) {
console.error('Access denied. User:', user);
alert('관리자만 접근할 수 있습니다');
window.location.href = '/pages/dashboard.html';
return;
}
await loadInitialData();
initializeYearSelectors();
initializeTabNavigation();
initializeEventListeners();
});
/**
* 초기 데이터 로드
*/
async function loadInitialData() {
await Promise.all([
loadWorkers(),
loadVacationTypes()
]);
}
/**
* 작업자 목록 로드
*/
async function loadWorkers() {
try {
const token = localStorage.getItem('sso_token');
console.log('Loading workers... Token:', token ? 'exists' : 'missing');
const response = await fetch(`${API_BASE_URL}/api/workers`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
console.log('Workers API Response status:', response.status);
if (!response.ok) {
const errorData = await response.json();
console.error('Workers API Error:', errorData);
throw new Error(errorData.message || '작업자 목록 로드 실패');
}
const result = await response.json();
console.log('Workers data:', result);
workers = result.data || [];
if (workers.length === 0) {
console.warn('No workers found in database');
showToast('등록된 작업자가 없습니다', 'warning');
return;
}
// 개별 입력 탭 - 작업자 셀렉트 박스 (부서별 그룹)
const selectWorker = document.getElementById('individualWorker');
const byDept = {};
workers.forEach(worker => {
const dept = worker.department_name || '부서 미지정';
if (!byDept[dept]) byDept[dept] = [];
byDept[dept].push(worker);
});
Object.keys(byDept).sort().forEach(dept => {
const group = document.createElement('optgroup');
group.label = dept;
byDept[dept].forEach(worker => {
const option = document.createElement('option');
option.value = worker.user_id;
option.textContent = `${worker.worker_name} (${worker.employment_status === 'employed' ? '재직' : '퇴사'})`;
group.appendChild(option);
});
selectWorker.appendChild(group);
});
console.log(`Loaded ${workers.length} workers successfully`);
} catch (error) {
console.error('작업자 로드 오류:', error);
showToast(`작업자 목록을 불러오는데 실패했습니다: ${error.message}`, 'error');
}
}
/**
* 휴가 유형 목록 로드
*/
async function loadVacationTypes() {
try {
const token = localStorage.getItem('sso_token');
const response = await fetch(`${API_BASE_URL}/api/vacation-types`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) throw new Error('휴가 유형 로드 실패');
const result = await response.json();
vacationTypes = result.data || [];
// 개별 입력 탭 - 휴가 유형 셀렉트 박스
const selectType = document.getElementById('individualVacationType');
vacationTypes.forEach(type => {
const option = document.createElement('option');
option.value = type.id;
option.textContent = `${type.type_name} ${type.is_special ? '(특별)' : ''}`;
selectType.appendChild(option);
});
// 특별 휴가 관리 탭 테이블 로드
loadSpecialTypesTable();
} catch (error) {
console.error('휴가 유형 로드 오류:', error);
showToast('휴가 유형을 불러오는데 실패했습니다', 'error');
}
}
/**
* 연도 셀렉터 초기화
*/
function initializeYearSelectors() {
const currentYear = new Date().getFullYear();
const yearSelectors = ['individualYear', 'bulkYear'];
yearSelectors.forEach(selectorId => {
const select = document.getElementById(selectorId);
for (let year = currentYear - 1; year <= currentYear + 2; year++) {
const option = document.createElement('option');
option.value = year;
option.textContent = `${year}`;
if (year === currentYear) {
option.selected = true;
}
select.appendChild(option);
}
});
}
/**
* 탭 네비게이션 초기화
*/
function initializeTabNavigation() {
const tabButtons = document.querySelectorAll('.tab-button');
tabButtons.forEach(button => {
button.addEventListener('click', () => {
const tabName = button.dataset.tab;
switchTab(tabName);
});
});
}
/**
* 탭 전환
*/
function switchTab(tabName) {
// 탭 버튼 활성화
document.querySelectorAll('.tab-button').forEach(btn => {
btn.classList.remove('active');
});
document.querySelector(`[data-tab="${tabName}"]`).classList.add('active');
// 탭 콘텐츠 표시
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.remove('active');
});
document.getElementById(`tab-${tabName}`).classList.add('active');
}
/**
* 이벤트 리스너 초기화
*/
function initializeEventListeners() {
// === 탭 1: 개별 입력 ===
document.getElementById('individualWorker').addEventListener('change', loadWorkerBalances);
document.getElementById('autoCalculateBtn').addEventListener('click', autoCalculateAnnualLeave);
document.getElementById('individualSubmitBtn').addEventListener('click', submitIndividualVacation);
document.getElementById('individualResetBtn').addEventListener('click', resetIndividualForm);
// === 탭 2: 일괄 입력 ===
document.getElementById('bulkPreviewBtn').addEventListener('click', previewBulkAllocation);
document.getElementById('bulkSubmitBtn').addEventListener('click', submitBulkAllocation);
// === 탭 3: 특별 휴가 관리 ===
document.getElementById('addSpecialTypeBtn').addEventListener('click', () => openVacationTypeModal());
// 모달 닫기
document.querySelectorAll('.modal-close').forEach(btn => {
btn.addEventListener('click', closeModals);
});
// 모달 폼 제출
document.getElementById('vacationTypeForm').addEventListener('submit', submitVacationType);
document.getElementById('editBalanceForm').addEventListener('submit', submitEditBalance);
}
// =============================================================================
// 탭 1: 개별 입력
// =============================================================================
/**
* 작업자의 기존 휴가 잔액 로드
*/
async function loadWorkerBalances() {
const workerId = document.getElementById('individualWorker').value;
const year = document.getElementById('individualYear').value;
if (!workerId) {
document.getElementById('individualTableBody').innerHTML = `
<tr><td colspan="8" class="loading-state"><p>작업자를 선택하세요</p></td></tr>
`;
return;
}
try {
const token = localStorage.getItem('sso_token');
const response = await fetch(`${API_BASE_URL}/api/vacation-balances/worker/${workerId}/year/${year}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) throw new Error('휴가 잔액 로드 실패');
const result = await response.json();
currentWorkerBalances = result.data || [];
updateWorkerBalancesTable();
} catch (error) {
console.error('휴가 잔액 로드 오류:', error);
showToast('휴가 잔액을 불러오는데 실패했습니다', 'error');
}
}
/**
* 작업자 휴가 잔액 테이블 업데이트
*/
function updateWorkerBalancesTable() {
const tbody = document.getElementById('individualTableBody');
if (currentWorkerBalances.length === 0) {
tbody.innerHTML = `
<tr><td colspan="8" class="loading-state"><p>등록된 휴가가 없습니다</p></td></tr>
`;
return;
}
tbody.innerHTML = currentWorkerBalances.map(balance => `
<tr>
<td>${balance.worker_name || '-'}</td>
<td>${balance.year}</td>
<td>${balance.type_name} ${balance.is_special ? '<span class="badge badge-info">특별</span>' : ''}</td>
<td>${balance.total_days}일</td>
<td>${balance.used_days}일</td>
<td>${balance.remaining_days}일</td>
<td>${balance.notes || '-'}</td>
<td class="action-buttons">
<button class="btn btn-sm btn-secondary btn-icon" onclick="window.editBalance(${balance.id})">✏️</button>
<button class="btn btn-sm btn-danger btn-icon" onclick="window.deleteBalance(${balance.id})">🗑️</button>
</td>
</tr>
`).join('');
}
/**
* 자동 계산 (연차만 해당)
*/
async function autoCalculateAnnualLeave() {
const workerId = document.getElementById('individualWorker').value;
const year = document.getElementById('individualYear').value;
const typeId = document.getElementById('individualVacationType').value;
if (!workerId) {
showToast('작업자를 선택하세요', 'warning');
return;
}
// 선택한 휴가 유형이 ANNUAL인지 확인
const selectedType = vacationTypes.find(t => t.id == typeId);
if (!selectedType || selectedType.type_code !== 'ANNUAL') {
showToast('연차(ANNUAL) 유형만 자동 계산이 가능합니다', 'warning');
return;
}
// 작업자의 입사일 조회
const worker = workers.find(w => w.user_id == workerId);
if (!worker || !worker.hire_date) {
showToast('작업자의 입사일 정보가 없습니다', 'error');
return;
}
try {
const token = localStorage.getItem('sso_token');
const response = await fetch(`${API_BASE_URL}/api/vacation-balances/auto-calculate`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
user_id: workerId,
hire_date: worker.hire_date,
year: year
})
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || '자동 계산 실패');
}
// 계산 결과 표시
const resultDiv = document.getElementById('autoCalculateResult');
resultDiv.innerHTML = `
<strong>자동 계산 완료</strong><br>
입사일: ${worker.hire_date}<br>
계산된 연차: ${result.data.calculated_days}일<br>
아래 "총 부여 일수"에 자동으로 입력됩니다.
`;
resultDiv.style.display = 'block';
// 폼에 자동 입력
document.getElementById('individualTotalDays').value = result.data.calculated_days;
document.getElementById('individualNotes').value = `근속년수 기반 자동 계산 (입사일: ${worker.hire_date})`;
showToast(result.message, 'success');
// 기존 데이터 새로고침
await loadWorkerBalances();
} catch (error) {
console.error('자동 계산 오류:', error);
showToast(error.message, 'error');
}
}
/**
* 개별 휴가 제출
*/
async function submitIndividualVacation() {
const workerId = document.getElementById('individualWorker').value;
const year = document.getElementById('individualYear').value;
const typeId = document.getElementById('individualVacationType').value;
const totalDays = document.getElementById('individualTotalDays').value;
const usedDays = document.getElementById('individualUsedDays').value || 0;
const notes = document.getElementById('individualNotes').value;
if (!workerId || !year || !typeId || !totalDays) {
showToast('필수 항목을 모두 입력하세요', 'warning');
return;
}
try {
const token = localStorage.getItem('sso_token');
const response = await fetch(`${API_BASE_URL}/api/vacation-balances`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
user_id: workerId,
vacation_type_id: typeId,
year: year,
total_days: parseFloat(totalDays),
used_days: parseFloat(usedDays),
notes: notes
})
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || '저장 실패');
}
showToast('휴가가 등록되었습니다', 'success');
resetIndividualForm();
await loadWorkerBalances();
} catch (error) {
console.error('휴가 등록 오류:', error);
showToast(error.message, 'error');
}
}
/**
* 개별 입력 폼 초기화
*/
function resetIndividualForm() {
document.getElementById('individualVacationType').value = '';
document.getElementById('individualTotalDays').value = '';
document.getElementById('individualUsedDays').value = '0';
document.getElementById('individualNotes').value = '';
document.getElementById('autoCalculateResult').style.display = 'none';
}
/**
* 휴가 수정 (전역 함수로 노출)
*/
window.editBalance = function(balanceId) {
const balance = currentWorkerBalances.find(b => b.id === balanceId);
if (!balance) return;
document.getElementById('editBalanceId').value = balance.id;
document.getElementById('editTotalDays').value = balance.total_days;
document.getElementById('editUsedDays').value = balance.used_days;
document.getElementById('editNotes').value = balance.notes || '';
document.getElementById('editBalanceModal').classList.add('active');
};
/**
* 휴가 삭제 (전역 함수로 노출)
*/
window.deleteBalance = async function(balanceId) {
if (!confirm('정말 삭제하시겠습니까?')) return;
try {
const token = localStorage.getItem('sso_token');
const response = await fetch(`${API_BASE_URL}/api/vacation-balances/${balanceId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || '삭제 실패');
}
showToast('삭제되었습니다', 'success');
await loadWorkerBalances();
} catch (error) {
console.error('삭제 오류:', error);
showToast(error.message, 'error');
}
};
/**
* 휴가 수정 제출
*/
async function submitEditBalance(e) {
e.preventDefault();
const balanceId = document.getElementById('editBalanceId').value;
const totalDays = document.getElementById('editTotalDays').value;
const usedDays = document.getElementById('editUsedDays').value;
const notes = document.getElementById('editNotes').value;
try {
const token = localStorage.getItem('sso_token');
const response = await fetch(`${API_BASE_URL}/api/vacation-balances/${balanceId}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
total_days: parseFloat(totalDays),
used_days: parseFloat(usedDays),
notes: notes
})
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || '수정 실패');
}
showToast('수정되었습니다', 'success');
closeModals();
await loadWorkerBalances();
} catch (error) {
console.error('수정 오류:', error);
showToast(error.message, 'error');
}
}
// =============================================================================
// 탭 2: 일괄 입력
// =============================================================================
let bulkPreviewData = [];
/**
* 일괄 할당 미리보기
*/
async function previewBulkAllocation() {
const year = document.getElementById('bulkYear').value;
const employmentStatus = document.getElementById('bulkEmploymentStatus').value;
// 필터링된 작업자 목록
let targetWorkers = workers;
if (employmentStatus === 'employed') {
targetWorkers = workers.filter(w => w.employment_status === 'employed');
}
// ANNUAL 유형 찾기
const annualType = vacationTypes.find(t => t.type_code === 'ANNUAL');
if (!annualType) {
showToast('ANNUAL 휴가 유형이 없습니다', 'error');
return;
}
// 미리보기 데이터 생성
bulkPreviewData = targetWorkers.map(worker => {
const hireDate = worker.hire_date;
if (!hireDate) {
return {
user_id: worker.user_id,
worker_name: worker.worker_name,
hire_date: '-',
years_worked: '-',
calculated_days: 0,
reason: '입사일 정보 없음',
status: 'error'
};
}
const calculatedDays = calculateAnnualLeaveDays(hireDate, year);
const yearsWorked = calculateYearsWorked(hireDate, year);
return {
user_id: worker.user_id,
worker_name: worker.worker_name,
hire_date: hireDate,
years_worked: yearsWorked,
calculated_days: calculatedDays,
reason: getCalculationReason(yearsWorked, calculatedDays),
status: 'ready'
};
});
updateBulkPreviewTable();
document.getElementById('bulkPreviewSection').style.display = 'block';
document.getElementById('bulkSubmitBtn').disabled = false;
}
/**
* 연차 일수 계산 (한국 근로기준법)
*/
function calculateAnnualLeaveDays(hireDate, targetYear) {
const hire = new Date(hireDate);
const targetDate = new Date(targetYear, 0, 1);
const monthsDiff = (targetDate.getFullYear() - hire.getFullYear()) * 12
+ (targetDate.getMonth() - hire.getMonth());
// 1년 미만: 월 1일
if (monthsDiff < 12) {
return Math.floor(monthsDiff);
}
// 1년 이상: 15일 기본 + 2년마다 1일 추가 (최대 25일)
const yearsWorked = Math.floor(monthsDiff / 12);
const additionalDays = Math.floor((yearsWorked - 1) / 2);
return Math.min(15 + additionalDays, 25);
}
/**
* 근속년수 계산
*/
function calculateYearsWorked(hireDate, targetYear) {
const hire = new Date(hireDate);
const targetDate = new Date(targetYear, 0, 1);
const monthsDiff = (targetDate.getFullYear() - hire.getFullYear()) * 12
+ (targetDate.getMonth() - hire.getMonth());
return (monthsDiff / 12).toFixed(1);
}
/**
* 계산 근거 생성
*/
function getCalculationReason(yearsWorked, days) {
const years = parseFloat(yearsWorked);
if (years < 1) {
return `입사 ${Math.floor(years * 12)}개월 (월 1일)`;
}
if (days === 25) {
return '최대 25일 (근속 3년 이상)';
}
return `근속 ${Math.floor(years)}년 (15일 + ${days - 15}일)`;
}
/**
* 일괄 미리보기 테이블 업데이트
*/
function updateBulkPreviewTable() {
const tbody = document.getElementById('bulkPreviewTableBody');
tbody.innerHTML = bulkPreviewData.map(item => {
const statusBadge = item.status === 'error'
? '<span class="badge badge-error">오류</span>'
: '<span class="badge badge-success">준비</span>';
return `
<tr>
<td>${item.worker_name}</td>
<td>${item.hire_date}</td>
<td>${item.years_worked}년</td>
<td>${item.calculated_days}일</td>
<td>${item.reason}</td>
<td>${statusBadge}</td>
</tr>
`;
}).join('');
}
/**
* 일괄 할당 제출
*/
async function submitBulkAllocation() {
const year = document.getElementById('bulkYear').value;
// 오류가 없는 항목만 필터링
const validItems = bulkPreviewData.filter(item => item.status !== 'error' && item.calculated_days > 0);
if (validItems.length === 0) {
showToast('생성할 항목이 없습니다', 'warning');
return;
}
if (!confirm(`${validItems.length}명의 연차를 생성하시겠습니까?`)) {
return;
}
// ANNUAL 유형 찾기
const annualType = vacationTypes.find(t => t.type_code === 'ANNUAL');
let successCount = 0;
let failCount = 0;
for (const item of validItems) {
try {
const token = localStorage.getItem('sso_token');
const response = await fetch(`${API_BASE_URL}/api/vacation-balances/auto-calculate`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
user_id: item.user_id,
hire_date: item.hire_date,
year: year
})
});
if (response.ok) {
successCount++;
} else {
failCount++;
}
} catch (error) {
failCount++;
}
}
showToast(`완료: ${successCount}건 성공, ${failCount}건 실패`, successCount > 0 ? 'success' : 'error');
// 미리보기 초기화
document.getElementById('bulkPreviewSection').style.display = 'none';
document.getElementById('bulkSubmitBtn').disabled = true;
bulkPreviewData = [];
}
// =============================================================================
// 탭 3: 특별 휴가 관리
// =============================================================================
/**
* 특별 휴가 유형 테이블 로드
*/
function loadSpecialTypesTable() {
const tbody = document.getElementById('specialTypesTableBody');
if (vacationTypes.length === 0) {
tbody.innerHTML = `
<tr><td colspan="7" class="loading-state"><p>등록된 휴가 유형이 없습니다</p></td></tr>
`;
return;
}
tbody.innerHTML = vacationTypes.map(type => `
<tr>
<td>${type.type_name}</td>
<td>${type.type_code}</td>
<td>${type.priority}</td>
<td>${type.is_special ? '<span class="badge badge-info">특별</span>' : '-'}</td>
<td>${type.is_system ? '<span class="badge badge-warning">시스템</span>' : '-'}</td>
<td>${type.description || '-'}</td>
<td class="action-buttons">
<button class="btn btn-sm btn-secondary btn-icon" onclick="window.editVacationType(${type.id})" ${type.is_system ? 'disabled' : ''}>✏️</button>
<button class="btn btn-sm btn-danger btn-icon" onclick="window.deleteVacationType(${type.id})" ${type.is_system ? 'disabled' : ''}>🗑️</button>
</td>
</tr>
`).join('');
}
/**
* 휴가 유형 모달 열기
*/
function openVacationTypeModal(typeId = null) {
const modal = document.getElementById('vacationTypeModal');
const form = document.getElementById('vacationTypeForm');
form.reset();
if (typeId) {
const type = vacationTypes.find(t => t.id === typeId);
if (!type) return;
document.getElementById('modalTitle').textContent = '휴가 유형 수정';
document.getElementById('modalTypeId').value = type.id;
document.getElementById('modalTypeName').value = type.type_name;
document.getElementById('modalTypeCode').value = type.type_code;
document.getElementById('modalPriority').value = type.priority;
document.getElementById('modalIsSpecial').checked = type.is_special === 1;
document.getElementById('modalDescription').value = type.description || '';
} else {
document.getElementById('modalTitle').textContent = '휴가 유형 추가';
document.getElementById('modalTypeId').value = '';
}
modal.classList.add('active');
}
/**
* 휴가 유형 수정 (전역 함수)
*/
window.editVacationType = function(typeId) {
openVacationTypeModal(typeId);
};
/**
* 휴가 유형 삭제 (전역 함수)
*/
window.deleteVacationType = async function(typeId) {
if (!confirm('정말 삭제하시겠습니까?')) return;
try {
const token = localStorage.getItem('sso_token');
const response = await fetch(`${API_BASE_URL}/api/vacation-types/${typeId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || '삭제 실패');
}
showToast('삭제되었습니다', 'success');
await loadVacationTypes();
} catch (error) {
console.error('삭제 오류:', error);
showToast(error.message, 'error');
}
};
/**
* 휴가 유형 제출
*/
async function submitVacationType(e) {
e.preventDefault();
const typeId = document.getElementById('modalTypeId').value;
const typeName = document.getElementById('modalTypeName').value;
const typeCode = document.getElementById('modalTypeCode').value;
const priority = document.getElementById('modalPriority').value;
const isSpecial = document.getElementById('modalIsSpecial').checked ? 1 : 0;
const description = document.getElementById('modalDescription').value;
const data = {
type_name: typeName,
type_code: typeCode.toUpperCase(),
priority: parseInt(priority),
is_special: isSpecial,
description: description
};
try {
const token = localStorage.getItem('sso_token');
const url = typeId
? `${API_BASE_URL}/api/vacation-types/${typeId}`
: `${API_BASE_URL}/api/vacation-types`;
const response = await fetch(url, {
method: typeId ? 'PUT' : 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || '저장 실패');
}
showToast(typeId ? '수정되었습니다' : '추가되었습니다', 'success');
closeModals();
await loadVacationTypes();
} catch (error) {
console.error('저장 오류:', error);
showToast(error.message, 'error');
}
}
// =============================================================================
// 공통 함수
// =============================================================================
/**
* 모달 닫기
*/
function closeModals() {
document.querySelectorAll('.modal').forEach(modal => {
modal.classList.remove('active');
});
}
// showToast → api-base.js 전역 사용

View File

@@ -1,241 +0,0 @@
/**
* 휴가 관리 공통 함수
* 모든 휴가 관련 페이지에서 사용하는 공통 함수 모음
*/
// 전역 변수
window.VacationCommon = {
workers: [],
vacationTypes: [],
currentUser: null
};
/**
* 작업자 목록 로드
*/
async function loadWorkers() {
try {
const response = await axios.get('/workers?limit=100');
if (response.data.success) {
window.VacationCommon.workers = response.data.data.filter(w => w.employment_status === 'employed');
return window.VacationCommon.workers;
}
} catch (error) {
console.error('작업자 목록 로드 오류:', error);
throw error;
}
}
/**
* 휴가 유형 목록 로드
*/
async function loadVacationTypes() {
try {
const response = await axios.get('/attendance/vacation-types');
if (response.data.success) {
window.VacationCommon.vacationTypes = response.data.data;
return window.VacationCommon.vacationTypes;
}
} catch (error) {
console.error('휴가 유형 로드 오류:', error);
throw error;
}
}
/**
* 현재 사용자 정보 가져오기
*/
function getCurrentUser() {
if (!window.VacationCommon.currentUser) {
window.VacationCommon.currentUser = JSON.parse(localStorage.getItem('sso_user'));
}
return window.VacationCommon.currentUser;
}
/**
* 휴가 신청 목록 렌더링
*/
function renderVacationRequests(requests, containerId, showActions = false, actionType = 'approval') {
const container = document.getElementById(containerId);
if (!requests || requests.length === 0) {
container.innerHTML = `
<div class="empty-state">
<p>휴가 신청 내역이 없습니다.</p>
</div>
`;
return;
}
const tableHTML = `
<table class="data-table">
<thead>
<tr>
<th>작업자</th>
<th>휴가 유형</th>
<th>시작일</th>
<th>종료일</th>
<th>일수</th>
<th>상태</th>
<th>사유</th>
${showActions ? '<th>관리</th>' : ''}
</tr>
</thead>
<tbody>
${requests.map(request => {
const validStatuses = ['pending', 'approved', 'rejected'];
const safeStatus = validStatuses.includes(request.status) ? request.status : 'pending';
const statusClass = safeStatus === 'pending' ? 'status-pending' :
safeStatus === 'approved' ? 'status-approved' : 'status-rejected';
const statusText = safeStatus === 'pending' ? '대기' :
safeStatus === 'approved' ? '승인' : '거부';
const workerName = escapeHtml(request.worker_name || '알 수 없음');
const typeName = escapeHtml(request.vacation_type_name || request.type_name || '알 수 없음');
const reasonText = escapeHtml(request.reason || '-');
const daysUsed = parseFloat(request.days_used) || 0;
return `
<tr>
<td><strong>${workerName}</strong></td>
<td>${typeName}</td>
<td>${escapeHtml(request.start_date || '-')}</td>
<td>${escapeHtml(request.end_date || '-')}</td>
<td>${daysUsed}일</td>
<td>
<span class="status-badge ${statusClass}">
${statusText}
</span>
</td>
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${reasonText}">
${reasonText}
</td>
${showActions ? renderActionButtons(request, actionType) : ''}
</tr>
`;
}).join('')}
</tbody>
</table>
`;
container.innerHTML = tableHTML;
}
/**
* 액션 버튼 렌더링
*/
function renderActionButtons(request, actionType) {
const safeRequestId = parseInt(request.request_id) || 0;
if (actionType === 'approval' && request.status === 'pending') {
return `
<td>
<div style="display: flex; gap: 0.5rem;">
<button class="btn-small btn-success" onclick="approveVacationRequest(${safeRequestId})" title="승인">
</button>
<button class="btn-small btn-danger" onclick="rejectVacationRequest(${safeRequestId})" title="거부">
</button>
</div>
</td>
`;
} else if (actionType === 'delete' && request.status === 'pending') {
return `
<td>
<button class="btn-small btn-danger" onclick="deleteVacationRequest(${safeRequestId})" title="삭제">
삭제
</button>
</td>
`;
}
return '<td>-</td>';
}
/**
* 휴가 신청 승인
*/
async function approveVacationRequest(requestId) {
if (!confirm('이 휴가 신청을 승인하시겠습니까?')) {
return;
}
try {
const response = await axios.patch(`/vacation-requests/${requestId}/approve`);
if (response.data.success) {
alert('휴가 신청이 승인되었습니다.');
// 페이지 새로고침 이벤트 발생
window.dispatchEvent(new Event('vacation-updated'));
return true;
}
} catch (error) {
console.error('승인 오류:', error);
alert(error.response?.data?.message || '승인 중 오류가 발생했습니다.');
return false;
}
}
/**
* 휴가 신청 거부
*/
async function rejectVacationRequest(requestId) {
const reason = prompt('거부 사유를 입력하세요:');
if (!reason) {
return;
}
try {
const response = await axios.patch(`/vacation-requests/${requestId}/reject`, {
review_note: reason
});
if (response.data.success) {
alert('휴가 신청이 거부되었습니다.');
// 페이지 새로고침 이벤트 발생
window.dispatchEvent(new Event('vacation-updated'));
return true;
}
} catch (error) {
console.error('거부 오류:', error);
alert(error.response?.data?.message || '거부 중 오류가 발생했습니다.');
return false;
}
}
/**
* 휴가 신청 삭제
*/
async function deleteVacationRequest(requestId) {
if (!confirm('이 휴가 신청을 삭제하시겠습니까?')) {
return;
}
try {
const response = await axios.delete(`/vacation-requests/${requestId}`);
if (response.data.success) {
alert('휴가 신청이 삭제되었습니다.');
// 페이지 새로고침 이벤트 발생
window.dispatchEvent(new Event('vacation-updated'));
return true;
}
} catch (error) {
console.error('삭제 오류:', error);
alert(error.response?.data?.message || '삭제 중 오류가 발생했습니다.');
return false;
}
}
/**
* axios 설정 대기
*/
function waitForAxiosConfig() {
return new Promise((resolve) => {
const check = setInterval(() => {
if (axios.defaults.baseURL) {
clearInterval(check);
resolve();
}
}, 50);
setTimeout(() => {
clearInterval(check);
resolve();
}, 5000);
});
}

View File

@@ -1,712 +0,0 @@
// 작업 분석 페이지 JavaScript
// API 설정 import
import './api-config.js';
// 전역 변수
let currentMode = 'period';
let currentTab = 'worker';
let analysisData = null;
let projectChart = null;
let errorByProjectChart = null;
let errorTimelineChart = null;
// 페이지 초기화
document.addEventListener('DOMContentLoaded', function() {
initializePage();
loadInitialData();
});
// 페이지 초기화
function initializePage() {
// 시간 업데이트 시작
updateCurrentTime();
setInterval(updateCurrentTime, 1000);
// 사용자 정보 업데이트
updateUserInfo();
// 프로필 메뉴 토글
setupProfileMenu();
// 로그아웃 버튼
setupLogoutButton();
// 기본 날짜 설정은 HTML에서 처리됨 (새로운 UI)
}
// 현재 시간 업데이트 (시 분 초 형식으로 고정)
function updateCurrentTime() {
const now = new Date();
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
const timeString = `${hours}${minutes}${seconds}`;
const timeElement = document.getElementById('timeValue');
if (timeElement) {
timeElement.textContent = timeString;
}
}
// 사용자 정보 업데이트 - navbar/sidebar는 app-init.js에서 공통 처리
function updateUserInfo() {
// app-init.js가 navbar 사용자 정보를 처리하므로 여기서는 아무것도 하지 않음
}
// 프로필 메뉴 설정
function setupProfileMenu() {
const userProfile = document.getElementById('userProfile');
const profileMenu = document.getElementById('profileMenu');
if (userProfile && profileMenu) {
userProfile.addEventListener('click', function(e) {
e.stopPropagation();
const isVisible = profileMenu.style.display === 'block';
profileMenu.style.display = isVisible ? 'none' : 'block';
});
// 외부 클릭 시 메뉴 닫기
document.addEventListener('click', function() {
profileMenu.style.display = 'none';
});
}
}
// 로그아웃 버튼 설정
function setupLogoutButton() {
const logoutBtn = document.getElementById('logoutBtn');
if (logoutBtn) {
logoutBtn.addEventListener('click', function() {
if (confirm('로그아웃 하시겠습니까?')) {
if (window.clearSSOAuth) window.clearSSOAuth();
window.location.href = window.getLoginUrl ? window.getLoginUrl() : '/login';
}
});
}
}
// 초기 데이터 로드
async function loadInitialData() {
try {
// 프로젝트 목록 로드
const projects = await apiCall('/projects/active/list', 'GET');
const projectData = Array.isArray(projects) ? projects : (projects.data || []);
// 프로젝트 필터 옵션 업데이트
updateProjectFilters(projectData);
} catch (error) {
console.error('초기 데이터 로딩 오류:', error);
showToast('초기 데이터를 불러오는데 실패했습니다.', 'error');
}
}
// 프로젝트 필터 업데이트
function updateProjectFilters(projects) {
const projectFilter = document.getElementById('projectFilter');
const projectModeSelect = document.getElementById('projectModeSelect');
if (projectFilter) {
projectFilter.innerHTML = '<option value="">전체 프로젝트</option>';
projects.forEach(project => {
projectFilter.innerHTML += `<option value="${project.project_id}">${project.project_name}</option>`;
});
}
if (projectModeSelect) {
projectModeSelect.innerHTML = '<option value="">프로젝트를 선택하세요</option>';
projects.forEach(project => {
projectModeSelect.innerHTML += `<option value="${project.project_id}">${project.project_name}</option>`;
});
}
}
// 분석 모드 전환
function switchAnalysisMode(mode) {
currentMode = mode;
// 탭 버튼 활성화 상태 변경
document.querySelectorAll('.mode-tab').forEach(tab => {
tab.classList.remove('active');
});
document.querySelector(`[data-mode="${mode}"]`).classList.add('active');
// 모드 콘텐츠 표시/숨김
document.querySelectorAll('.analysis-mode').forEach(content => {
content.classList.remove('active');
});
document.getElementById(`${mode}-mode`).classList.add('active');
}
// 분석 탭 전환
function switchAnalysisTab(tab) {
currentTab = tab;
// 탭 버튼 활성화 상태 변경
document.querySelectorAll('.analysis-tab').forEach(tabBtn => {
tabBtn.classList.remove('active');
});
document.querySelector(`[data-tab="${tab}"]`).classList.add('active');
// 탭 콘텐츠 표시/숨김
document.querySelectorAll('.analysis-content').forEach(content => {
content.classList.remove('active');
});
document.getElementById(`${tab}-analysis`).classList.add('active');
}
// 기간별 분석 로드
async function loadPeriodAnalysis() {
const startDate = document.getElementById('startDate').value;
const endDate = document.getElementById('endDate').value;
const projectId = document.getElementById('projectFilter').value;
if (!startDate || !endDate) {
showToast('시작일과 종료일을 모두 선택해주세요.', 'error');
return;
}
if (new Date(startDate) > new Date(endDate)) {
showToast('시작일이 종료일보다 늦을 수 없습니다.', 'error');
return;
}
showLoading(true);
try {
// API 호출 파라미터 구성
const params = new URLSearchParams({
start: startDate,
end: endDate
});
if (projectId) {
params.append('project_id', projectId);
}
// 여러 API를 병렬로 호출하여 종합 분석 데이터 구성
const [statsRes, workerStatsRes, projectStatsRes, errorAnalysisRes] = await Promise.all([
apiCall(`/work-analysis/stats?${params}`, 'GET').catch(err => {
console.error(' stats API 오류:', err);
return { data: null };
}),
apiCall(`/work-analysis/worker-stats?${params}`, 'GET').catch(err => {
console.error(' worker-stats API 오류:', err);
return { data: [] };
}),
apiCall(`/work-analysis/project-stats?${params}`, 'GET').catch(err => {
console.error(' project-stats API 오류:', err);
return { data: [] };
}),
apiCall(`/work-analysis/error-analysis?${params}`, 'GET').catch(err => {
console.error(' error-analysis API 오류:', err);
return { data: {} };
})
]);
console.log(' - stats:', statsRes);
console.log(' - worker-stats:', workerStatsRes);
console.log(' - project-stats:', projectStatsRes);
console.log(' - error-analysis:', errorAnalysisRes);
// 종합 분석 데이터 구성
analysisData = {
summary: statsRes.data || statsRes,
workerStats: workerStatsRes.data || workerStatsRes,
projectStats: projectStatsRes.data || projectStatsRes,
errorStats: errorAnalysisRes.data || errorAnalysisRes
};
// 결과 표시
displayPeriodAnalysis(analysisData);
// 결과 섹션 표시
document.getElementById('periodResults').style.display = 'block';
showToast('분석이 완료되었습니다.', 'success');
} catch (error) {
console.error('기간별 분석 오류:', error);
showToast('분석 중 오류가 발생했습니다.', 'error');
} finally {
showLoading(false);
}
}
// 기간별 분석 결과 표시
function displayPeriodAnalysis(data) {
// 요약 통계 업데이트
updateSummaryStats(data.summary || {});
// 작업자별 분석 표시
displayWorkerAnalysis(data.workerStats || []);
// 프로젝트별 분석 표시
displayProjectAnalysis(data.projectStats || []);
// 오류 분석 표시 (전체 분석 데이터도 함께 전달)
displayErrorAnalysis(data.errorStats || {}, data);
}
// 요약 통계 업데이트
function updateSummaryStats(summary) {
// API 응답 구조에 맞게 필드명 조정
document.getElementById('totalHours').textContent = `${summary.totalHours || summary.total_hours || 0}h`;
document.getElementById('totalWorkers').textContent = `${summary.activeworkers || summary.activeWorkers || summary.total_workers || 0}`;
document.getElementById('totalProjects').textContent = `${summary.activeProjects || summary.active_projects || summary.total_projects || 0}`;
document.getElementById('errorRate').textContent = `${summary.errorRate || summary.error_rate || 0}%`;
}
// 작업자별 분석 표시
function displayWorkerAnalysis(workerStats) {
const grid = document.getElementById('workerAnalysisGrid');
if (!workerStats || (Array.isArray(workerStats) && workerStats.length === 0)) {
grid.innerHTML = `
<div class="empty-state">
<div class="empty-icon">👥</div>
<h3>분석할 작업자 데이터가 없습니다.</h3>
<p>선택한 기간에 등록된 작업이 없습니다.</p>
</div>
`;
return;
}
let gridHtml = '';
workerStats.forEach(worker => {
const workerName = worker.worker_name || worker.name || '알 수 없음';
const totalHours = worker.total_hours || worker.totalHours || 0;
gridHtml += `
<div class="worker-card">
<div class="worker-header">
<div class="worker-info">
<div class="worker-avatar">${workerName.charAt(0)}</div>
<div class="worker-name">${workerName}</div>
</div>
<div class="worker-total-hours">${totalHours}h</div>
</div>
<div class="worker-projects">
`;
// API 응답 구조에 따라 프로젝트 데이터 처리
const projects = worker.projects || worker.project_details || [];
if (projects.length > 0) {
projects.forEach(project => {
const projectName = project.project_name || project.name || '프로젝트';
gridHtml += `
<div class="project-item">
<div class="project-name">${projectName}</div>
<div class="work-items">
`;
const works = project.works || project.work_details || project.tasks || [];
if (works.length > 0) {
works.forEach(work => {
const workName = work.work_name || work.task_name || work.name || '작업';
const workHours = work.hours || work.total_hours || work.work_hours || 0;
gridHtml += `
<div class="work-item">
<div class="work-name">${workName}</div>
<div class="work-hours">${workHours}h</div>
</div>
`;
});
} else {
gridHtml += `
<div class="work-item">
<div class="work-name">총 작업시간</div>
<div class="work-hours">${project.total_hours || project.hours || 0}h</div>
</div>
`;
}
gridHtml += `
</div>
</div>
`;
});
} else {
gridHtml += `
<div class="project-item">
<div class="project-name">전체 작업</div>
<div class="work-items">
<div class="work-item">
<div class="work-name">총 작업시간</div>
<div class="work-hours">${totalHours}h</div>
</div>
</div>
</div>
`;
}
gridHtml += `
</div>
</div>
`;
});
grid.innerHTML = gridHtml;
}
// 프로젝트별 분석 표시
function displayProjectAnalysis(projectStats) {
const detailsContainer = document.getElementById('projectDetails');
if (projectStats && projectStats.length > 0) {
}
if (!projectStats || projectStats.length === 0) {
detailsContainer.innerHTML = `
<div class="empty-state">
<div class="empty-icon">📁</div>
<h3>분석할 프로젝트 데이터가 없습니다.</h3>
</div>
`;
return;
}
// 프로젝트 상세 정보 표시
let detailsHtml = '';
// 전체 시간 계산 (퍼센트 계산용)
const totalAllHours = projectStats.reduce((sum, p) => {
return sum + (p.totalHours || p.total_hours || p.hours || 0);
}, 0);
projectStats.forEach(project => {
const projectName = project.project_name || project.name || project.projectName || '프로젝트';
const totalHours = project.totalHours || project.total_hours || project.hours || 0;
// 퍼센트 계산
let percentage = project.percentage || project.percent || 0;
if (percentage === 0 && totalAllHours > 0) {
percentage = Math.round((totalHours / totalAllHours) * 100);
}
detailsHtml += `
<div class="project-detail-card">
<div class="project-detail-header">
<div class="project-detail-name">${projectName}</div>
<div class="project-percentage">${percentage}%</div>
</div>
<div class="project-hours">${totalHours}시간</div>
</div>
`;
});
detailsContainer.innerHTML = detailsHtml;
// 차트 업데이트
updateProjectChart(projectStats);
}
// 프로젝트 차트 업데이트
function updateProjectChart(projectStats) {
const ctx = document.getElementById('projectChart');
if (projectChart) {
projectChart.destroy();
}
const labels = projectStats.map(p => p.project_name || p.name || p.projectName || '프로젝트');
const data = projectStats.map(p => p.totalHours || p.total_hours || p.hours || 0);
const colors = [
'#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
'#06b6d4', '#84cc16', '#f97316', '#ec4899', '#6366f1'
];
projectChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: labels,
datasets: [{
data: data,
backgroundColor: colors.slice(0, data.length),
borderWidth: 2,
borderColor: '#ffffff'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: {
padding: 20,
usePointStyle: true
}
}
}
}
});
}
// 오류 분석 표시
function displayErrorAnalysis(errorStats, allData) {
// errorStats가 배열인 경우 첫 번째 요소 사용
let errorData = errorStats;
if (Array.isArray(errorStats) && errorStats.length > 0) {
errorData = errorStats[0];
}
// 오류 요약 업데이트 - 실제 데이터 구조에 맞게 수정
const errorHours = errorData.totalHours || errorData.total_hours || errorData.error_hours || 0;
// 전체 작업 시간에서 오류 시간을 빼서 정규 시간 계산
// 요약 통계에서 전체 시간을 가져와서 계산
const totalHours = allData && allData.summary ? allData.summary.totalHours : 0;
const normalHours = Math.max(0, totalHours - errorHours);
document.getElementById('normalHours').textContent = `${normalHours}h`;
document.getElementById('errorHours').textContent = `${errorHours}h`;
// 프로젝트별 에러율 차트
if (errorStats.projectErrorRates) {
updateErrorByProjectChart(errorStats.projectErrorRates);
}
// 일별 오류 추이 차트
if (errorStats.dailyErrorTrend) {
updateErrorTimelineChart(errorStats.dailyErrorTrend);
}
// 오류 유형별 분석
if (errorStats.errorTypes) {
displayErrorTypes(errorStats.errorTypes);
}
}
// 프로젝트별 에러율 차트 업데이트
function updateErrorByProjectChart(projectErrorRates) {
const ctx = document.getElementById('errorByProjectChart');
if (errorByProjectChart) {
errorByProjectChart.destroy();
}
const labels = projectErrorRates.map(p => p.project_name);
const data = projectErrorRates.map(p => p.error_rate);
errorByProjectChart = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: '에러율 (%)',
data: data,
backgroundColor: 'rgba(239, 68, 68, 0.8)',
borderColor: 'rgba(239, 68, 68, 1)',
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
max: 100,
ticks: {
callback: function(value) {
return value + '%';
}
}
}
},
plugins: {
legend: {
display: false
}
}
}
});
}
// 일별 오류 추이 차트 업데이트
function updateErrorTimelineChart(dailyErrorTrend) {
const ctx = document.getElementById('errorTimelineChart');
if (errorTimelineChart) {
errorTimelineChart.destroy();
}
const labels = dailyErrorTrend.map(d => formatDate(new Date(d.date)));
const errorData = dailyErrorTrend.map(d => d.error_count);
const totalData = dailyErrorTrend.map(d => d.total_count);
errorTimelineChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: '총 작업',
data: totalData,
borderColor: 'rgba(59, 130, 246, 1)',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
fill: true
},
{
label: '오류 작업',
data: errorData,
borderColor: 'rgba(239, 68, 68, 1)',
backgroundColor: 'rgba(239, 68, 68, 0.1)',
fill: true
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true
}
},
plugins: {
legend: {
position: 'top'
}
}
}
});
}
// 오류 유형별 분석 표시
function displayErrorTypes(errorTypes) {
const container = document.getElementById('errorTypesAnalysis');
if (!errorTypes || errorTypes.length === 0) {
container.innerHTML = `
<div class="empty-state">
<div class="empty-icon">⚠️</div>
<h3>오류 유형 데이터가 없습니다.</h3>
</div>
`;
return;
}
let html = '<h4>🔍 오류 유형별 상세 분석</h4>';
errorTypes.forEach(errorType => {
html += `
<div class="error-type-item">
<div class="error-type-info">
<div class="error-type-icon">⚠️</div>
<div class="error-type-name">${errorType.error_name}</div>
</div>
<div class="error-type-stats">
<div class="error-type-count">${errorType.count}건</div>
<div class="error-type-percentage">${errorType.percentage}%</div>
</div>
</div>
`;
});
container.innerHTML = html;
}
// 프로젝트별 분석 로드
async function loadProjectAnalysis() {
const projectId = document.getElementById('projectModeSelect').value;
const startDate = document.getElementById('projectStartDate').value;
const endDate = document.getElementById('projectEndDate').value;
if (!projectId) {
showToast('프로젝트를 선택해주세요.', 'error');
return;
}
showLoading(true);
try {
// API 호출 파라미터 구성
const params = new URLSearchParams({
project_id: projectId
});
if (startDate) params.append('start', startDate);
if (endDate) params.append('end', endDate);
// 프로젝트별 상세 분석 데이터 로드
const response = await apiCall(`/work-analysis/project-worktype-analysis?${params}`, 'GET');
const projectAnalysisData = response.data || response;
// 결과 표시
displayProjectModeAnalysis(projectAnalysisData);
// 결과 섹션 표시
document.getElementById('projectModeResults').style.display = 'block';
showToast('프로젝트 분석이 완료되었습니다.', 'success');
} catch (error) {
console.error('프로젝트별 분석 오류:', error);
showToast('프로젝트 분석 중 오류가 발생했습니다.', 'error');
} finally {
showLoading(false);
}
}
// 프로젝트별 분석 결과 표시
function displayProjectModeAnalysis(data) {
const container = document.getElementById('projectModeResults');
// 프로젝트별 분석 결과 HTML 생성
let html = `
<div class="project-mode-analysis">
<h3>📁 ${data.project_name} 분석 결과</h3>
<!-- 프로젝트별 상세 분석 내용 -->
</div>
`;
container.innerHTML = html;
}
// 로딩 상태 표시/숨김
function showLoading(show) {
const overlay = document.getElementById('loadingOverlay');
if (overlay) {
overlay.style.display = show ? 'flex' : 'none';
}
}
// 날짜 포맷팅
function formatDate(date) {
if (!date) return '';
const d = new Date(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
// showToast → api-base.js 전역 사용
// 전역 함수로 노출
window.switchAnalysisMode = switchAnalysisMode;
window.switchAnalysisTab = switchAnalysisTab;
window.loadPeriodAnalysis = loadPeriodAnalysis;
window.loadProjectAnalysis = loadProjectAnalysis;

View File

@@ -1,225 +0,0 @@
/**
* Work Analysis API Client Module
* 작업 분석 관련 모든 API 호출을 관리하는 모듈
*/
class WorkAnalysisAPIClient {
constructor() {
this.baseURL = window.API_BASE_URL || 'http://localhost:30005/api';
}
/**
* 기본 API 호출 메서드
* @param {string} endpoint - API 엔드포인트
* @param {string} method - HTTP 메서드
* @param {Object} data - 요청 데이터
* @returns {Promise<Object>} API 응답
*/
async apiCall(endpoint, method = 'GET', data = null) {
try {
const config = {
method,
headers: {
'Content-Type': 'application/json',
}
};
if (data && method !== 'GET') {
config.body = JSON.stringify(data);
}
const response = await fetch(`${this.baseURL}${endpoint}`, config);
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || `HTTP ${response.status}`);
}
return result;
} catch (error) {
console.error(` API 실패: ${this.baseURL}${endpoint}`, error);
throw error;
}
}
/**
* 날짜 범위 파라미터 생성
* @param {string} startDate - 시작일
* @param {string} endDate - 종료일
* @param {Object} additionalParams - 추가 파라미터
* @returns {URLSearchParams} URL 파라미터
*/
createDateParams(startDate, endDate, additionalParams = {}) {
const params = new URLSearchParams({
start: startDate,
end: endDate,
...additionalParams
});
return params;
}
// ========== 기본 통계 API ==========
/**
* 기본 통계 조회
*/
async getBasicStats(startDate, endDate, projectId = null) {
const params = this.createDateParams(startDate, endDate,
projectId ? { project_id: projectId } : {}
);
return await this.apiCall(`/work-analysis/stats?${params}`);
}
/**
* 일별 추이 조회
*/
async getDailyTrend(startDate, endDate, projectId = null) {
const params = this.createDateParams(startDate, endDate,
projectId ? { project_id: projectId } : {}
);
return await this.apiCall(`/work-analysis/daily-trend?${params}`);
}
/**
* 작업자별 통계 조회
*/
async getWorkerStats(startDate, endDate, projectId = null) {
const params = this.createDateParams(startDate, endDate,
projectId ? { project_id: projectId } : {}
);
return await this.apiCall(`/work-analysis/worker-stats?${params}`);
}
/**
* 프로젝트별 통계 조회
*/
async getProjectStats(startDate, endDate) {
const params = this.createDateParams(startDate, endDate);
return await this.apiCall(`/work-analysis/project-stats?${params}`);
}
// ========== 상세 분석 API ==========
/**
* 프로젝트별-작업유형별 분석
*/
async getProjectWorkTypeAnalysis(startDate, endDate, limit = 2000) {
const params = this.createDateParams(startDate, endDate, { limit });
return await this.apiCall(`/work-analysis/project-worktype-analysis?${params}`);
}
/**
* 최근 작업 데이터 조회
*/
async getRecentWork(startDate, endDate, limit = 2000) {
const params = this.createDateParams(startDate, endDate, { limit });
return await this.apiCall(`/work-analysis/recent-work?${params}`);
}
/**
* 오류 분석 데이터 조회
*/
async getErrorAnalysis(startDate, endDate) {
const params = this.createDateParams(startDate, endDate);
return await this.apiCall(`/work-analysis/error-analysis?${params}`);
}
// ========== 배치 API 호출 ==========
/**
* 여러 API를 병렬로 호출
* @param {Array} apiCalls - API 호출 배열
* @returns {Promise<Array>} 결과 배열
*/
async batchCall(apiCalls) {
const promises = apiCalls.map(async ({ name, method, ...args }) => {
try {
const result = await this[method](...args);
return { name, success: true, data: result };
} catch (error) {
console.warn(` ${name} API 오류:`, error);
return { name, success: false, error: error.message, data: null };
}
});
const results = await Promise.all(promises);
return results.reduce((acc, result) => {
acc[result.name] = result;
return acc;
}, {});
}
/**
* 차트 데이터를 위한 배치 호출
*/
async getChartData(startDate, endDate, projectId = null) {
return await this.batchCall([
{
name: 'dailyTrend',
method: 'getDailyTrend',
startDate,
endDate,
projectId
},
{
name: 'workerStats',
method: 'getWorkerStats',
startDate,
endDate,
projectId
},
{
name: 'projectStats',
method: 'getProjectStats',
startDate,
endDate
},
{
name: 'errorAnalysis',
method: 'getErrorAnalysis',
startDate,
endDate
}
]);
}
/**
* 프로젝트 분포 분석을 위한 배치 호출
*/
async getProjectDistributionData(startDate, endDate) {
return await this.batchCall([
{
name: 'projectWorkType',
method: 'getProjectWorkTypeAnalysis',
startDate,
endDate
},
{
name: 'workerStats',
method: 'getWorkerStats',
startDate,
endDate
},
{
name: 'recentWork',
method: 'getRecentWork',
startDate,
endDate
}
]);
}
}
// 전역 인스턴스 생성
window.WorkAnalysisAPI = new WorkAnalysisAPIClient();
// 하위 호환성을 위한 전역 함수
window.apiCall = (endpoint, method, data) => {
return window.WorkAnalysisAPI.apiCall(endpoint, method, data);
};
// Export는 브라우저 환경에서 제거됨

View File

@@ -1,443 +0,0 @@
/**
* Work Analysis Chart Renderer Module
* 작업 분석 차트 렌더링을 담당하는 모듈
*/
class WorkAnalysisChartRenderer {
constructor() {
this.charts = new Map(); // 차트 인스턴스 관리
this.dataProcessor = window.WorkAnalysisDataProcessor;
this.defaultColors = [
'#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
'#06b6d4', '#84cc16', '#f97316', '#ec4899', '#6366f1'
];
}
// ========== 차트 관리 ==========
/**
* 기존 차트 제거
* @param {string} chartId - 차트 ID
*/
destroyChart(chartId) {
if (this.charts.has(chartId)) {
this.charts.get(chartId).destroy();
this.charts.delete(chartId);
}
}
/**
* 모든 차트 제거
*/
destroyAllCharts() {
this.charts.forEach((chart, id) => {
chart.destroy();
});
this.charts.clear();
}
/**
* 차트 생성 및 등록
* @param {string} chartId - 차트 ID
* @param {HTMLCanvasElement} canvas - 캔버스 요소
* @param {Object} config - 차트 설정
* @returns {Chart} 생성된 차트 인스턴스
*/
createChart(chartId, canvas, config) {
// 기존 차트가 있으면 제거
this.destroyChart(chartId);
const chart = new Chart(canvas, config);
this.charts.set(chartId, chart);
return chart;
}
// ========== 시계열 차트 ==========
/**
* 시계열 차트 렌더링 (기간별 작업 현황)
* @param {string} startDate - 시작일
* @param {string} endDate - 종료일
* @param {string} projectId - 프로젝트 ID (선택사항)
*/
async renderTimeSeriesChart(startDate, endDate, projectId = '') {
try {
const api = window.WorkAnalysisAPI;
const dailyTrendResponse = await api.getDailyTrend(startDate, endDate, projectId);
if (!dailyTrendResponse.success || !dailyTrendResponse.data) {
throw new Error('일별 추이 데이터를 가져올 수 없습니다');
}
const canvas = document.getElementById('workStatusChart');
if (!canvas) {
console.error(' workStatusChart 캔버스를 찾을 수 없습니다');
return;
}
const chartData = this.dataProcessor.processTimeSeriesData(dailyTrendResponse.data);
const config = {
type: 'line',
data: chartData,
options: {
responsive: true,
maintainAspectRatio: false,
aspectRatio: 2,
scales: {
y: {
beginAtZero: true,
title: {
display: true,
text: '작업시간 (h)'
}
},
y1: {
type: 'linear',
display: true,
position: 'right',
title: {
display: true,
text: '작업자 수 (명)'
},
grid: {
drawOnChartArea: false,
},
}
},
plugins: {
title: {
display: true,
text: '일별 작업 현황'
},
legend: {
display: true,
position: 'top'
}
}
}
};
this.createChart('workStatus', canvas, config);
} catch (error) {
console.error(' 시계열 차트 렌더링 실패:', error);
this._showChartError('workStatusChart', '시계열 차트를 불러올 수 없습니다');
}
}
// ========== 스택 바 차트 ==========
/**
* 스택 바 차트 렌더링 (프로젝트별 → 작업유형별)
* @param {Array} projectData - 프로젝트 데이터
*/
renderStackedBarChart(projectData) {
const canvas = document.getElementById('projectDistributionChart');
if (!canvas) {
console.error(' projectDistributionChart 캔버스를 찾을 수 없습니다');
return;
}
if (!projectData || !projectData.projects || projectData.projects.length === 0) {
this._showChartError('projectDistributionChart', '프로젝트 데이터가 없습니다');
return;
}
// 데이터 변환
const { labels, datasets } = this._processStackedBarData(projectData.projects);
const config = {
type: 'bar',
data: {
labels,
datasets
},
options: {
responsive: true,
maintainAspectRatio: false,
aspectRatio: 2,
scales: {
x: {
stacked: true,
title: {
display: true,
text: '프로젝트'
}
},
y: {
stacked: true,
beginAtZero: true,
title: {
display: true,
text: '작업시간 (h)'
}
}
},
plugins: {
title: {
display: true,
text: '프로젝트별 작업유형 분포'
},
legend: {
display: true,
position: 'top'
},
tooltip: {
mode: 'index',
intersect: false,
callbacks: {
title: function(context) {
return `${context[0].label}`;
},
label: function(context) {
const workType = context.dataset.label;
const hours = context.parsed.y;
const percentage = ((hours / projectData.totalHours) * 100).toFixed(1);
return `${workType}: ${hours}h (${percentage}%)`;
}
}
}
}
}
};
this.createChart('projectDistribution', canvas, config);
}
/**
* 스택 바 차트 데이터 처리
*/
_processStackedBarData(projects) {
// 모든 작업유형 수집
const allWorkTypes = new Set();
projects.forEach(project => {
project.workTypes.forEach(wt => {
allWorkTypes.add(wt.work_type_name);
});
});
const workTypeArray = Array.from(allWorkTypes);
const labels = projects.map(p => p.project_name);
// 작업유형별 데이터셋 생성
const datasets = workTypeArray.map((workTypeName, index) => {
const data = projects.map(project => {
const workType = project.workTypes.find(wt => wt.work_type_name === workTypeName);
return workType ? workType.totalHours : 0;
});
return {
label: workTypeName,
data,
backgroundColor: this.defaultColors[index % this.defaultColors.length],
borderColor: this.defaultColors[index % this.defaultColors.length],
borderWidth: 1
};
});
return { labels, datasets };
}
// ========== 도넛 차트 ==========
/**
* 도넛 차트 렌더링 (작업자별 성과)
* @param {Array} workerData - 작업자 데이터
*/
renderWorkerPerformanceChart(workerData) {
const canvas = document.getElementById('workerPerformanceChart');
if (!canvas) {
console.error(' workerPerformanceChart 캔버스를 찾을 수 없습니다');
return;
}
if (!workerData || workerData.length === 0) {
this._showChartError('workerPerformanceChart', '작업자 데이터가 없습니다');
return;
}
const chartData = this.dataProcessor.processDonutChartData(
workerData.map(worker => ({
name: worker.worker_name,
hours: worker.totalHours
}))
);
const config = {
type: 'doughnut',
data: chartData,
options: {
responsive: true,
maintainAspectRatio: false,
aspectRatio: 1,
plugins: {
title: {
display: true,
text: '작업자별 작업시간 분포'
},
legend: {
display: true,
position: 'right'
},
tooltip: {
callbacks: {
label: function(context) {
const label = context.label;
const value = context.parsed;
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const percentage = ((value / total) * 100).toFixed(1);
return `${label}: ${value}h (${percentage}%)`;
}
}
}
}
}
};
this.createChart('workerPerformance', canvas, config);
}
// ========== 오류 분석 차트 ==========
/**
* 오류 분석 차트 렌더링
* @param {Array} errorData - 오류 데이터
*/
renderErrorAnalysisChart(errorData) {
const canvas = document.getElementById('errorAnalysisChart');
if (!canvas) {
console.error(' errorAnalysisChart 캔버스를 찾을 수 없습니다');
return;
}
if (!errorData || errorData.length === 0) {
this._showChartError('errorAnalysisChart', '오류 데이터가 없습니다');
return;
}
// 오류가 있는 데이터만 필터링
const errorItems = errorData.filter(item =>
item.error_count > 0 || (item.errorDetails && item.errorDetails.length > 0)
);
if (errorItems.length === 0) {
this._showChartError('errorAnalysisChart', '오류가 발생한 항목이 없습니다');
return;
}
const chartData = this.dataProcessor.processDonutChartData(
errorItems.map(item => ({
name: item.project_name || item.name,
hours: item.errorHours || item.error_count
}))
);
const config = {
type: 'doughnut',
data: chartData,
options: {
responsive: true,
maintainAspectRatio: false,
aspectRatio: 1,
plugins: {
title: {
display: true,
text: '프로젝트별 오류 분포'
},
legend: {
display: true,
position: 'bottom'
},
tooltip: {
callbacks: {
label: function(context) {
const label = context.label;
const value = context.parsed;
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const percentage = ((value / total) * 100).toFixed(1);
return `${label}: ${value}h (${percentage}%)`;
}
}
}
}
}
};
this.createChart('errorAnalysis', canvas, config);
}
// ========== 유틸리티 ==========
/**
* 차트 오류 표시
* @param {string} canvasId - 캔버스 ID
* @param {string} message - 오류 메시지
*/
_showChartError(canvasId, message) {
const canvas = document.getElementById(canvasId);
if (!canvas) return;
const container = canvas.parentElement;
if (container) {
container.innerHTML = `
<div style="
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 300px;
color: #666;
text-align: center;
">
<div style="font-size: 3rem; margin-bottom: 1rem;">📊</div>
<div style="font-size: 1.1rem; font-weight: 600; margin-bottom: 0.5rem;">차트를 표시할 수 없습니다</div>
<div style="font-size: 0.9rem;">${message}</div>
</div>
`;
}
}
/**
* 차트 리사이즈
*/
resizeCharts() {
this.charts.forEach((chart, id) => {
try {
chart.resize();
} catch (error) {
console.warn(' 차트 리사이즈 실패:', id, error);
}
});
}
/**
* 차트 상태 확인
*/
getChartStatus() {
const status = {};
this.charts.forEach((chart, id) => {
status[id] = {
type: chart.config.type,
datasetCount: chart.data.datasets.length,
dataPointCount: chart.data.labels ? chart.data.labels.length : 0
};
});
return status;
}
}
// 전역 인스턴스 생성
window.WorkAnalysisChartRenderer = new WorkAnalysisChartRenderer();
// 윈도우 리사이즈 이벤트 리스너
window.addEventListener('resize', () => {
window.WorkAnalysisChartRenderer.resizeCharts();
});
// Export는 브라우저 환경에서 제거됨

View File

@@ -1,349 +0,0 @@
/**
* Work Analysis Data Processor Module
* 작업 분석 데이터 가공 및 변환을 담당하는 모듈
*/
class WorkAnalysisDataProcessor {
// ========== 유틸리티 함수 ==========
/**
* 주말 여부 확인
* @param {string} dateString - 날짜 문자열
* @returns {boolean} 주말 여부
*/
isWeekendDate(dateString) {
const date = new Date(dateString);
const dayOfWeek = date.getDay();
return dayOfWeek === 0 || dayOfWeek === 6; // 일요일(0) 또는 토요일(6)
}
/**
* 연차/휴무 프로젝트 여부 확인
* @param {string} projectName - 프로젝트명
* @returns {boolean} 연차/휴무 여부
*/
isVacationProject(projectName) {
if (!projectName) return false;
const vacationKeywords = ['연차', '휴무', '휴가', '병가', '특별휴가'];
return vacationKeywords.some(keyword => projectName.includes(keyword));
}
/**
* 날짜 포맷팅 (간단한 형식)
* @param {string} dateString - 날짜 문자열
* @returns {string} 포맷된 날짜
*/
formatSimpleDate(dateString) {
if (!dateString) return '';
return dateString.split('T')[0]; // 시간 부분 제거
}
// ========== 프로젝트 분포 데이터 처리 ==========
/**
* 프로젝트별 데이터 집계
* @param {Array} recentWorkData - 최근 작업 데이터
* @returns {Object} 집계된 프로젝트 데이터
*/
aggregateProjectData(recentWorkData) {
if (!recentWorkData || recentWorkData.length === 0) {
return { projects: [], totalHours: 0 };
}
const projectMap = new Map();
let vacationData = null;
recentWorkData.forEach(work => {
const isWeekend = this.isWeekendDate(work.report_date);
const isVacation = this.isVacationProject(work.project_name);
// 주말 연차는 제외
if (isWeekend && isVacation) {
return;
}
if (isVacation) {
// 연차/휴무 통합 처리
if (!vacationData) {
vacationData = {
project_id: 'vacation',
project_name: '연차/휴무',
job_no: null,
totalHours: 0,
workTypes: new Map()
};
}
this._addWorkToProject(vacationData, work, '연차/휴무');
} else {
// 일반 프로젝트 처리
const projectKey = work.project_id || 'unknown';
if (!projectMap.has(projectKey)) {
projectMap.set(projectKey, {
project_id: projectKey,
project_name: work.project_name || `프로젝트 ${projectKey}`,
job_no: work.job_no,
totalHours: 0,
workTypes: new Map()
});
}
const project = projectMap.get(projectKey);
this._addWorkToProject(project, work);
}
});
// 결과 배열 생성
const projects = Array.from(projectMap.values());
if (vacationData && vacationData.totalHours > 0) {
projects.push(vacationData);
}
// 작업유형을 배열로 변환하고 정렬
projects.forEach(project => {
project.workTypes = Array.from(project.workTypes.values())
.sort((a, b) => b.totalHours - a.totalHours);
});
// 프로젝트를 총 시간 순으로 정렬 (연차/휴무는 맨 아래)
projects.sort((a, b) => {
if (a.project_id === 'vacation') return 1;
if (b.project_id === 'vacation') return -1;
return b.totalHours - a.totalHours;
});
const totalHours = projects.reduce((sum, p) => sum + p.totalHours, 0);
return { projects, totalHours };
}
/**
* 프로젝트에 작업 데이터 추가 (내부 헬퍼)
*/
_addWorkToProject(project, work, overrideWorkTypeName = null) {
const hours = parseFloat(work.work_hours) || 0;
project.totalHours += hours;
const workTypeKey = work.work_type_id || 'unknown';
const workTypeName = overrideWorkTypeName || work.work_type_name || `작업유형 ${workTypeKey}`;
if (!project.workTypes.has(workTypeKey)) {
project.workTypes.set(workTypeKey, {
work_type_id: workTypeKey,
work_type_name: workTypeName,
totalHours: 0
});
}
project.workTypes.get(workTypeKey).totalHours += hours;
}
// ========== 오류 분석 데이터 처리 ==========
/**
* 작업 형태별 오류 데이터 집계
* @param {Array} recentWorkData - 최근 작업 데이터
* @returns {Array} 집계된 오류 데이터
*/
aggregateErrorData(recentWorkData) {
if (!recentWorkData || recentWorkData.length === 0) {
return [];
}
const workTypeMap = new Map();
let vacationData = null;
recentWorkData.forEach(work => {
const isWeekend = this.isWeekendDate(work.report_date);
const isVacation = this.isVacationProject(work.project_name);
// 주말 연차는 완전히 제외
if (isWeekend && isVacation) {
return;
}
if (isVacation) {
// 모든 연차/휴무를 하나로 통합
if (!vacationData) {
vacationData = {
project_id: 'vacation',
project_name: '연차/휴무',
job_no: null,
work_type_id: 'vacation',
work_type_name: '연차/휴무',
regularHours: 0,
errorHours: 0,
errorDetails: new Map(),
isVacation: true
};
}
this._addWorkToErrorData(vacationData, work);
} else {
// 일반 프로젝트 처리
const workTypeKey = work.work_type_id || 'unknown';
const combinedKey = `${work.project_id || 'unknown'}_${workTypeKey}`;
if (!workTypeMap.has(combinedKey)) {
workTypeMap.set(combinedKey, {
project_id: work.project_id,
project_name: work.project_name || `프로젝트 ${work.project_id}`,
job_no: work.job_no,
work_type_id: workTypeKey,
work_type_name: work.work_type_name || `작업유형 ${workTypeKey}`,
regularHours: 0,
errorHours: 0,
errorDetails: new Map(),
isVacation: false
});
}
const workTypeData = workTypeMap.get(combinedKey);
this._addWorkToErrorData(workTypeData, work);
}
});
// 결과 배열 생성
const result = Array.from(workTypeMap.values());
// 연차/휴무 데이터가 있으면 추가
if (vacationData && (vacationData.regularHours > 0 || vacationData.errorHours > 0)) {
result.push(vacationData);
}
// 최종 데이터 처리
const processedResult = result.map(wt => ({
...wt,
totalHours: wt.regularHours + wt.errorHours,
errorRate: wt.regularHours + wt.errorHours > 0 ?
((wt.errorHours / (wt.regularHours + wt.errorHours)) * 100).toFixed(1) : '0.0',
errorDetails: Array.from(wt.errorDetails.entries()).map(([type, hours]) => ({
type, hours
}))
})).filter(wt => wt.totalHours > 0) // 시간이 있는 것만 표시
.sort((a, b) => {
// 연차/휴무를 맨 아래로
if (a.isVacation && !b.isVacation) return 1;
if (!a.isVacation && b.isVacation) return -1;
// 같은 프로젝트 내에서는 오류 시간 순으로 정렬
if (a.project_id === b.project_id) {
return b.errorHours - a.errorHours;
}
// 다른 프로젝트는 프로젝트명 순으로 정렬
return (a.project_name || '').localeCompare(b.project_name || '');
});
return processedResult;
}
/**
* 작업 데이터를 오류 분석 데이터에 추가 (내부 헬퍼)
*/
_addWorkToErrorData(workTypeData, work) {
const hours = parseFloat(work.work_hours) || 0;
if (work.work_status === 'error' || work.error_type_id) {
workTypeData.errorHours += hours;
// 오류 유형별 세분화
const errorTypeName = work.error_type_name || work.error_description || '설계미스';
if (!workTypeData.errorDetails.has(errorTypeName)) {
workTypeData.errorDetails.set(errorTypeName, 0);
}
workTypeData.errorDetails.set(errorTypeName,
workTypeData.errorDetails.get(errorTypeName) + hours
);
} else {
workTypeData.regularHours += hours;
}
}
// ========== 차트 데이터 처리 ==========
/**
* 시계열 차트 데이터 변환
* @param {Array} dailyData - 일별 데이터
* @returns {Object} 차트 데이터
*/
processTimeSeriesData(dailyData) {
if (!dailyData || dailyData.length === 0) {
return { labels: [], datasets: [] };
}
const labels = dailyData.map(item => this.formatSimpleDate(item.date));
const hours = dailyData.map(item => item.total_hours || 0);
const workers = dailyData.map(item => item.worker_count || 0);
return {
labels,
datasets: [
{
label: '총 작업시간',
data: hours,
borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.4
},
{
label: '참여 작업자 수',
data: workers,
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
tension: 0.4,
yAxisID: 'y1'
}
]
};
}
/**
* 도넛 차트 데이터 변환
* @param {Array} projectData - 프로젝트 데이터
* @returns {Object} 차트 데이터
*/
processDonutChartData(projectData) {
if (!projectData || projectData.length === 0) {
return { labels: [], datasets: [] };
}
const labels = projectData.map(item => item.project_name || item.name);
const data = projectData.map(item => item.total_hours || item.hours || 0);
const colors = this._generateColors(data.length);
return {
labels,
datasets: [{
data,
backgroundColor: colors,
borderWidth: 2,
borderColor: '#ffffff'
}]
};
}
/**
* 색상 생성 헬퍼
*/
_generateColors(count) {
const baseColors = [
'#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
'#06b6d4', '#84cc16', '#f97316', '#ec4899', '#6366f1'
];
const colors = [];
for (let i = 0; i < count; i++) {
colors.push(baseColors[i % baseColors.length]);
}
return colors;
}
}
// 전역 인스턴스 생성
window.WorkAnalysisDataProcessor = new WorkAnalysisDataProcessor();
// Export는 브라우저 환경에서 제거됨

View File

@@ -1,596 +0,0 @@
/**
* Work Analysis Main Controller Module
* 작업 분석 페이지의 메인 컨트롤러 - 모든 모듈을 조율하고 사용자 상호작용을 처리
*/
class WorkAnalysisMainController {
constructor() {
this.api = window.WorkAnalysisAPI;
this.state = window.WorkAnalysisState;
this.dataProcessor = window.WorkAnalysisDataProcessor;
this.tableRenderer = window.WorkAnalysisTableRenderer;
this.chartRenderer = window.WorkAnalysisChartRenderer;
this.init();
}
/**
* 초기화
*/
init() {
this.setupEventListeners();
this.setupStateListeners();
this.initializeUI();
}
/**
* 이벤트 리스너 설정
*/
setupEventListeners() {
// 기간 확정 버튼
const confirmButton = document.getElementById('confirmPeriodBtn');
if (confirmButton) {
confirmButton.addEventListener('click', () => this.handlePeriodConfirm());
}
// 분석 모드 탭
document.querySelectorAll('[data-mode]').forEach(button => {
button.addEventListener('click', (e) => {
const mode = e.target.dataset.mode;
this.handleModeChange(mode);
});
});
// 분석 탭 네비게이션
document.querySelectorAll('[data-tab]').forEach(button => {
button.addEventListener('click', (e) => {
const tabId = e.target.dataset.tab;
this.handleTabChange(tabId);
});
});
// 개별 분석 실행 버튼들
this.setupAnalysisButtons();
// 날짜 입력 필드
const startDateInput = document.getElementById('startDate');
const endDateInput = document.getElementById('endDate');
if (startDateInput && endDateInput) {
[startDateInput, endDateInput].forEach(input => {
input.addEventListener('change', () => this.handleDateChange());
});
}
}
/**
* 개별 분석 버튼 설정
*/
setupAnalysisButtons() {
const buttons = [
{ selector: 'button[onclick*="analyzeWorkStatus"]', handler: () => this.analyzeWorkStatus() },
{ selector: 'button[onclick*="analyzeProjectDistribution"]', handler: () => this.analyzeProjectDistribution() },
{ selector: 'button[onclick*="analyzeWorkerPerformance"]', handler: () => this.analyzeWorkerPerformance() },
{ selector: 'button[onclick*="analyzeErrorAnalysis"]', handler: () => this.analyzeErrorAnalysis() }
];
buttons.forEach(({ selector, handler }) => {
const button = document.querySelector(selector);
if (button) {
// 기존 onclick 제거하고 새 이벤트 리스너 추가
button.removeAttribute('onclick');
button.addEventListener('click', handler);
}
});
}
/**
* 상태 리스너 설정
*/
setupStateListeners() {
// 기간 확정 상태 변경 시 UI 업데이트
this.state.subscribe('periodConfirmed', (newState, prevState) => {
this.updateAnalysisButtons(newState.isAnalysisEnabled);
if (newState.confirmedPeriod.confirmed && !prevState.confirmedPeriod.confirmed) {
this.showAnalysisTabs();
}
});
// 로딩 상태 변경 시 UI 업데이트
this.state.subscribe('loadingState', (newState) => {
if (newState.isLoading) {
this.showLoading(newState.loadingMessage);
} else {
this.hideLoading();
}
});
// 탭 변경 시 UI 업데이트
this.state.subscribe('tabChange', (newState) => {
this.updateActiveTab(newState.currentTab);
});
// 에러 발생 시 처리
this.state.subscribe('errorOccurred', (newState) => {
if (newState.lastError) {
this.handleError(newState.lastError);
}
});
}
/**
* UI 초기화
*/
initializeUI() {
// 기본 날짜 설정
const currentState = this.state.getState();
const startDateInput = document.getElementById('startDate');
const endDateInput = document.getElementById('endDate');
if (startDateInput && currentState.confirmedPeriod.start) {
startDateInput.value = currentState.confirmedPeriod.start;
}
if (endDateInput && currentState.confirmedPeriod.end) {
endDateInput.value = currentState.confirmedPeriod.end;
}
// 분석 버튼 초기 상태 설정
this.updateAnalysisButtons(false);
// 분석 탭 숨김
this.hideAnalysisTabs();
}
// ========== 이벤트 핸들러 ==========
/**
* 기간 확정 처리
*/
async handlePeriodConfirm() {
try {
const startDate = document.getElementById('startDate').value;
const endDate = document.getElementById('endDate').value;
this.state.confirmPeriod(startDate, endDate);
this.showToast('기간이 확정되었습니다', 'success');
} catch (error) {
console.error(' 기간 확정 처리 오류:', error);
this.state.setError(error);
this.showToast(error.message, 'error');
}
}
/**
* 분석 모드 변경 처리
*/
handleModeChange(mode) {
try {
this.state.setAnalysisMode(mode);
this.updateModeButtons(mode);
// 캐시 초기화
this.state.clearCache();
} catch (error) {
this.state.setError(error);
}
}
/**
* 탭 변경 처리
*/
handleTabChange(tabId) {
this.state.setCurrentTab(tabId);
}
/**
* 날짜 변경 처리
*/
handleDateChange() {
// 날짜가 변경되면 기간 확정 상태 해제
this.state.updateState({
confirmedPeriod: {
...this.state.getState().confirmedPeriod,
confirmed: false
},
isAnalysisEnabled: false
});
this.updateAnalysisButtons(false);
this.hideAnalysisTabs();
}
// ========== 분석 실행 ==========
/**
* 기본 통계 로드
*/
async loadBasicStats() {
const currentState = this.state.getState();
const { start, end } = currentState.confirmedPeriod;
try {
this.state.startLoading('기본 통계를 로딩 중입니다...');
const statsResponse = await this.api.getBasicStats(start, end);
if (statsResponse.success && statsResponse.data) {
const stats = statsResponse.data;
// 정상/오류 시간 계산
const totalHours = stats.totalHours || 0;
const errorReports = stats.errorRate || 0;
const errorHours = Math.round(totalHours * (errorReports / 100));
const normalHours = totalHours - errorHours;
const cardData = {
totalHours: totalHours,
normalHours: normalHours,
errorHours: errorHours,
workerCount: stats.activeWorkers || stats.activeworkers || 0,
errorRate: errorReports
};
this.state.setCache('basicStats', cardData);
this.updateResultCards(cardData);
} else {
// 기본값으로 카드 업데이트
const defaultData = {
totalHours: 0,
normalHours: 0,
errorHours: 0,
workerCount: 0,
errorRate: 0
};
this.updateResultCards(defaultData);
console.warn(' 기본 통계 데이터가 없어서 기본값으로 설정');
}
} catch (error) {
console.error(' 기본 통계 로드 실패:', error);
// 에러 시에도 기본값으로 카드 업데이트
const defaultData = {
totalHours: 0,
normalHours: 0,
errorHours: 0,
workerCount: 0,
errorRate: 0
};
this.updateResultCards(defaultData);
} finally {
this.state.stopLoading();
}
}
/**
* 기간별 작업 현황 분석
*/
async analyzeWorkStatus() {
if (!this.state.canAnalyze()) {
this.showToast('기간을 먼저 확정해주세요', 'warning');
return;
}
const currentState = this.state.getState();
const { start, end } = currentState.confirmedPeriod;
try {
this.state.startLoading('기간별 작업 현황을 분석 중입니다...');
// 실제 API 호출
const batchData = await this.api.batchCall([
{
name: 'projectWorkType',
method: 'getProjectWorkTypeAnalysis',
startDate: start,
endDate: end
},
{
name: 'workerStats',
method: 'getWorkerStats',
startDate: start,
endDate: end
},
{
name: 'recentWork',
method: 'getRecentWork',
startDate: start,
endDate: end,
limit: 2000
}
]);
// 데이터 처리
const recentWorkData = batchData.recentWork?.success ? batchData.recentWork.data.data : [];
const workerData = batchData.workerStats?.success ? batchData.workerStats.data.data : [];
const projectData = this.dataProcessor.aggregateProjectData(recentWorkData);
// 테이블 렌더링
this.tableRenderer.renderWorkStatusTable(projectData, workerData, recentWorkData);
this.showToast('기간별 작업 현황 분석이 완료되었습니다', 'success');
} catch (error) {
console.error(' 기간별 작업 현황 분석 오류:', error);
this.state.setError(error);
this.showToast('기간별 작업 현황 분석에 실패했습니다', 'error');
} finally {
this.state.stopLoading();
}
}
/**
* 프로젝트별 분포 분석
*/
async analyzeProjectDistribution() {
if (!this.state.canAnalyze()) {
this.showToast('기간을 먼저 확정해주세요', 'warning');
return;
}
const currentState = this.state.getState();
const { start, end } = currentState.confirmedPeriod;
try {
this.state.startLoading('프로젝트별 분포를 분석 중입니다...');
// 실제 API 호출
const distributionData = await this.api.getProjectDistributionData(start, end);
// 데이터 처리
const recentWorkData = distributionData.recentWork?.success ? distributionData.recentWork.data.data : [];
const workerData = distributionData.workerStats?.success ? distributionData.workerStats.data.data : [];
const projectData = this.dataProcessor.aggregateProjectData(recentWorkData);
// 테이블 렌더링
this.tableRenderer.renderProjectDistributionTable(projectData, workerData);
this.showToast('프로젝트별 분포 분석이 완료되었습니다', 'success');
} catch (error) {
console.error(' 프로젝트별 분포 분석 오류:', error);
this.state.setError(error);
this.showToast('프로젝트별 분포 분석에 실패했습니다', 'error');
} finally {
this.state.stopLoading();
}
}
/**
* 작업자별 성과 분석
*/
async analyzeWorkerPerformance() {
if (!this.state.canAnalyze()) {
this.showToast('기간을 먼저 확정해주세요', 'warning');
return;
}
const currentState = this.state.getState();
const { start, end } = currentState.confirmedPeriod;
try {
this.state.startLoading('작업자별 성과를 분석 중입니다...');
const workerStatsResponse = await this.api.getWorkerStats(start, end);
if (workerStatsResponse.success && workerStatsResponse.data) {
this.chartRenderer.renderWorkerPerformanceChart(workerStatsResponse.data);
this.showToast('작업자별 성과 분석이 완료되었습니다', 'success');
} else {
throw new Error('작업자 데이터를 가져올 수 없습니다');
}
} catch (error) {
console.error(' 작업자별 성과 분석 오류:', error);
this.state.setError(error);
this.showToast('작업자별 성과 분석에 실패했습니다', 'error');
} finally {
this.state.stopLoading();
}
}
/**
* 오류 분석
*/
async analyzeErrorAnalysis() {
if (!this.state.canAnalyze()) {
this.showToast('기간을 먼저 확정해주세요', 'warning');
return;
}
const currentState = this.state.getState();
const { start, end } = currentState.confirmedPeriod;
try {
this.state.startLoading('오류 분석을 진행 중입니다...');
// 병렬로 API 호출
const [recentWorkResponse, errorAnalysisResponse] = await Promise.all([
this.api.getRecentWork(start, end, 2000),
this.api.getErrorAnalysis(start, end)
]);
if (recentWorkResponse.success && recentWorkResponse.data) {
this.tableRenderer.renderErrorAnalysisTable(recentWorkResponse.data);
this.showToast('오류 분석이 완료되었습니다', 'success');
} else {
throw new Error('작업 데이터를 가져올 수 없습니다');
}
} catch (error) {
console.error(' 오류 분석 실패:', error);
this.state.setError(error);
this.showToast('오류 분석에 실패했습니다', 'error');
} finally {
this.state.stopLoading();
}
}
// ========== UI 업데이트 ==========
/**
* 결과 카드 업데이트
*/
updateResultCards(stats) {
const cards = {
totalHours: stats.totalHours || 0,
normalHours: stats.normalHours || 0,
errorHours: stats.errorHours || 0,
workerCount: stats.activeWorkers || 0,
errorRate: stats.errorRate || 0
};
Object.entries(cards).forEach(([key, value]) => {
const element = document.getElementById(key);
if (element) {
element.textContent = typeof value === 'number' ?
(key.includes('Rate') ? `${value}%` : value.toLocaleString()) : value;
}
});
}
/**
* 분석 버튼 상태 업데이트
*/
updateAnalysisButtons(enabled) {
const buttons = document.querySelectorAll('.chart-analyze-btn');
buttons.forEach(button => {
button.disabled = !enabled;
button.style.opacity = enabled ? '1' : '0.5';
});
}
/**
* 분석 탭 표시
*/
showAnalysisTabs() {
const tabNavigation = document.getElementById('analysisTabNavigation');
if (tabNavigation) {
tabNavigation.style.display = 'block';
}
}
/**
* 분석 탭 숨김
*/
hideAnalysisTabs() {
const tabNavigation = document.getElementById('analysisTabNavigation');
if (tabNavigation) {
tabNavigation.style.display = 'none';
}
}
/**
* 활성 탭 업데이트
*/
updateActiveTab(tabId) {
// 탭 버튼 업데이트
document.querySelectorAll('.tab-button').forEach(button => {
button.classList.remove('active');
if (button.dataset.tab === tabId) {
button.classList.add('active');
}
});
// 탭 컨텐츠 업데이트
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.remove('active');
if (content.id === `${tabId}-tab`) {
content.classList.add('active');
}
});
}
/**
* 모드 버튼 업데이트
*/
updateModeButtons(mode) {
document.querySelectorAll('[data-mode]').forEach(button => {
button.classList.remove('active');
if (button.dataset.mode === mode) {
button.classList.add('active');
}
});
}
/**
* 로딩 표시
*/
showLoading(message = '분석 중입니다...') {
const loadingElement = document.getElementById('loadingState');
if (loadingElement) {
const textElement = loadingElement.querySelector('.loading-text');
if (textElement) {
textElement.textContent = message;
}
loadingElement.style.display = 'flex';
}
}
/**
* 로딩 숨김
*/
hideLoading() {
const loadingElement = document.getElementById('loadingState');
if (loadingElement) {
loadingElement.style.display = 'none';
}
}
/**
* 토스트 메시지 표시
*/
showToast(message, type = 'info') {
// 간단한 토스트 구현 (실제로는 더 정교한 토스트 라이브러리 사용 권장)
if (type === 'error') {
alert(`${message}`);
} else if (type === 'success') {
} else if (type === 'warning') {
alert(`⚠️ ${message}`);
}
}
/**
* 에러 처리
*/
handleError(errorInfo) {
console.error(' 에러 발생:', errorInfo);
this.showToast(errorInfo.message, 'error');
}
// ========== 유틸리티 ==========
/**
* 컨트롤러 상태 디버그
*/
debug() {
console.log('- API 클라이언트:', this.api);
console.log('- 상태 관리자:', this.state.getState());
console.log('- 차트 상태:', this.chartRenderer.getChartStatus());
}
}
// 전역 인스턴스 생성 및 초기화
document.addEventListener('DOMContentLoaded', () => {
window.WorkAnalysisMainController = new WorkAnalysisMainController();
});
// Export는 브라우저 환경에서 제거됨

View File

@@ -1,261 +0,0 @@
/**
* Work Analysis Module Loader
* 작업 분석 모듈들을 순서대로 로드하고 초기화하는 로더
*/
class WorkAnalysisModuleLoader {
constructor() {
this.modules = [
{ name: 'API Client', path: '/js/work-analysis/api-client.js', loaded: false },
{ name: 'Data Processor', path: '/js/work-analysis/data-processor.js', loaded: false },
{ name: 'State Manager', path: '/js/work-analysis/state-manager.js', loaded: false },
{ name: 'Table Renderer', path: '/js/work-analysis/table-renderer.js', loaded: false },
{ name: 'Chart Renderer', path: '/js/work-analysis/chart-renderer.js', loaded: false },
{ name: 'Main Controller', path: '/js/work-analysis/main-controller.js', loaded: false }
];
this.loadingPromise = null;
}
/**
* 모든 모듈 로드
* @returns {Promise} 로딩 완료 Promise
*/
async loadAll() {
if (this.loadingPromise) {
return this.loadingPromise;
}
this.loadingPromise = this._loadModules();
return this.loadingPromise;
}
/**
* 모듈들을 순차적으로 로드
*/
async _loadModules() {
try {
// 의존성 순서대로 로드
for (const module of this.modules) {
await this._loadModule(module);
}
this._onAllModulesLoaded();
} catch (error) {
console.error(' 모듈 로딩 실패:', error);
this._onLoadingError(error);
throw error;
}
}
/**
* 개별 모듈 로드
* @param {Object} module - 모듈 정보
*/
async _loadModule(module) {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = module.path;
script.type = 'text/javascript';
script.onload = () => {
module.loaded = true;
resolve();
};
script.onerror = (error) => {
console.error(` 로딩 실패: ${module.name}`, error);
reject(new Error(`Failed to load ${module.name}: ${module.path}`));
};
document.head.appendChild(script);
});
}
/**
* 모든 모듈 로딩 완료 시 호출
*/
_onAllModulesLoaded() {
// 전역 변수 확인
const requiredGlobals = [
'WorkAnalysisAPI',
'WorkAnalysisDataProcessor',
'WorkAnalysisState',
'WorkAnalysisTableRenderer',
'WorkAnalysisChartRenderer'
];
const missingGlobals = requiredGlobals.filter(name => !window[name]);
if (missingGlobals.length > 0) {
console.warn(' 일부 전역 객체가 누락됨:', missingGlobals);
}
// 하위 호환성을 위한 전역 함수들 설정
this._setupLegacyFunctions();
// 모듈 로딩 완료 이벤트 발생
window.dispatchEvent(new CustomEvent('workAnalysisModulesLoaded', {
detail: { modules: this.modules }
}));
}
/**
* 하위 호환성을 위한 전역 함수 설정
*/
_setupLegacyFunctions() {
// 기존 HTML에서 사용하던 함수들을 새 모듈 시스템으로 연결
const legacyFunctions = {
// 기간 확정
confirmPeriod: () => {
if (window.WorkAnalysisMainController) {
window.WorkAnalysisMainController.handlePeriodConfirm();
}
},
// 분석 모드 변경
switchAnalysisMode: (mode) => {
if (window.WorkAnalysisState) {
window.WorkAnalysisState.setAnalysisMode(mode);
}
},
// 탭 변경
switchTab: (tabId) => {
if (window.WorkAnalysisState) {
window.WorkAnalysisState.setCurrentTab(tabId);
}
},
// 개별 분석 함수들
analyzeWorkStatus: () => {
if (window.WorkAnalysisMainController) {
window.WorkAnalysisMainController.analyzeWorkStatus();
}
},
analyzeProjectDistribution: () => {
if (window.WorkAnalysisMainController) {
window.WorkAnalysisMainController.analyzeProjectDistribution();
}
},
analyzeWorkerPerformance: () => {
if (window.WorkAnalysisMainController) {
window.WorkAnalysisMainController.analyzeWorkerPerformance();
}
},
analyzeErrorAnalysis: () => {
if (window.WorkAnalysisMainController) {
window.WorkAnalysisMainController.analyzeErrorAnalysis();
}
}
};
// 전역 함수로 등록
Object.assign(window, legacyFunctions);
}
/**
* 로딩 에러 처리
*/
_onLoadingError(error) {
// 에러 UI 표시
const container = document.querySelector('.analysis-container');
if (container) {
const errorHTML = `
<div style="
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 50vh;
text-align: center;
color: #ef4444;
">
<div style="font-size: 4rem; margin-bottom: 1rem;">⚠️</div>
<h2 style="margin-bottom: 1rem;">모듈 로딩 실패</h2>
<p style="margin-bottom: 2rem; color: #666;">
작업 분석 시스템을 로드하는 중 오류가 발생했습니다.<br>
페이지를 새로고침하거나 관리자에게 문의하세요.
</p>
<button onclick="location.reload()" style="
padding: 0.75rem 1.5rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 0.5rem;
cursor: pointer;
font-size: 1rem;
">
페이지 새로고침
</button>
<details style="margin-top: 2rem; text-align: left; max-width: 600px;">
<summary style="cursor: pointer; color: #666;">기술적 세부사항</summary>
<pre style="
background: #f8f9fa;
padding: 1rem;
border-radius: 0.5rem;
margin-top: 1rem;
overflow-x: auto;
font-size: 0.875rem;
">${error.message}</pre>
</details>
</div>
`;
container.innerHTML = errorHTML;
}
}
/**
* 로딩 상태 확인
* @returns {Object} 로딩 상태 정보
*/
getLoadingStatus() {
const total = this.modules.length;
const loaded = this.modules.filter(m => m.loaded).length;
return {
total,
loaded,
percentage: Math.round((loaded / total) * 100),
isComplete: loaded === total,
modules: this.modules.map(m => ({
name: m.name,
loaded: m.loaded
}))
};
}
/**
* 특정 모듈 로딩 상태 확인
* @param {string} moduleName - 모듈명
* @returns {boolean} 로딩 완료 여부
*/
isModuleLoaded(moduleName) {
const module = this.modules.find(m => m.name === moduleName);
return module ? module.loaded : false;
}
}
// 전역 인스턴스 생성
window.WorkAnalysisModuleLoader = new WorkAnalysisModuleLoader();
// 자동 로딩 시작 (DOM이 준비되면)
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
window.WorkAnalysisModuleLoader.loadAll();
});
} else {
// DOM이 이미 준비된 경우 즉시 로딩
window.WorkAnalysisModuleLoader.loadAll();
}
// Export는 브라우저 환경에서 제거됨

View File

@@ -1,374 +0,0 @@
/**
* Work Analysis State Manager Module
* 작업 분석 페이지의 상태 관리를 담당하는 모듈
*/
class WorkAnalysisStateManager {
constructor() {
this.state = {
// 분석 설정
analysisMode: 'period', // 'period' | 'project'
confirmedPeriod: {
start: null,
end: null,
confirmed: false
},
// UI 상태
currentTab: 'work-status',
isAnalysisEnabled: false,
isLoading: false,
// 데이터 캐시
cache: {
basicStats: null,
chartData: null,
projectDistribution: null,
errorAnalysis: null
},
// 에러 상태
lastError: null
};
this.listeners = new Map();
this.init();
}
/**
* 초기화
*/
init() {
// 기본 날짜 설정 (현재 월)
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0);
this.updateState({
confirmedPeriod: {
start: this.formatDate(startOfMonth),
end: this.formatDate(endOfMonth),
confirmed: false
}
});
}
/**
* 상태 업데이트
* @param {Object} updates - 업데이트할 상태
*/
updateState(updates) {
const prevState = { ...this.state };
this.state = { ...this.state, ...updates };
// 리스너들에게 상태 변경 알림
this.notifyListeners(prevState, this.state);
}
/**
* 상태 리스너 등록
* @param {string} key - 리스너 키
* @param {Function} callback - 콜백 함수
*/
subscribe(key, callback) {
this.listeners.set(key, callback);
}
/**
* 상태 리스너 제거
* @param {string} key - 리스너 키
*/
unsubscribe(key) {
this.listeners.delete(key);
}
/**
* 리스너들에게 알림
*/
notifyListeners(prevState, newState) {
this.listeners.forEach((callback, key) => {
try {
callback(newState, prevState);
} catch (error) {
console.error(` 리스너 ${key} 오류:`, error);
}
});
}
// ========== 분석 설정 관리 ==========
/**
* 분석 모드 변경
* @param {string} mode - 분석 모드 ('period' | 'project')
*/
setAnalysisMode(mode) {
if (mode !== 'period' && mode !== 'project') {
throw new Error('유효하지 않은 분석 모드입니다.');
}
this.updateState({
analysisMode: mode,
currentTab: 'work-status' // 모드 변경 시 첫 번째 탭으로 리셋
});
}
/**
* 기간 확정
* @param {string} startDate - 시작일
* @param {string} endDate - 종료일
*/
confirmPeriod(startDate, endDate) {
// 날짜 유효성 검사
if (!startDate || !endDate) {
throw new Error('시작일과 종료일을 모두 입력해주세요.');
}
const start = new Date(startDate);
const end = new Date(endDate);
if (start > end) {
throw new Error('시작일이 종료일보다 늦을 수 없습니다.');
}
// 최대 1년 제한
const maxDays = 365;
const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24));
if (daysDiff > maxDays) {
throw new Error(`분석 기간은 최대 ${maxDays}일까지 가능합니다.`);
}
this.updateState({
confirmedPeriod: {
start: startDate,
end: endDate,
confirmed: true
},
isAnalysisEnabled: true,
// 기간 변경 시 캐시 초기화
cache: {
basicStats: null,
chartData: null,
projectDistribution: null,
errorAnalysis: null
}
});
}
/**
* 현재 탭 변경
* @param {string} tabId - 탭 ID
*/
setCurrentTab(tabId) {
const validTabs = ['work-status', 'project-distribution', 'worker-performance', 'error-analysis'];
if (!validTabs.includes(tabId)) {
console.warn('유효하지 않은 탭 ID:', tabId);
return;
}
this.updateState({ currentTab: tabId });
// DOM 업데이트 직접 수행
this.updateTabDOM(tabId);
}
/**
* 탭 DOM 업데이트
* @param {string} tabId - 탭 ID
*/
updateTabDOM(tabId) {
// 탭 버튼 업데이트
document.querySelectorAll('.tab-button').forEach(button => {
button.classList.remove('active');
if (button.dataset.tab === tabId) {
button.classList.add('active');
}
});
// 탭 컨텐츠 업데이트
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.remove('active');
if (content.id === `${tabId}-tab`) {
content.classList.add('active');
}
});
}
// ========== 로딩 상태 관리 ==========
/**
* 로딩 시작
* @param {string} message - 로딩 메시지
*/
startLoading(message = '분석 중입니다...') {
this.updateState({
isLoading: true,
loadingMessage: message
});
}
/**
* 로딩 종료
*/
stopLoading() {
this.updateState({
isLoading: false,
loadingMessage: null
});
}
// ========== 데이터 캐시 관리 ==========
/**
* 캐시 데이터 저장
* @param {string} key - 캐시 키
* @param {*} data - 저장할 데이터
*/
setCache(key, data) {
this.updateState({
cache: {
...this.state.cache,
[key]: {
data,
timestamp: Date.now()
}
}
});
}
/**
* 캐시 데이터 조회
* @param {string} key - 캐시 키
* @param {number} maxAge - 최대 유효 시간 (밀리초)
* @returns {*} 캐시된 데이터 또는 null
*/
getCache(key, maxAge = 5 * 60 * 1000) { // 기본 5분
const cached = this.state.cache[key];
if (!cached) {
return null;
}
const age = Date.now() - cached.timestamp;
if (age > maxAge) {
return null;
}
return cached.data;
}
/**
* 캐시 초기화
* @param {string} key - 특정 키만 초기화 (선택사항)
*/
clearCache(key = null) {
if (key) {
this.updateState({
cache: {
...this.state.cache,
[key]: null
}
});
} else {
this.updateState({
cache: {
basicStats: null,
chartData: null,
projectDistribution: null,
errorAnalysis: null
}
});
}
}
// ========== 에러 관리 ==========
/**
* 에러 설정
* @param {Error|string} error - 에러 객체 또는 메시지
*/
setError(error) {
const errorInfo = {
message: error instanceof Error ? error.message : error,
timestamp: Date.now(),
stack: error instanceof Error ? error.stack : null
};
this.updateState({
lastError: errorInfo,
isLoading: false
});
console.error(' 에러 발생:', errorInfo);
}
/**
* 에러 초기화
*/
clearError() {
this.updateState({ lastError: null });
}
// ========== 유틸리티 ==========
/**
* 날짜 포맷팅
* @param {Date} date - 날짜 객체
* @returns {string} YYYY-MM-DD 형식
*/
formatDate(date) {
return date.toISOString().split('T')[0];
}
/**
* 현재 상태 조회
* @returns {Object} 현재 상태
*/
getState() {
return { ...this.state };
}
/**
* 분석 가능 여부 확인
* @returns {boolean} 분석 가능 여부
*/
canAnalyze() {
return this.state.confirmedPeriod.confirmed &&
this.state.confirmedPeriod.start &&
this.state.confirmedPeriod.end &&
!this.state.isLoading;
}
/**
* 상태 디버그 정보 출력
*/
debug() {
}
}
// 전역 인스턴스 생성
window.WorkAnalysisState = new WorkAnalysisStateManager();
// 하위 호환성을 위한 전역 변수들
Object.defineProperty(window, 'currentAnalysisMode', {
get: () => window.WorkAnalysisState.state.analysisMode,
set: (value) => window.WorkAnalysisState.setAnalysisMode(value)
});
Object.defineProperty(window, 'confirmedStartDate', {
get: () => window.WorkAnalysisState.state.confirmedPeriod.start
});
Object.defineProperty(window, 'confirmedEndDate', {
get: () => window.WorkAnalysisState.state.confirmedPeriod.end
});
Object.defineProperty(window, 'isAnalysisEnabled', {
get: () => window.WorkAnalysisState.state.isAnalysisEnabled
});
// Export는 브라우저 환경에서 제거됨

View File

@@ -1,501 +0,0 @@
/**
* Work Analysis Table Renderer Module
* 작업 분석 테이블 렌더링을 담당하는 모듈
*/
class WorkAnalysisTableRenderer {
constructor() {
this.dataProcessor = window.WorkAnalysisDataProcessor;
}
// ========== 프로젝트 분포 테이블 ==========
/**
* 프로젝트 분포 테이블 렌더링 (Production Report 스타일)
* @param {Array} projectData - 프로젝트 데이터
* @param {Array} workerData - 작업자 데이터
*/
renderProjectDistributionTable(projectData, workerData) {
const tbody = document.getElementById('projectDistributionTableBody');
const tfoot = document.getElementById('projectDistributionTableFooter');
if (!tbody) {
console.error(' projectDistributionTableBody 요소를 찾을 수 없습니다');
return;
}
// 프로젝트 데이터가 없으면 작업자 데이터로 대체
if (!projectData || !projectData.projects || projectData.projects.length === 0) {
this._renderFallbackTable(workerData, tbody, tfoot);
return;
}
let tableRows = [];
let grandTotalHours = 0;
let grandTotalManDays = 0;
let grandTotalLaborCost = 0;
// 공수당 인건비 (350,000원)
const manDayRate = 350000;
// 먼저 전체 시간을 계산 (부하율 계산용)
projectData.projects.forEach(project => {
project.workTypes.forEach(workType => {
grandTotalHours += workType.totalHours;
});
});
// 프로젝트별로 렌더링
projectData.projects.forEach(project => {
const projectName = project.project_name || '알 수 없는 프로젝트';
const jobNo = project.job_no || 'N/A';
const workTypes = project.workTypes || [];
if (workTypes.length === 0) {
// 작업유형이 없는 경우
const projectHours = project.totalHours || 0;
const manDays = Math.round((projectHours / 8) * 100) / 100;
const laborCost = manDays * manDayRate;
const loadRate = grandTotalHours > 0 ? ((projectHours / grandTotalHours) * 100).toFixed(2) : '0.00';
grandTotalManDays += manDays;
grandTotalLaborCost += laborCost;
const isVacation = project.project_id === 'vacation';
const displayText = isVacation ? projectName : jobNo;
tableRows.push(`
<tr class="project-group ${isVacation ? 'vacation-project' : ''}">
<td class="project-name">${displayText}</td>
<td class="work-content">데이터 없음</td>
<td class="man-days">${manDays}</td>
<td class="load-rate">${loadRate}%</td>
<td class="labor-cost">₩${laborCost.toLocaleString()}</td>
</tr>
`);
} else {
// 작업유형별 렌더링
workTypes.forEach((workType, index) => {
const isFirstWorkType = index === 0;
const rowspan = workTypes.length;
const workTypeHours = workType.totalHours || 0;
const manDays = Math.round((workTypeHours / 8) * 100) / 100;
const laborCost = manDays * manDayRate;
const loadRate = grandTotalHours > 0 ? ((workTypeHours / grandTotalHours) * 100).toFixed(2) : '0.00';
grandTotalManDays += manDays;
grandTotalLaborCost += laborCost;
const isVacation = project.project_id === 'vacation';
const displayText = isVacation ? projectName : jobNo;
tableRows.push(`
<tr class="project-group ${isVacation ? 'vacation-project' : ''}">
${isFirstWorkType ? `<td class="project-name" rowspan="${rowspan}">${displayText}</td>` : ''}
<td class="work-content">${workType.work_type_name}</td>
<td class="man-days">${manDays}</td>
<td class="load-rate">${loadRate}%</td>
<td class="labor-cost">₩${laborCost.toLocaleString()}</td>
</tr>
`);
});
// 프로젝트 소계 행 추가
const projectTotalHours = workTypes.reduce((sum, wt) => sum + (wt.totalHours || 0), 0);
const projectTotalManDays = Math.round((projectTotalHours / 8) * 100) / 100;
const projectTotalLaborCost = projectTotalManDays * manDayRate;
const projectLoadRate = grandTotalHours > 0 ? ((projectTotalHours / grandTotalHours) * 100).toFixed(2) : '0.00';
tableRows.push(`
<tr class="project-subtotal">
<td colspan="2"><strong>${projectName} 소계</strong></td>
<td><strong>${projectTotalManDays}</strong></td>
<td><strong>${projectLoadRate}%</strong></td>
<td><strong>₩${projectTotalLaborCost.toLocaleString()}</strong></td>
</tr>
`);
}
});
// 테이블 업데이트
tbody.innerHTML = tableRows.join('');
// 총계 업데이트
if (tfoot) {
document.getElementById('totalManDays').textContent = grandTotalManDays.toFixed(2);
document.getElementById('totalLaborCost').textContent = `${grandTotalLaborCost.toLocaleString()}`;
tfoot.style.display = 'table-footer-group';
}
}
/**
* 대체 테이블 렌더링 (작업자 데이터 기반)
*/
_renderFallbackTable(workerData, tbody, tfoot) {
if (!workerData || workerData.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="5" style="text-align: center; padding: 2rem; color: #666;">
해당 기간에 데이터가 없습니다
</td>
</tr>
`;
if (tfoot) tfoot.style.display = 'none';
return;
}
const manDayRate = 350000;
let totalManDays = 0;
let totalLaborCost = 0;
const tableRows = workerData.map(worker => {
const hours = worker.totalHours || 0;
const manDays = Math.round((hours / 8) * 100) / 100;
const laborCost = manDays * manDayRate;
totalManDays += manDays;
totalLaborCost += laborCost;
return `
<tr class="project-group">
<td class="project-name">작업자 기반</td>
<td class="work-content">${worker.worker_name}</td>
<td class="man-days">${manDays}</td>
<td class="load-rate">-</td>
<td class="labor-cost">₩${laborCost.toLocaleString()}</td>
</tr>
`;
});
tbody.innerHTML = tableRows.join('');
// 총계 업데이트
if (tfoot) {
document.getElementById('totalManDays').textContent = totalManDays.toFixed(2);
document.getElementById('totalLaborCost').textContent = `${totalLaborCost.toLocaleString()}`;
tfoot.style.display = 'table-footer-group';
}
}
// ========== 오류 분석 테이블 ==========
/**
* 오류 분석 테이블 렌더링
* @param {Array} recentWorkData - 최근 작업 데이터
*/
renderErrorAnalysisTable(recentWorkData) {
const tableBody = document.getElementById('errorAnalysisTableBody');
const tableFooter = document.getElementById('errorAnalysisTableFooter');
// DOM 요소 존재 확인
if (!tableBody) {
console.error(' errorAnalysisTableBody 요소를 찾을 수 없습니다');
return;
}
if (!recentWorkData || recentWorkData.length === 0) {
tableBody.innerHTML = `
<tr>
<td colspan="6" style="text-align: center; padding: 2rem; color: #666;">
해당 기간에 오류 데이터가 없습니다
</td>
</tr>
`;
if (tableFooter) {
tableFooter.style.display = 'none';
}
return;
}
// 작업 형태별 오류 데이터 집계
const errorData = this.dataProcessor.aggregateErrorData(recentWorkData);
let tableRows = [];
let grandTotalHours = 0;
let grandTotalRegularHours = 0;
let grandTotalErrorHours = 0;
// 프로젝트별로 그룹화
const projectGroups = new Map();
errorData.forEach(workType => {
const projectKey = workType.isVacation ? 'vacation' : workType.project_id;
if (!projectGroups.has(projectKey)) {
projectGroups.set(projectKey, []);
}
projectGroups.get(projectKey).push(workType);
});
// 프로젝트별로 렌더링
Array.from(projectGroups.entries()).forEach(([projectKey, workTypes]) => {
workTypes.forEach((workType, index) => {
grandTotalHours += workType.totalHours;
grandTotalRegularHours += workType.regularHours;
grandTotalErrorHours += workType.errorHours;
const rowClass = workType.isVacation ? 'vacation-project' : 'project-group';
const isFirstWorkType = index === 0;
const rowspan = workTypes.length;
// 세부시간 구성
let detailHours = [];
if (workType.regularHours > 0) {
detailHours.push(`<span class="regular-hours">정규: ${workType.regularHours}h</span>`);
}
// 오류 세부사항 추가
workType.errorDetails.forEach(error => {
detailHours.push(`<span class="error-hours">오류: ${error.type} ${error.hours}h</span>`);
});
// 작업 타입 구성 (단순화)
let workTypeDisplay = '';
if (workType.regularHours > 0) {
workTypeDisplay += `
<div class="work-type-item regular">
<span class="work-type-status">정규시간</span>
</div>
`;
}
workType.errorDetails.forEach(error => {
workTypeDisplay += `
<div class="work-type-item error">
<span class="work-type-status">오류: ${error.type}</span>
</div>
`;
});
tableRows.push(`
<tr class="${rowClass}">
${isFirstWorkType ? `<td class="project-name" rowspan="${rowspan}">${workType.isVacation ? '연차/휴무' : (workType.project_name || 'N/A')}</td>` : ''}
<td class="work-content">${workType.work_type_name}</td>
<td class="total-hours">${workType.totalHours}h</td>
<td class="detail-hours">
${detailHours.join('<br>')}
</td>
<td class="work-type">
<div class="work-type-breakdown">
${workTypeDisplay}
</div>
</td>
<td class="error-percentage ${workType.errorHours > 0 ? 'has-error' : ''}">${workType.errorRate}%</td>
</tr>
`);
});
});
if (tableRows.length === 0) {
tableBody.innerHTML = `
<tr>
<td colspan="6" style="text-align: center; padding: 2rem; color: #666;">
해당 기간에 작업 데이터가 없습니다
</td>
</tr>
`;
if (tableFooter) {
tableFooter.style.display = 'none';
}
} else {
tableBody.innerHTML = tableRows.join('');
// 총계 업데이트
const totalErrorRate = grandTotalHours > 0 ? ((grandTotalErrorHours / grandTotalHours) * 100).toFixed(1) : '0.0';
// 안전한 DOM 요소 접근
const totalErrorHoursElement = document.getElementById('totalErrorHours');
if (totalErrorHoursElement) {
totalErrorHoursElement.textContent = `${grandTotalHours}h`;
}
if (tableFooter) {
const detailHoursCell = tableFooter.querySelector('.total-row td:nth-child(4)');
const errorRateCell = tableFooter.querySelector('.total-row td:nth-child(6)');
if (detailHoursCell) {
detailHoursCell.innerHTML = `
<strong>정규: ${grandTotalRegularHours}h<br>오류: ${grandTotalErrorHours}h</strong>
`;
}
if (errorRateCell) {
errorRateCell.innerHTML = `<strong>${totalErrorRate}%</strong>`;
}
tableFooter.style.display = 'table-footer-group';
}
}
}
// ========== 기간별 작업 현황 테이블 ==========
/**
* 기간별 작업 현황 테이블 렌더링
* @param {Array} projectData - 프로젝트 데이터
* @param {Array} workerData - 작업자 데이터
* @param {Array} recentWorkData - 최근 작업 데이터
*/
renderWorkStatusTable(projectData, workerData, recentWorkData) {
const tableContainer = document.querySelector('#work-status-tab .table-container');
if (!tableContainer) {
console.error(' 작업 현황 테이블 컨테이너를 찾을 수 없습니다');
return;
}
// 데이터가 없는 경우 처리
if (!workerData || workerData.length === 0) {
tableContainer.innerHTML = `
<div style="text-align: center; padding: 3rem; color: #666;">
<div style="font-size: 3rem; margin-bottom: 1rem;">📊</div>
<div style="font-size: 1.2rem; margin-bottom: 0.5rem;">데이터가 없습니다</div>
<div style="font-size: 0.9rem;">선택한 기간에 작업 데이터가 없습니다.</div>
</div>
`;
return;
}
// 작업자별 데이터 처리
const workerStats = this._processWorkerStats(workerData, recentWorkData);
let tableHTML = `
<table class="work-status-table">
<thead>
<tr>
<th>작업자</th>
<th>분류(프로젝트)</th>
<th>작업내용</th>
<th>투입시간</th>
<th>작업공수</th>
<th>작업일/일평균시간</th>
<th>비고</th>
</tr>
</thead>
<tbody>
`;
let totalHours = 0;
let totalManDays = 0;
workerStats.forEach(worker => {
worker.projects.forEach((project, projectIndex) => {
project.workTypes.forEach((workType, workTypeIndex) => {
const isFirstProject = projectIndex === 0 && workTypeIndex === 0;
const workerRowspan = worker.totalRowspan;
totalHours += workType.hours;
totalManDays += workType.manDays;
tableHTML += `
<tr class="worker-group">
${isFirstProject ? `
<td class="worker-name" rowspan="${workerRowspan}">${worker.name}</td>
` : ''}
<td class="project-name">${project.name}</td>
<td class="work-content">${workType.name}</td>
<td class="work-hours">${workType.hours}h</td>
${isFirstProject ? `
<td class="man-days" rowspan="${workerRowspan}">${worker.totalManDays.toFixed(1)}</td>
<td class="work-days" rowspan="${workerRowspan}">${worker.workDays}일 / ${worker.avgHours.toFixed(1)}h</td>
` : ''}
<td class="remarks">${workType.remarks}</td>
</tr>
`;
});
});
});
tableHTML += `
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="3"><strong>총 공수</strong></td>
<td><strong>${totalHours}h</strong></td>
<td><strong>${totalManDays.toFixed(1)}</strong></td>
<td colspan="2"></td>
</tr>
</tfoot>
</table>
`;
tableContainer.innerHTML = tableHTML;
}
/**
* 작업자별 통계 처리 (내부 헬퍼)
*/
_processWorkerStats(workerData, recentWorkData) {
if (!workerData || workerData.length === 0) {
return [];
}
return workerData.map(worker => {
// 해당 작업자의 작업 데이터 필터링
const workerWork = recentWorkData ?
recentWorkData.filter(work => work.user_id === worker.user_id) : [];
// 프로젝트별로 그룹화
const projectMap = new Map();
workerWork.forEach(work => {
const projectKey = work.project_id || 'unknown';
if (!projectMap.has(projectKey)) {
projectMap.set(projectKey, {
name: work.project_name || `프로젝트 ${projectKey}`,
workTypes: new Map()
});
}
const project = projectMap.get(projectKey);
const workTypeKey = work.work_type_id || 'unknown';
const workTypeName = work.work_type_name || `작업유형 ${workTypeKey}`;
if (!project.workTypes.has(workTypeKey)) {
project.workTypes.set(workTypeKey, {
name: workTypeName,
hours: 0,
remarks: '정상'
});
}
const workType = project.workTypes.get(workTypeKey);
workType.hours += parseFloat(work.work_hours) || 0;
// 오류가 있으면 비고 업데이트
if (work.work_status === 'error' || work.error_type_id) {
workType.remarks = work.error_type_name || work.error_description || '오류';
}
});
// 프로젝트 배열로 변환
const projects = Array.from(projectMap.values()).map(project => ({
...project,
workTypes: Array.from(project.workTypes.values()).map(wt => ({
...wt,
manDays: Math.round((wt.hours / 8) * 10) / 10
}))
}));
// 전체 행 수 계산
const totalRowspan = projects.reduce((sum, p) => sum + p.workTypes.length, 0);
return {
name: worker.worker_name,
totalHours: worker.totalHours || 0,
totalManDays: (worker.totalHours || 0) / 8,
workDays: worker.workingDays || 0,
avgHours: worker.avgHours || 0,
projects,
totalRowspan: Math.max(totalRowspan, 1)
};
});
}
}
// 전역 인스턴스 생성
window.WorkAnalysisTableRenderer = new WorkAnalysisTableRenderer();
// Export는 브라우저 환경에서 제거됨

View File

@@ -1,172 +0,0 @@
// 작업 관리 페이지 JavaScript
// 전역 변수
let statsData = {
projects: 0,
workers: 0,
tasks: 0,
codeTypes: 0
};
// 페이지 초기화
document.addEventListener('DOMContentLoaded', function() {
initializePage();
loadStatistics();
});
// 페이지 초기화
function initializePage() {
// 시간 업데이트 시작
updateCurrentTime();
setInterval(updateCurrentTime, 1000);
// 사용자 정보 업데이트
updateUserInfo();
// 프로필 메뉴 토글
setupProfileMenu();
// 로그아웃 버튼
setupLogoutButton();
}
// 현재 시간 업데이트 (시 분 초 형식으로 고정)
function updateCurrentTime() {
const now = new Date();
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
const timeString = `${hours}${minutes}${seconds}`;
const timeElement = document.getElementById('timeValue');
if (timeElement) {
timeElement.textContent = timeString;
}
}
// navbar/sidebar는 app-init.js에서 공통 처리
function updateUserInfo() {
// app-init.js가 navbar 사용자 정보를 처리
}
// 프로필 메뉴 설정
function setupProfileMenu() {
const userProfile = document.getElementById('userProfile');
const profileMenu = document.getElementById('profileMenu');
if (userProfile && profileMenu) {
userProfile.addEventListener('click', function(e) {
e.stopPropagation();
const isVisible = profileMenu.style.display === 'block';
profileMenu.style.display = isVisible ? 'none' : 'block';
});
// 외부 클릭 시 메뉴 닫기
document.addEventListener('click', function() {
profileMenu.style.display = 'none';
});
}
}
// 로그아웃 버튼 설정
function setupLogoutButton() {
const logoutBtn = document.getElementById('logoutBtn');
if (logoutBtn) {
logoutBtn.addEventListener('click', function() {
if (confirm('로그아웃 하시겠습니까?')) {
if (window.clearSSOAuth) window.clearSSOAuth();
window.location.href = window.getLoginUrl ? window.getLoginUrl() : '/login';
}
});
}
}
// 통계 데이터 로드
async function loadStatistics() {
try {
// 프로젝트 수 조회
try {
const projectsResponse = await apiCall('/projects', 'GET');
if (projectsResponse && Array.isArray(projectsResponse)) {
statsData.projects = projectsResponse.length;
updateStatDisplay('projectCount', statsData.projects);
}
} catch (error) {
console.warn('프로젝트 통계 로드 실패:', error);
updateStatDisplay('projectCount', '오류');
}
// 작업자 수 조회
try {
const workersResponse = await apiCall('/workers', 'GET');
if (workersResponse && Array.isArray(workersResponse)) {
const activeWorkers = workersResponse.filter(w => w.status === 'active');
statsData.workers = activeWorkers.length;
updateStatDisplay('workerCount', statsData.workers);
}
} catch (error) {
console.warn('작업자 통계 로드 실패:', error);
updateStatDisplay('workerCount', '오류');
}
// 작업 유형 수 조회
try {
const tasksResponse = await apiCall('/tasks', 'GET');
if (tasksResponse && Array.isArray(tasksResponse)) {
const activeTasks = tasksResponse.filter(t => t.is_active);
statsData.tasks = activeTasks.length;
updateStatDisplay('taskCount', statsData.tasks);
}
} catch (error) {
console.warn('작업 유형 통계 로드 실패:', error);
updateStatDisplay('taskCount', '오류');
}
// 코드 타입 수 조회 (임시로 고정값)
statsData.codeTypes = 3; // ISSUE_TYPE, ERROR_TYPE, WORK_STATUS
updateStatDisplay('codeTypeCount', statsData.codeTypes);
} catch (error) {
console.error('통계 데이터 로딩 오류:', error);
}
}
// 통계 표시 업데이트
function updateStatDisplay(elementId, value) {
const element = document.getElementById(elementId);
if (element) {
element.textContent = value;
// 애니메이션 효과
element.style.transform = 'scale(1.1)';
setTimeout(() => {
element.style.transform = 'scale(1)';
}, 200);
}
}
// 최근 활동 관련 함수들 제거됨
// 페이지 네비게이션
function navigateToPage(url) {
// 로딩 효과
const card = event.currentTarget;
const originalContent = card.innerHTML;
card.style.opacity = '0.7';
card.style.pointerEvents = 'none';
// 잠시 후 페이지 이동
setTimeout(() => {
window.location.href = url;
}, 300);
}
// showToast → api-base.js 전역 사용
// 전역 함수로 노출
window.navigateToPage = navigateToPage;

View File

@@ -1,213 +0,0 @@
import { renderCalendar } from '/js/calendar.js';
import { API, getAuthHeaders, ensureAuthenticated } from '/js/api-config.js';
// 인증 확인
ensureAuthenticated();
const calendarEl = document.getElementById('calendar');
const reportBody = document.getElementById('reportBody');
let selectedDate = '';
// 캘린더 렌더링
renderCalendar('calendar', (dateStr) => {
selectedDate = dateStr;
loadReports();
});
// 보고서 로딩
async function loadReports() {
if (!selectedDate) return;
reportBody.innerHTML = '<tr><td colspan="8">불러오는 중...</td></tr>';
try {
const [wRes, pRes, rRes] = await Promise.all([
fetch(`${API}/workers`, { headers: getAuthHeaders() }),
fetch(`${API}/projects/active/list`, { headers: getAuthHeaders() }),
fetch(`${API}/workreports?start=${selectedDate}&end=${selectedDate}`, { headers: getAuthHeaders() })
]);
if (![wRes, pRes, rRes].every(res => res.ok)) throw new Error('불러오기 실패');
const [allWorkers, projects, reports] = await Promise.all([
wRes.json(), pRes.json(), rRes.json()
]);
// 활성화된 작업자만 필터링
const workers = allWorkers.filter(worker => {
return worker.status === 'active' || worker.is_active === 1 || worker.is_active === true;
});
// 배열 체크
if (!Array.isArray(workers) || !Array.isArray(projects) || !Array.isArray(reports)) {
throw new Error('잘못된 데이터 형식');
}
if (!reports.length) {
reportBody.innerHTML = '<tr><td colspan="8">등록된 보고서가 없습니다.</td></tr>';
return;
}
const nameMap = Object.fromEntries(workers.map(w => [w.user_id, w.worker_name]));
const projMap = Object.fromEntries(projects.map(p => [p.project_id, p.project_name]));
// const taskMap = Object.fromEntries(tasks.map(t => [t.task_id, `${t.category}:${t.subcategory}`])); // tasks 테이블 삭제됨
reportBody.innerHTML = '';
reports.forEach((r, i) => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${i + 1}</td>
<td>${nameMap[r.user_id] || r.user_id}</td>
<td><select data-id="project">
${projects.map(p =>
`<option value="${p.project_id}" ${p.project_id === r.project_id ? 'selected' : ''}>${p.project_name}</option>`
).join('')}</select></td>
<td><select data-id="task" disabled>
<option>작업 유형 (삭제됨)</option>
</select></td>
<td><input type="number" min="0" step="0.5" value="${r.overtime_hours || ''}" data-id="overtime"></td>
<td><select data-id="work_details">
${['근무', '연차', '유급', '반차', '반반차', '조퇴', '휴무'].map(opt =>
`<option value="${opt}" ${r.work_details === opt ? 'selected' : ''}>${opt}</option>`
).join('')}</select></td>
<td><input type="text" value="${r.memo || ''}" data-id="memo"></td>
<td>
<button class="action-btn save-btn">저장</button>
<button class="action-btn delete-btn">삭제</button>
</td>`;
// 저장 버튼
tr.querySelector('.save-btn').onclick = async () => {
// 입력값 검증
const projectId = tr.querySelector('[data-id="project"]').value;
const taskId = tr.querySelector('[data-id="task"]').value;
const overtimeHours = tr.querySelector('[data-id="overtime"]').value;
if (!projectId || !taskId) {
alert('❌ 프로젝트와 작업을 선택해주세요.');
return;
}
// 날짜 형식 처리 - MySQL DATE 형식으로 변환
const formatDate = (dateStr) => {
if (!dateStr) return selectedDate;
// 이미 YYYY-MM-DD 형식인지 확인
if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
return dateStr;
}
// ISO 형식이나 다른 형식을 YYYY-MM-DD로 변환
const date = new Date(dateStr);
if (isNaN(date.getTime())) {
return selectedDate; // 잘못된 날짜면 선택된 날짜 사용
}
return date.toISOString().split('T')[0]; // YYYY-MM-DD 형식으로 변환
};
const payload = {
date: formatDate(r.date), // 날짜 형식 변환
user_id: r.user_id, // 기존 작업자 ID 유지
project_id: Number(projectId),
task_id: Number(taskId),
overtime_hours: overtimeHours ? Number(overtimeHours) : null,
work_details: tr.querySelector('[data-id="work_details"]').value,
memo: tr.querySelector('[data-id="memo"]').value.trim() || null
};
// 저장 버튼 상태 변경 (로딩 중)
const saveBtn = tr.querySelector('.save-btn');
const originalText = saveBtn.textContent;
const originalColor = saveBtn.style.backgroundColor;
saveBtn.textContent = '저장 중...';
saveBtn.style.backgroundColor = '#ffc107';
saveBtn.disabled = true;
try {
const res = await fetch(`${API}/workreports/${r.id}`, {
method: 'PUT',
headers: {
...getAuthHeaders(),
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
const result = await res.json();
if (res.ok && result.success) {
// 성공 상태 표시
saveBtn.textContent = '✅ 완료';
saveBtn.style.backgroundColor = '#28a745';
saveBtn.style.color = 'white';
setTimeout(() => {
saveBtn.textContent = originalText;
saveBtn.style.backgroundColor = originalColor;
saveBtn.style.color = '';
saveBtn.disabled = false;
}, 2000);
// alert 대신 조용한 알림
console.log('저장 완료:', result);
} else {
console.error('저장 실패:', result);
alert(`❌ 저장 실패: ${result.error || result.message || '알 수 없는 오류'}`);
// 실패 시 버튼 복원
saveBtn.textContent = originalText;
saveBtn.style.backgroundColor = originalColor;
saveBtn.disabled = false;
}
} catch (err) {
console.error('저장 요청 에러:', err);
alert('❌ 저장 요청 실패: ' + err.message);
// 에러 시 버튼 복원
saveBtn.textContent = originalText;
saveBtn.style.backgroundColor = originalColor;
saveBtn.disabled = false;
}
};
// 삭제 버튼
tr.querySelector('.delete-btn').onclick = async () => {
if (!confirm('정말 삭제하시겠습니까?')) return;
try {
const res = await fetch(`${API}/workreports/${r.id}`, {
method: 'DELETE',
headers: getAuthHeaders()
});
if (res.ok) {
tr.remove();
// 행 번호 다시 매기기
updateRowNumbers();
alert('✅ 삭제 완료');
} else {
const result = await res.json();
alert(`❌ 삭제 실패: ${result.error || result.message || '알 수 없는 오류'}`);
}
} catch (err) {
console.error('삭제 요청 에러:', err);
alert('❌ 삭제 요청 실패: ' + err.message);
}
};
reportBody.appendChild(tr);
});
} catch (err) {
console.error('데이터 로딩 에러:', err);
reportBody.innerHTML = '<tr><td colspan="8">❌ 불러오기 실패: ' + err.message + '</td></tr>';
}
}
// 행 번호 다시 매기기
function updateRowNumbers() {
reportBody.querySelectorAll('tr').forEach((tr, i) => {
const firstTd = tr.querySelector('td:first-child');
if (firstTd) firstTd.textContent = i + 1;
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,771 +0,0 @@
// work-review.js - 통합 API 설정 적용 버전
// =================================================================
// 🌐 통합 API 설정 import
// =================================================================
import { API, getAuthHeaders, apiCall } from '/js/api-config.js';
// 전역 변수
let currentDate = new Date();
let selectedDate = null;
let selectedDateData = null;
let basicData = {
workTypes: [],
workStatusTypes: [],
errorTypes: [],
projects: []
};
// 현재 사용자 정보 가져오기
function getCurrentUser() {
try {
const token = localStorage.getItem('sso_token');
if (!token) return null;
const payloadBase64 = token.split('.')[1];
if (payloadBase64) {
const payload = JSON.parse(atob(payloadBase64));
return payload;
}
} catch (error) {
console.log('토큰에서 사용자 정보 추출 실패:', error);
}
return null;
}
// 메시지 표시
function showMessage(message, type = 'info') {
const container = document.getElementById('message-container');
container.innerHTML = `<div class="message ${type}">${message}</div>`;
if (type !== 'loading') {
setTimeout(() => {
container.innerHTML = '';
}, 5000);
}
}
// 날짜 포맷팅
function formatDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
// 월 표시 업데이트
function updateMonthDisplay() {
const monthElement = document.getElementById('currentMonth');
const year = currentDate.getFullYear();
const month = currentDate.getMonth() + 1;
monthElement.textContent = `${year}${month}`;
}
// 근무 유형 분류
function classifyWorkType(totalHours) {
if (totalHours === 0) return { type: 'vacation', label: '휴무' };
if (totalHours === 2) return { type: 'vacation', label: '조퇴' };
if (totalHours === 4) return { type: 'vacation', label: '반차' };
if (totalHours === 6) return { type: 'vacation', label: '반반차' };
if (totalHours === 8) return { type: 'normal-work', label: '정시근무' };
if (totalHours > 8) return { type: 'overtime', label: '잔업' };
return { type: 'vacation', label: '기타' };
}
// 캘린더 렌더링 (데이터 로드 없이)
function renderCalendar() {
const calendar = document.getElementById('calendar');
// 기존 날짜 셀들 제거 (헤더는 유지)
const dayHeaders = calendar.querySelectorAll('.day-header');
calendar.innerHTML = '';
dayHeaders.forEach(header => calendar.appendChild(header));
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
// 해당 월의 첫째 날과 마지막 날
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
// 첫째 주의 시작 (일요일부터 시작)
const startDate = new Date(firstDay);
startDate.setDate(startDate.getDate() - firstDay.getDay());
// 마지막 주의 끝
const endDate = new Date(lastDay);
endDate.setDate(endDate.getDate() + (6 - lastDay.getDay()));
// 오늘 날짜
const today = new Date();
const todayStr = formatDate(today);
// 날짜 셀 생성
let currentCalendarDate = new Date(startDate);
while (currentCalendarDate <= endDate) {
const dateStr = formatDate(currentCalendarDate);
const isCurrentMonth = currentCalendarDate.getMonth() === month;
const isToday = dateStr === todayStr;
const isSelected = selectedDate === dateStr;
const dayCell = document.createElement('div');
dayCell.className = 'day-cell';
if (!isCurrentMonth) {
dayCell.classList.add('other-month');
}
if (isToday) {
dayCell.classList.add('today');
}
if (isSelected) {
dayCell.classList.add('selected');
}
// 날짜 번호
const dayNumber = document.createElement('div');
dayNumber.className = 'day-number';
dayNumber.textContent = currentCalendarDate.getDate();
dayCell.appendChild(dayNumber);
// 클릭 이벤트 - 현재 월의 날짜만 클릭 가능
if (isCurrentMonth) {
dayCell.style.cursor = 'pointer';
dayCell.addEventListener('click', () => {
selectedDate = dateStr;
loadDayData(dateStr);
renderCalendar(); // 선택 상태 업데이트를 위해 재렌더링
});
}
calendar.appendChild(dayCell);
currentCalendarDate.setDate(currentCalendarDate.getDate() + 1);
}
}
// 특정 날짜 데이터 로드 (통합 API 사용)
async function loadDayData(dateStr) {
try {
showMessage(`${dateStr} 데이터를 불러오는 중... (통합 API)`, 'loading');
const data = await apiCall(`${API}/daily-work-reports?date=${dateStr}`);
const dataArray = Array.isArray(data) ? data : (data.data || []);
// 데이터 처리
processDayData(dateStr, dataArray);
renderDayInfo();
document.getElementById('message-container').innerHTML = '';
} catch (error) {
console.error('날짜 데이터 로드 실패:', error);
showMessage('데이터를 불러올 수 없습니다: ' + error.message, 'error');
selectedDateData = null;
renderDayInfo();
}
}
// 일별 데이터 처리
function processDayData(dateStr, works) {
const dayData = {
date: dateStr,
totalHours: 0,
workers: new Set(),
reviewed: Math.random() > 0.3, // 임시: 70% 확률로 검토 완료
details: works
};
works.forEach(work => {
dayData.totalHours += parseFloat(work.work_hours || 0);
dayData.workers.add(work.worker_name || work.user_id);
});
const workType = classifyWorkType(dayData.totalHours);
dayData.workType = workType.type;
dayData.workLabel = workType.label;
selectedDateData = dayData;
}
// 선택된 날짜 정보 렌더링
function renderDayInfo() {
const dayInfoContainer = document.getElementById('day-info-container');
if (!selectedDate) {
dayInfoContainer.innerHTML = `
<div class="day-info-placeholder">
<h3>📅 날짜를 선택하세요</h3>
<p>캘린더에서 날짜를 클릭하면 해당 날짜의 작업 정보를 확인할 수 있습니다.</p>
</div>
`;
return;
}
if (!selectedDateData) {
dayInfoContainer.innerHTML = `
<div class="day-info-placeholder">
<h3>📅 ${selectedDate}</h3>
<p>해당 날짜에 등록된 작업이 없습니다.</p>
</div>
`;
return;
}
const data = selectedDateData;
// 작업자별 상세 정보 생성
const workerDetailsHtml = Array.from(data.workers).map(worker => {
const workerWorks = data.details.filter(w => (w.worker_name || w.user_id) === worker);
const workerHours = workerWorks.reduce((sum, w) => sum + parseFloat(w.work_hours || 0), 0);
const workerWorkItemsHtml = workerWorks.map(work => `
<div class="work-item-detail">
<div class="work-item-info">
<strong>${work.project_name || '프로젝트'}</strong> - ${work.work_hours}시간<br>
<small>작업: ${work.work_type_name || '미지정'} | 상태: ${work.work_status_name || '미지정'}</small>
${work.error_type_name ? `<br><small style="color: #dc3545;">에러: ${work.error_type_name}</small>` : ''}
</div>
<div class="work-item-actions">
<button class="edit-work-btn" onclick="editWorkItem('${work.id}')">✏️ 수정</button>
<button class="delete-work-btn" onclick="deleteWorkItem('${work.id}')">🗑️ 삭제</button>
</div>
</div>
`).join('');
return `
<div class="worker-detail-section">
<div class="worker-header-detail">
<strong>👤 ${worker}</strong> - 총 ${workerHours}시간
<button class="delete-worker-btn" onclick="deleteWorkerAllWorks('${selectedDate}', '${worker}')">
🗑️ 전체삭제
</button>
</div>
<div class="worker-work-items">
${workerWorkItemsHtml}
</div>
</div>
`;
}).join('');
dayInfoContainer.innerHTML = `
<div class="day-info-content">
<div class="day-info-header">
<h3>📅 ${selectedDate} 작업 정보</h3>
<div class="day-info-actions">
<button class="review-toggle ${data.reviewed ? 'reviewed' : ''}" onclick="toggleReview()">
${data.reviewed ? '✅ 검토완료' : '⏳ 검토하기'}
</button>
<button class="refresh-day-btn" onclick="refreshCurrentDay()">
🔄 새로고침
</button>
</div>
</div>
<div class="day-summary">
<div class="summary-item">
<span class="summary-label">총 작업시간:</span>
<span class="summary-value">${data.totalHours}시간</span>
</div>
<div class="summary-item">
<span class="summary-label">근무 유형:</span>
<span class="summary-value ${data.workType}">${data.workLabel}</span>
</div>
<div class="summary-item">
<span class="summary-label">작업자 수:</span>
<span class="summary-value">${data.workers.size}명</span>
</div>
<div class="summary-item">
<span class="summary-label">검토 상태:</span>
<span class="summary-value ${data.reviewed ? 'reviewed' : 'unreviewed'}">
${data.reviewed ? '✅ 검토완료' : '⏳ 미검토'}
</span>
</div>
</div>
<div class="workers-detail-container">
<h4>👥 작업자별 상세</h4>
${workerDetailsHtml}
</div>
</div>
`;
}
// 검토 상태 토글
function toggleReview() {
if (selectedDateData) {
selectedDateData.reviewed = !selectedDateData.reviewed;
renderDayInfo();
// TODO: 실제로는 여기서 API 호출해서 DB에 저장해야 함
console.log(`검토 상태 변경: ${selectedDate} - ${selectedDateData.reviewed ? '검토완료' : '미검토'}`);
showMessage(`검토 상태가 ${selectedDateData.reviewed ? '완료' : '미완료'}로 변경되었습니다.`, 'success');
}
}
// 현재 날짜 새로고침
function refreshCurrentDay() {
if (selectedDate) {
loadDayData(selectedDate);
}
}
// 🛠️ 작업 항목 수정 함수 (통합 API 사용)
async function editWorkItem(workId) {
try {
console.log('수정할 작업 ID:', workId);
if (!selectedDateData) {
showMessage('작업 데이터를 찾을 수 없습니다.', 'error');
return;
}
const workData = selectedDateData.details.find(work => work.id == workId);
if (!workData) {
showMessage('수정할 작업 데이터를 찾을 수 없습니다.', 'error');
return;
}
// 기본 데이터가 없으면 로드
if (basicData.workTypes.length === 0) {
showMessage('기본 데이터를 불러오는 중... (통합 API)', 'loading');
await loadBasicData();
}
showEditModal(workData);
document.getElementById('message-container').innerHTML = '';
} catch (error) {
console.error('작업 정보 조회 오류:', error);
showMessage('작업 정보를 불러올 수 없습니다: ' + error.message, 'error');
}
}
// 🛠️ 수정 모달 표시 (개선된 버전)
function showEditModal(workData) {
const modalHtml = `
<div class="edit-modal" id="editModal">
<div class="edit-modal-content">
<div class="edit-modal-header">
<h3>✏️ 작업 수정</h3>
<button class="close-modal-btn" onclick="closeEditModal()">×</button>
</div>
<div class="edit-modal-body">
<div class="edit-form-group">
<label>🏗️ 프로젝트</label>
<select class="edit-select" id="editProject" required>
<option value="">프로젝트 선택</option>
${basicData.projects.map(p => `
<option value="${p.project_id}" ${p.project_id == workData.project_id ? 'selected' : ''}>
${p.project_name}
</option>
`).join('')}
</select>
</div>
<div class="edit-form-group">
<label>⚙️ 작업 유형</label>
<select class="edit-select" id="editWorkType" required>
<option value="">작업 유형 선택</option>
${basicData.workTypes.map(wt => `
<option value="${wt.id}" ${wt.id == workData.work_type_id ? 'selected' : ''}>
${wt.name}
</option>
`).join('')}
</select>
</div>
<div class="edit-form-group">
<label>📊 업무 상태</label>
<select class="edit-select" id="editWorkStatus" required>
<option value="">업무 상태 선택</option>
${basicData.workStatusTypes.map(ws => `
<option value="${ws.id}" ${ws.id == workData.work_status_id ? 'selected' : ''}>
${ws.name}
</option>
`).join('')}
</select>
</div>
<div class="edit-form-group" id="editErrorTypeGroup" style="${workData.work_status_id == 2 ? '' : 'display: none;'}">
<label>❌ 에러 유형</label>
<select class="edit-select" id="editErrorType">
<option value="">에러 유형 선택</option>
${basicData.errorTypes.map(et => `
<option value="${et.id}" ${et.id == workData.error_type_id ? 'selected' : ''}>
${et.name}
</option>
`).join('')}
</select>
</div>
<div class="edit-form-group">
<label>⏰ 작업 시간</label>
<input type="number" class="edit-input" id="editWorkHours"
value="${workData.work_hours}"
min="0" max="24" step="0.5" required>
</div>
</div>
<div class="edit-modal-footer">
<button class="btn btn-secondary" onclick="closeEditModal()">취소</button>
<button class="btn btn-success" onclick="saveEditedWork('${workData.id}')">💾 저장</button>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
// 업무 상태 변경 이벤트
document.getElementById('editWorkStatus').addEventListener('change', (e) => {
const errorTypeGroup = document.getElementById('editErrorTypeGroup');
if (e.target.value === '2') {
errorTypeGroup.style.display = 'block';
} else {
errorTypeGroup.style.display = 'none';
}
});
}
// 🛠️ 수정 모달 닫기
function closeEditModal() {
const modal = document.getElementById('editModal');
if (modal) {
modal.remove();
}
}
// 🛠️ 수정된 작업 저장 (통합 API 사용)
async function saveEditedWork(workId) {
try {
// 입력값 검증
const projectId = document.getElementById('editProject').value;
const workTypeId = document.getElementById('editWorkType').value;
const workStatusId = document.getElementById('editWorkStatus').value;
const errorTypeId = document.getElementById('editErrorType').value;
const workHours = document.getElementById('editWorkHours').value;
// 필수값 체크
if (!projectId) {
showMessage('프로젝트를 선택해주세요.', 'error');
document.getElementById('editProject').focus();
return;
}
if (!workTypeId) {
showMessage('작업 유형을 선택해주세요.', 'error');
document.getElementById('editWorkType').focus();
return;
}
if (!workStatusId) {
showMessage('업무 상태를 선택해주세요.', 'error');
document.getElementById('editWorkStatus').focus();
return;
}
if (!workHours || workHours <= 0) {
showMessage('작업 시간을 올바르게 입력해주세요.', 'error');
document.getElementById('editWorkHours').focus();
return;
}
if (workStatusId === '2' && !errorTypeId) {
showMessage('에러 상태인 경우 에러 유형을 선택해주세요.', 'error');
document.getElementById('editErrorType').focus();
return;
}
const updateData = {
project_id: parseInt(projectId),
work_type_id: parseInt(workTypeId),
work_status_id: parseInt(workStatusId),
error_type_id: errorTypeId ? parseInt(errorTypeId) : null,
work_hours: parseFloat(workHours)
};
showMessage('작업을 수정하는 중... (통합 API)', 'loading');
// 저장 버튼 비활성화
const saveBtn = document.querySelector('.btn-success');
const originalText = saveBtn.textContent;
saveBtn.textContent = '저장 중...';
saveBtn.disabled = true;
const result = await apiCall(`${API}/daily-work-reports/my-entry/${workId}`, {
method: 'PUT',
body: JSON.stringify(updateData)
});
showMessage('✅ 작업이 성공적으로 수정되었습니다!', 'success');
closeEditModal();
refreshCurrentDay(); // 현재 날짜 데이터 새로고침
} catch (error) {
console.error(' 수정 실패:', error);
showMessage('수정 중 오류가 발생했습니다: ' + error.message, 'error');
// 버튼 복원
const saveBtn = document.querySelector('.btn-success');
if (saveBtn) {
saveBtn.textContent = '💾 저장';
saveBtn.disabled = false;
}
}
}
// 🗑️ 작업 항목 삭제 (통합 API 사용)
async function deleteWorkItem(workId) {
// 확인 대화상자
const confirmDelete = await showConfirmDialog(
'작업 삭제 확인',
'정말로 이 작업을 삭제하시겠습니까?',
'삭제된 작업은 복구할 수 없습니다.'
);
if (!confirmDelete) return;
try {
console.log('삭제할 작업 ID:', workId);
showMessage('작업을 삭제하는 중... (통합 API)', 'loading');
const result = await apiCall(`${API}/daily-work-reports/my-entry/${workId}`, {
method: 'DELETE'
});
showMessage('✅ 작업이 성공적으로 삭제되었습니다!', 'success');
refreshCurrentDay(); // 현재 날짜 데이터 새로고침
} catch (error) {
console.error(' 삭제 실패:', error);
showMessage('삭제 중 오류가 발생했습니다: ' + error.message, 'error');
}
}
// 🗑️ 작업자의 모든 작업 삭제 (통합 API 사용)
async function deleteWorkerAllWorks(date, workerName) {
// 확인 대화상자
const confirmDelete = await showConfirmDialog(
'전체 작업 삭제 확인',
`정말로 ${workerName}님의 ${date} 모든 작업을 삭제하시겠습니까?`,
'삭제된 작업들은 복구할 수 없습니다.'
);
if (!confirmDelete) return;
try {
if (!selectedDateData) return;
const workerWorks = selectedDateData.details.filter(w => (w.worker_name || w.user_id) === workerName);
if (workerWorks.length === 0) {
showMessage('삭제할 작업이 없습니다.', 'error');
return;
}
showMessage(`${workerName}님의 작업들을 삭제하는 중... (통합 API)`, 'loading');
// 순차적으로 삭제 (병렬 처리하면 서버 부하 발생 가능)
let successCount = 0;
let failCount = 0;
for (const work of workerWorks) {
try {
await apiCall(`${API}/daily-work-reports/my-entry/${work.id}`, {
method: 'DELETE'
});
successCount++;
} catch (error) {
console.error(`작업 ${work.id} 삭제 실패:`, error);
failCount++;
}
}
if (failCount === 0) {
showMessage(`${workerName}님의 모든 작업(${successCount}개)이 삭제되었습니다!`, 'success');
} else {
showMessage(`⚠️ ${successCount}개 삭제 완료, ${failCount}개 삭제 실패`, 'warning');
}
refreshCurrentDay(); // 현재 날짜 데이터 새로고침
} catch (error) {
console.error(' 전체 삭제 실패:', error);
showMessage('작업 삭제 중 오류가 발생했습니다: ' + error.message, 'error');
}
}
// 확인 대화상자 표시
function showConfirmDialog(title, message, warning) {
return new Promise((resolve) => {
const modalHtml = `
<div class="confirm-modal" id="confirmModal">
<div class="confirm-modal-content">
<div class="confirm-modal-header">
<h3>⚠️ ${title}</h3>
</div>
<div class="confirm-modal-body">
<p><strong>${message}</strong></p>
<p style="color: #dc3545; font-size: 0.9rem;">${warning}</p>
</div>
<div class="confirm-modal-footer">
<button class="btn btn-secondary" onclick="resolveConfirm(false)">취소</button>
<button class="btn btn-danger" onclick="resolveConfirm(true)">삭제</button>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
// 전역 함수로 resolve 함수 노출
window.resolveConfirm = (result) => {
const modal = document.getElementById('confirmModal');
if (modal) modal.remove();
delete window.resolveConfirm;
resolve(result);
};
});
}
// 기본 데이터 로드 (통합 API 사용)
async function loadBasicData() {
try {
const promises = [
// 활성 프로젝트 로드
apiCall(`${API}/projects/active/list`)
.then(data => Array.isArray(data) ? data : (data.projects || []))
.catch(() => []),
// 작업 유형 로드
apiCall(`${API}/daily-work-reports/work-types`)
.then(data => Array.isArray(data) ? data : [
{id: 1, name: 'Base'},
{id: 2, name: 'Vessel'},
{id: 3, name: 'Piping'}
])
.catch(() => [
{id: 1, name: 'Base'},
{id: 2, name: 'Vessel'},
{id: 3, name: 'Piping'}
]),
// 업무 상태 유형 로드
apiCall(`${API}/daily-work-reports/work-status-types`)
.then(data => Array.isArray(data) ? data : [
{id: 1, name: '정규'},
{id: 2, name: '에러'}
])
.catch(() => [
{id: 1, name: '정규'},
{id: 2, name: '에러'}
]),
// 에러 유형 로드
apiCall(`${API}/daily-work-reports/error-types`)
.then(data => Array.isArray(data) ? data : [
{id: 1, name: '설계미스'},
{id: 2, name: '외주작업 불량'},
{id: 3, name: '입고지연'},
{id: 4, name: '작업 불량'}
])
.catch(() => [
{id: 1, name: '설계미스'},
{id: 2, name: '외주작업 불량'},
{id: 3, name: '입고지연'},
{id: 4, name: '작업 불량'}
])
];
const [projects, workTypes, workStatusTypes, errorTypes] = await Promise.all(promises);
basicData = {
projects,
workTypes,
workStatusTypes,
errorTypes
};
} catch (error) {
console.error('기본 데이터 로드 실패:', error);
}
}
// 이벤트 리스너 설정
function setupEventListeners() {
document.getElementById('prevMonth').addEventListener('click', () => {
currentDate.setMonth(currentDate.getMonth() - 1);
updateMonthDisplay();
selectedDate = null;
selectedDateData = null;
renderCalendar();
renderDayInfo();
});
document.getElementById('nextMonth').addEventListener('click', () => {
currentDate.setMonth(currentDate.getMonth() + 1);
updateMonthDisplay();
selectedDate = null;
selectedDateData = null;
renderCalendar();
renderDayInfo();
});
// 오늘 날짜로 이동 버튼 추가
document.getElementById('goToday')?.addEventListener('click', () => {
const today = new Date();
currentDate = new Date(today);
updateMonthDisplay();
renderCalendar();
// 오늘 날짜 자동 선택
const todayStr = formatDate(today);
selectedDate = todayStr;
loadDayData(todayStr);
});
}
// 전역 함수로 노출
window.toggleReview = toggleReview;
window.refreshCurrentDay = refreshCurrentDay;
window.editWorkItem = editWorkItem;
window.deleteWorkItem = deleteWorkItem;
window.deleteWorkerAllWorks = deleteWorkerAllWorks;
window.closeEditModal = closeEditModal;
window.saveEditedWork = saveEditedWork;
// 초기화
async function init() {
try {
const token = localStorage.getItem('sso_token');
if (!token || token === 'undefined') {
showMessage('로그인이 필요합니다.', 'error');
setTimeout(() => {
window.location.href = '/';
}, 2000);
return;
}
updateMonthDisplay();
setupEventListeners();
renderCalendar();
renderDayInfo();
// 기본 데이터 미리 로드
await loadBasicData();
} catch (error) {
console.error('초기화 오류:', error);
showMessage('초기화 중 오류가 발생했습니다.', 'error');
}
}
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', init);

View File

@@ -1,506 +0,0 @@
// worker-individual-report.js - 작업자별 개별 보고서 관리
// 전역 변수
let currentWorkerId = null;
let currentWorkerName = '';
let selectedDate = '';
let currentUser = null;
let workTypes = [];
let workStatusTypes = [];
let errorTypes = [];
let projects = [];
let existingWork = [];
// URL 파라미터에서 정보 추출
function getUrlParams() {
const urlParams = new URLSearchParams(window.location.search);
return {
user_id: urlParams.get('user_id'),
worker_name: decodeURIComponent(urlParams.get('worker_name') || ''),
date: urlParams.get('date') || new Date().toISOString().split('T')[0]
};
}
// 현재 로그인한 사용자 정보 가져오기
function getCurrentUser() {
try {
const token = localStorage.getItem('sso_token');
if (!token) return null;
const payloadBase64 = token.split('.')[1];
if (payloadBase64) {
const payload = JSON.parse(atob(payloadBase64));
return payload;
}
} catch (error) {
console.log('토큰에서 사용자 정보 추출 실패:', error);
}
try {
const userInfo = localStorage.getItem('sso_user');
if (userInfo) {
return JSON.parse(userInfo);
}
} catch (error) {
console.log('localStorage에서 사용자 정보 파싱 실패:', error);
}
return null;
}
// 메시지 표시 함수
function showMessage(msg, type = 'info') {
const container = document.getElementById('message-container');
if (container) {
container.innerHTML = `<div class="message ${type}">${msg}</div>`;
setTimeout(() => {
container.innerHTML = '';
}, 5000);
}
}
// 페이지 초기화
document.addEventListener('DOMContentLoaded', async () => {
// API 함수가 로드될 때까지 기다림
let retryCount = 0;
const maxRetries = 50;
while (!window.apiCall && retryCount < maxRetries) {
await new Promise(resolve => setTimeout(resolve, 100));
retryCount++;
}
if (!window.apiCall) {
console.error(' API 함수를 로드할 수 없습니다.');
showMessage('시스템을 초기화할 수 없습니다. 페이지를 새로고침해주세요.', 'error');
return;
}
try {
await initializePage();
} catch (error) {
console.error('페이지 초기화 오류:', error);
showMessage('페이지를 불러오는 중 오류가 발생했습니다.', 'error');
}
});
async function initializePage() {
// URL 파라미터 추출
const params = getUrlParams();
currentWorkerId = parseInt(params.user_id);
currentWorkerName = params.worker_name;
selectedDate = params.date;
// 사용자 정보 설정
currentUser = getCurrentUser();
if (!currentWorkerId || !currentWorkerName) {
showMessage('잘못된 접근입니다. 작업자 정보가 없습니다.', 'error');
setTimeout(() => {
window.history.back();
}, 2000);
return;
}
// 페이지 제목 설정
updatePageHeader();
// 이벤트 리스너 설정
setupEventListeners();
// 초기 데이터 로드
await loadInitialData();
}
function updatePageHeader() {
document.getElementById('pageTitle').textContent = `👤 ${currentWorkerName} 작업 보고서`;
document.getElementById('pageSubtitle').textContent = `${selectedDate} 작업 내용을 관리합니다.`;
// 작업자 정보 카드 업데이트
document.getElementById('workerInitial').textContent = currentWorkerName.charAt(0);
document.getElementById('workerName').textContent = currentWorkerName;
document.getElementById('selectedDate').textContent = selectedDate;
}
function setupEventListeners() {
// 새 작업 추가 버튼
document.getElementById('addNewWorkBtn').addEventListener('click', showNewWorkForm);
document.getElementById('cancelNewWorkBtn').addEventListener('click', hideNewWorkForm);
document.getElementById('saveNewWorkBtn').addEventListener('click', saveNewWork);
// 업무 상태 변경 시 에러 유형 섹션 토글
document.getElementById('newWorkStatusSelect').addEventListener('change', toggleErrorTypeSection);
// 빠른 시간 버튼
document.querySelectorAll('.quick-time-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
document.getElementById('newWorkHours').value = e.target.dataset.hours;
});
});
// 휴가 처리 버튼들
document.querySelectorAll('.vacation-process-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const vacationType = e.target.dataset.type;
handleVacationProcess(vacationType);
});
});
}
async function loadInitialData() {
try {
showMessage('데이터를 불러오는 중...', 'loading');
// 병렬로 데이터 로드
await Promise.all([
loadWorkerInfo(),
loadExistingWork(),
loadProjects(),
loadWorkTypes(),
loadWorkStatusTypes(),
loadErrorTypes()
]);
// UI 업데이트
updateWorkerSummary();
renderExistingWork();
populateDropdowns();
showMessage('데이터 로드 완료', 'success');
} catch (error) {
console.error('초기 데이터 로드 실패:', error);
showMessage('데이터 로드 중 오류가 발생했습니다: ' + error.message, 'error');
}
}
async function loadWorkerInfo() {
try {
const response = await window.apiCall(`/workers/${currentWorkerId}`);
const worker = response.data || response;
document.getElementById('workerJob').textContent = worker.job_type || '작업자';
} catch (error) {
console.error('작업자 정보 로드 오류:', error);
}
}
async function loadExistingWork() {
try {
const response = await window.apiCall(`/daily-work-reports?date=${selectedDate}&user_id=${currentWorkerId}`);
existingWork = Array.isArray(response) ? response : (response.data || []);
} catch (error) {
console.error('기존 작업 로드 오류:', error);
existingWork = [];
}
}
async function loadProjects() {
try {
const response = await window.apiCall(`/projects/active/list`);
projects = Array.isArray(response) ? response : (response.data || []);
} catch (error) {
console.error('프로젝트 로드 오류:', error);
projects = [];
}
}
async function loadWorkTypes() {
try {
const response = await window.apiCall(`/daily-work-reports/work-types`);
workTypes = Array.isArray(response) ? response : (response.data || []);
} catch (error) {
console.error('작업 유형 로드 오류:', error);
workTypes = [];
}
}
async function loadWorkStatusTypes() {
try {
const response = await window.apiCall(`/daily-work-reports/work-status-types`);
workStatusTypes = Array.isArray(response) ? response : (response.data || []);
} catch (error) {
console.error('작업 상태 유형 로드 오류:', error);
workStatusTypes = [];
}
}
async function loadErrorTypes() {
try {
const response = await window.apiCall(`/daily-work-reports/error-types`);
errorTypes = Array.isArray(response) ? response : (response.data || []);
} catch (error) {
console.error('에러 유형 로드 오류:', error);
errorTypes = [];
}
}
function updateWorkerSummary() {
const totalHours = existingWork.reduce((sum, work) => sum + parseFloat(work.work_hours || 0), 0);
const workCount = existingWork.length;
document.getElementById('totalHours').textContent = `${totalHours.toFixed(1)}h`;
document.getElementById('workCount').textContent = `${workCount}`;
// 12시간 초과 경고
if (totalHours > 12) {
document.getElementById('totalHours').classList.add('warning');
showMessage(`⚠️ 총 작업시간이 ${totalHours.toFixed(1)}시간으로 12시간을 초과했습니다.`, 'warning');
}
}
function renderExistingWork() {
const container = document.getElementById('existingWorkList');
if (existingWork.length === 0) {
container.innerHTML = `
<div class="empty-state">
<div class="empty-icon">📭</div>
<h3>등록된 작업이 없습니다</h3>
<p>${selectedDate}${currentWorkerName}님의 작업이 등록되지 않았습니다.</p>
</div>
`;
return;
}
container.innerHTML = existingWork.map(work => `
<div class="existing-work-item" data-work-id="${work.id}">
<div class="work-item-header">
<div class="work-item-info">
<h4>${work.project_name || '미지정 프로젝트'}</h4>
<p>${work.work_type_name || '미지정 작업'}</p>
</div>
<div class="work-item-status">
<span class="status-badge ${work.work_status_id === 2 ? 'error' : 'normal'}">
${work.work_status_name || '정상'}
</span>
<span class="work-hours">${work.work_hours}h</span>
</div>
</div>
${work.work_status_id === 2 && work.error_type_name ? `
<div class="work-item-error">
<span class="error-label">오류:</span>
<span class="error-type">${work.error_type_name}</span>
</div>
` : ''}
<div class="work-item-actions">
<button class="btn btn-sm btn-primary" onclick="editWork(${work.id})">
✏️ 수정
</button>
<button class="btn btn-sm btn-danger" onclick="deleteWork(${work.id})">
🗑️ 삭제
</button>
</div>
</div>
`).join('');
}
function populateDropdowns() {
// 프로젝트 드롭다운
const projectSelect = document.getElementById('newProjectSelect');
projectSelect.innerHTML = '<option value="">프로젝트를 선택하세요</option>';
projects.forEach(project => {
const option = document.createElement('option');
option.value = project.project_id;
option.textContent = project.project_name;
projectSelect.appendChild(option);
});
// 작업 유형 드롭다운
const workTypeSelect = document.getElementById('newWorkTypeSelect');
workTypeSelect.innerHTML = '<option value="">작업 유형을 선택하세요</option>';
workTypes.forEach(type => {
const option = document.createElement('option');
option.value = type.id;
option.textContent = type.name;
workTypeSelect.appendChild(option);
});
// 작업 상태 드롭다운
const workStatusSelect = document.getElementById('newWorkStatusSelect');
workStatusSelect.innerHTML = '<option value="">업무 상태를 선택하세요</option>';
workStatusTypes.forEach(status => {
const option = document.createElement('option');
option.value = status.id;
option.textContent = status.name;
workStatusSelect.appendChild(option);
});
// 에러 유형 드롭다운
const errorTypeSelect = document.getElementById('newErrorTypeSelect');
errorTypeSelect.innerHTML = '<option value="">에러 유형을 선택하세요</option>';
errorTypes.forEach(error => {
const option = document.createElement('option');
option.value = error.id;
option.textContent = error.name;
errorTypeSelect.appendChild(option);
});
}
function showNewWorkForm() {
document.getElementById('newWorkSection').style.display = 'block';
document.getElementById('addNewWorkBtn').style.display = 'none';
}
function hideNewWorkForm() {
document.getElementById('newWorkSection').style.display = 'none';
document.getElementById('addNewWorkBtn').style.display = 'block';
resetNewWorkForm();
}
function resetNewWorkForm() {
document.getElementById('newProjectSelect').value = '';
document.getElementById('newWorkTypeSelect').value = '';
document.getElementById('newWorkStatusSelect').value = '';
document.getElementById('newErrorTypeSelect').value = '';
document.getElementById('newWorkHours').value = '1.00';
document.getElementById('newErrorTypeSection').classList.remove('visible');
}
function toggleErrorTypeSection() {
const workStatusSelect = document.getElementById('newWorkStatusSelect');
const errorSection = document.getElementById('newErrorTypeSection');
const errorTypeSelect = document.getElementById('newErrorTypeSelect');
if (workStatusSelect.value === '2') { // 에러 상태
errorSection.classList.add('visible');
errorTypeSelect.setAttribute('required', 'true');
} else {
errorSection.classList.remove('visible');
errorTypeSelect.removeAttribute('required');
errorTypeSelect.value = '';
}
}
async function saveNewWork() {
try {
const projectId = document.getElementById('newProjectSelect').value;
const workTypeId = document.getElementById('newWorkTypeSelect').value;
const workStatusId = document.getElementById('newWorkStatusSelect').value;
const errorTypeId = document.getElementById('newErrorTypeSelect').value;
const workHours = document.getElementById('newWorkHours').value;
// 유효성 검사
if (!projectId || !workTypeId || !workStatusId || !workHours) {
showMessage('모든 필수 필드를 입력해주세요.', 'error');
return;
}
if (workStatusId === '2' && !errorTypeId) {
showMessage('에러 상태일 때는 에러 유형을 선택해야 합니다.', 'error');
return;
}
showMessage('작업을 저장하는 중...', 'loading');
const workData = {
report_date: selectedDate,
user_id: currentWorkerId,
project_id: parseInt(projectId),
work_type_id: parseInt(workTypeId),
work_status_id: parseInt(workStatusId),
error_type_id: workStatusId === '2' ? parseInt(errorTypeId) : null,
work_hours: parseFloat(workHours),
created_by: currentUser?.user_id || 1
};
const response = await window.apiCall(`/daily-work-reports`, 'POST', workData);
showMessage('작업이 성공적으로 저장되었습니다.', 'success');
// 데이터 새로고침
await loadExistingWork();
updateWorkerSummary();
renderExistingWork();
hideNewWorkForm();
} catch (error) {
console.error('작업 저장 오류:', error);
showMessage(`작업 저장 중 오류가 발생했습니다: ${error.message}`, 'error');
}
}
async function editWork(workId) {
// TODO: 작업 수정 모달 또는 인라인 편집 구현
console.log(`작업 ${workId} 수정`);
showMessage('작업 수정 기능은 곧 구현될 예정입니다.', 'info');
}
async function deleteWork(workId) {
if (!confirm('이 작업을 삭제하시겠습니까?')) {
return;
}
try {
showMessage('작업을 삭제하는 중...', 'loading');
await window.apiCall(`/daily-work-reports/${workId}`, {
method: 'DELETE'
});
showMessage('작업이 성공적으로 삭제되었습니다.', 'success');
// 데이터 새로고침
await loadExistingWork();
updateWorkerSummary();
renderExistingWork();
} catch (error) {
console.error('작업 삭제 오류:', error);
showMessage(`작업 삭제 중 오류가 발생했습니다: ${error.message}`, 'error');
}
}
async function handleVacationProcess(vacationType) {
const vacationNames = {
'full': '연차',
'half-half': '반반차',
'half': '반차'
};
const vacationHours = {
'full': 8,
'half-half': 6,
'half': 4
};
if (!confirm(`${vacationNames[vacationType]} 처리하시겠습니까?\n(${vacationHours[vacationType]}시간으로 자동 입력됩니다)`)) {
return;
}
try {
showMessage(`${vacationNames[vacationType]} 처리 중...`, 'loading');
// 휴가용 작업 보고서 생성
const vacationWork = {
report_date: selectedDate,
user_id: currentWorkerId,
project_id: 1, // 기본 프로젝트 (휴가용)
work_type_id: 999, // 휴가 전용 작업 유형 (DB에 추가 필요)
work_status_id: 1, // 정상 상태
error_type_id: null,
work_hours: vacationHours[vacationType],
created_by: currentUser?.user_id || 1
};
const response = await window.apiCall(`/daily-work-reports`, {
method: 'POST',
body: JSON.stringify(vacationWork)
});
showMessage(`${vacationNames[vacationType]} 처리가 완료되었습니다.`, 'success');
// 데이터 새로고침
await loadExistingWork();
updateWorkerSummary();
renderExistingWork();
} catch (error) {
console.error('휴가 처리 오류:', error);
showMessage(`휴가 처리 중 오류가 발생했습니다: ${error.message}`, 'error');
}
}
// 전역 함수로 등록
window.editWork = editWork;
window.deleteWork = deleteWork;

View File

@@ -1,546 +0,0 @@
// 작업장 레이아웃 지도 관리
// 전역 변수
let layoutMapImage = null;
let mapRegions = [];
let canvas = null;
let ctx = null;
let isDrawing = false;
let startX = 0;
let startY = 0;
let currentRect = null;
// ==================== 레이아웃 지도 모달 ====================
/**
* 레이아웃 지도 모달 열기
*/
async function openLayoutMapModal() {
// window 객체에서 currentCategoryId 가져오기
const currentCategoryId = window.currentCategoryId;
if (!currentCategoryId) {
window.window.showToast('공장을 먼저 선택해주세요.', 'warning');
return;
}
const modal = document.getElementById('layoutMapModal');
if (!modal) return;
// 캔버스 초기화
canvas = document.getElementById('regionCanvas');
ctx = canvas.getContext('2d');
// 현재 카테고리의 레이아웃 이미지 및 영역 로드
await loadLayoutMapData();
// 작업장 선택 옵션 업데이트
updateWorkplaceSelect();
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
}
/**
* 레이아웃 지도 모달 닫기
*/
function closeLayoutMapModal() {
const modal = document.getElementById('layoutMapModal');
if (modal) {
modal.style.display = 'none';
document.body.style.overflow = '';
}
// 캔버스 이벤트 리스너 제거
if (canvas) {
canvas.removeEventListener('mousedown', startDrawing);
canvas.removeEventListener('mousemove', draw);
canvas.removeEventListener('mouseup', stopDrawing);
}
// 메인 페이지의 레이아웃 미리보기 업데이트
const currentCategoryId = window.currentCategoryId;
const categories = window.categories;
if (currentCategoryId && categories) {
const category = categories.find(c => c.category_id == currentCategoryId);
if (category && window.updateLayoutPreview) {
window.updateLayoutPreview(category);
}
}
}
/**
* 레이아웃 지도 데이터 로드
*/
async function loadLayoutMapData() {
try {
const currentCategoryId = window.currentCategoryId;
const categories = window.categories;
// 현재 카테고리 정보 가져오기
const category = categories.find(c => c.category_id == currentCategoryId);
if (!category) return;
// 레이아웃 이미지 표시
const currentImageDiv = document.getElementById('currentLayoutImage');
if (category.layout_image) {
// 이미지 경로를 전체 URL로 변환
const fullImageUrl = category.layout_image.startsWith('http')
? category.layout_image
: `${window.API_BASE_URL || 'http://localhost:30005/api'}${category.layout_image}`.replace('/api/', '/');
currentImageDiv.innerHTML = `
<img src="${fullImageUrl}" style="max-width: 100%; max-height: 300px; border-radius: 4px;" alt="현재 레이아웃 이미지">
`;
// 캔버스에도 이미지 로드
loadImageToCanvas(fullImageUrl);
} else {
currentImageDiv.innerHTML = '<span style="color: #94a3b8;">업로드된 이미지가 없습니다</span>';
}
// 영역 데이터 로드
const regionsResponse = await window.apiCall(`/workplaces/categories/${currentCategoryId}/map-regions`, 'GET');
if (regionsResponse && regionsResponse.success) {
mapRegions = regionsResponse.data || [];
} else {
mapRegions = [];
}
renderRegionList();
} catch (error) {
console.error('레이아웃 지도 데이터 로딩 오류:', error);
window.window.showToast('레이아웃 지도 데이터를 불러오는데 실패했습니다.', 'error');
}
}
/**
* 이미지를 캔버스에 로드
*/
function loadImageToCanvas(imagePath) {
const img = new Image();
img.onload = function() {
// 캔버스 크기를 이미지 크기에 맞춤 (최대 800px)
const maxWidth = 800;
const scale = img.width > maxWidth ? maxWidth / img.width : 1;
canvas.width = img.width * scale;
canvas.height = img.height * scale;
// 이미지 그리기
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
layoutMapImage = img;
// 기존 영역들 그리기
drawExistingRegions();
// 캔버스 이벤트 리스너 등록
setupCanvasEvents();
};
img.src = imagePath;
}
/**
* 작업장 선택 옵션 업데이트
*/
function updateWorkplaceSelect() {
const select = document.getElementById('regionWorkplaceSelect');
if (!select) return;
const currentCategoryId = window.currentCategoryId;
const workplaces = window.workplaces;
// 현재 카테고리의 작업장만 필터링
const categoryWorkplaces = workplaces.filter(w => w.category_id == currentCategoryId);
let options = '<option value="">작업장을 선택하세요</option>';
categoryWorkplaces.forEach(wp => {
// 이미 영역이 정의된 작업장은 표시
const hasRegion = mapRegions.some(r => r.workplace_id === wp.workplace_id);
options += `<option value="${wp.workplace_id}">${wp.workplace_name}${hasRegion ? ' (영역 정의됨)' : ''}</option>`;
});
select.innerHTML = options;
}
// ==================== 이미지 업로드 ====================
/**
* 이미지 미리보기
*/
function previewLayoutImage(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(e) {
const currentImageDiv = document.getElementById('currentLayoutImage');
currentImageDiv.innerHTML = `
<img src="${e.target.result}" style="max-width: 100%; max-height: 300px; border-radius: 4px;" alt="미리보기">
<p style="color: #64748b; font-size: 14px; margin-top: 8px;">미리보기 (저장하려면 "이미지 업로드" 버튼을 클릭하세요)</p>
`;
};
reader.readAsDataURL(file);
}
/**
* 레이아웃 이미지 업로드
*/
async function uploadLayoutImage() {
const fileInput = document.getElementById('layoutImageFile');
const file = fileInput.files[0];
if (!file) {
window.showToast('이미지 파일을 선택해주세요.', 'warning');
return;
}
const currentCategoryId = window.currentCategoryId;
if (!currentCategoryId) {
window.showToast('공장을 먼저 선택해주세요.', 'error');
return;
}
try {
// FormData 생성
const formData = new FormData();
formData.append('image', file);
// 업로드 요청
const response = await fetch(`${window.API_BASE_URL || 'http://localhost:30005/api'}/workplaces/categories/${currentCategoryId}/layout-image`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('sso_token')}`
},
body: formData
});
const result = await response.json();
if (result.success) {
window.showToast('이미지가 성공적으로 업로드되었습니다.', 'success');
// 이미지 경로를 전체 URL로 변환
const fullImageUrl = `${window.API_BASE_URL || 'http://localhost:30005/api'}${result.data.image_path}`.replace('/api/', '/');
// 이미지를 캔버스에 로드
loadImageToCanvas(fullImageUrl);
// 현재 이미지 미리보기도 업데이트
const currentImageDiv = document.getElementById('currentLayoutImage');
if (currentImageDiv) {
currentImageDiv.innerHTML = `
<img src="${fullImageUrl}" style="max-width: 100%; max-height: 300px; border-radius: 4px;" alt="현재 레이아웃 이미지">
`;
}
// 카테고리 데이터 새로고침 (workplace-management.js의 loadCategories 함수 호출)
if (window.loadCategories) {
await window.loadCategories();
// 메인 페이지 미리보기도 업데이트
const currentCategoryId = window.currentCategoryId;
const categories = window.categories;
if (currentCategoryId && categories && window.updateLayoutPreview) {
const category = categories.find(c => c.category_id == currentCategoryId);
if (category) {
window.updateLayoutPreview(category);
}
}
}
} else {
throw new Error(result.message || '업로드 실패');
}
} catch (error) {
console.error('이미지 업로드 오류:', error);
window.showToast(error.message || '이미지 업로드 중 오류가 발생했습니다.', 'error');
}
}
// ==================== 영역 그리기 ====================
/**
* 캔버스 이벤트 설정
*/
function setupCanvasEvents() {
canvas.addEventListener('mousedown', startDrawing);
canvas.addEventListener('mousemove', draw);
canvas.addEventListener('mouseup', stopDrawing);
canvas.addEventListener('mouseleave', stopDrawing);
}
/**
* 그리기 시작
*/
function startDrawing(e) {
const rect = canvas.getBoundingClientRect();
startX = e.clientX - rect.left;
startY = e.clientY - rect.top;
isDrawing = true;
}
/**
* 그리기
*/
function draw(e) {
if (!isDrawing) return;
const rect = canvas.getBoundingClientRect();
const currentX = e.clientX - rect.left;
const currentY = e.clientY - rect.top;
// 캔버스 다시 그리기
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (layoutMapImage) {
ctx.drawImage(layoutMapImage, 0, 0, canvas.width, canvas.height);
}
// 기존 영역들 그리기
drawExistingRegions();
// 현재 그리는 사각형
const width = currentX - startX;
const height = currentY - startY;
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = 3;
ctx.strokeRect(startX, startY, width, height);
ctx.fillStyle = 'rgba(59, 130, 246, 0.2)';
ctx.fillRect(startX, startY, width, height);
currentRect = { startX, startY, endX: currentX, endY: currentY };
}
/**
* 그리기 종료
*/
function stopDrawing() {
isDrawing = false;
}
/**
* 기존 영역들 그리기
*/
function drawExistingRegions() {
mapRegions.forEach(region => {
const x1 = (region.x_start / 100) * canvas.width;
const y1 = (region.y_start / 100) * canvas.height;
const x2 = (region.x_end / 100) * canvas.width;
const y2 = (region.y_end / 100) * canvas.height;
ctx.strokeStyle = '#10b981';
ctx.lineWidth = 2;
ctx.strokeRect(x1, y1, x2 - x1, y2 - y1);
ctx.fillStyle = 'rgba(16, 185, 129, 0.15)';
ctx.fillRect(x1, y1, x2 - x1, y2 - y1);
// 작업장 이름 표시
ctx.fillStyle = '#10b981';
ctx.font = '14px sans-serif';
ctx.fillText(region.workplace_name || '', x1 + 5, y1 + 20);
});
}
/**
* 현재 영역 지우기
*/
function clearCurrentRegion() {
currentRect = null;
// 캔버스 다시 그리기
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (layoutMapImage) {
ctx.drawImage(layoutMapImage, 0, 0, canvas.width, canvas.height);
}
drawExistingRegions();
}
/**
* 영역 저장
*/
async function saveRegion() {
const workplaceId = document.getElementById('regionWorkplaceSelect').value;
if (!workplaceId) {
window.showToast('작업장을 선택해주세요.', 'warning');
return;
}
if (!currentRect) {
window.showToast('영역을 그려주세요.', 'warning');
return;
}
const currentCategoryId = window.currentCategoryId;
try {
// 비율로 변환 (0~100%)
const xStart = Math.min(currentRect.startX, currentRect.endX) / canvas.width * 100;
const yStart = Math.min(currentRect.startY, currentRect.endY) / canvas.height * 100;
const xEnd = Math.max(currentRect.startX, currentRect.endX) / canvas.width * 100;
const yEnd = Math.max(currentRect.startY, currentRect.endY) / canvas.height * 100;
// 기존 영역이 있는지 확인
const existingRegion = mapRegions.find(r => r.workplace_id == workplaceId);
const regionData = {
workplace_id: parseInt(workplaceId),
category_id: parseInt(currentCategoryId),
x_start: xStart.toFixed(2),
y_start: yStart.toFixed(2),
x_end: xEnd.toFixed(2),
y_end: yEnd.toFixed(2),
shape: 'rect'
};
let response;
if (existingRegion) {
// 수정
response = await window.apiCall(`/workplaces/map-regions/${existingRegion.region_id}`, 'PUT', regionData);
} else {
// 신규 등록
response = await window.apiCall('/workplaces/map-regions', 'POST', regionData);
}
if (response && response.success) {
window.showToast('영역이 성공적으로 저장되었습니다.', 'success');
// 데이터 새로고침
await loadLayoutMapData();
// 현재 그림 초기화
clearCurrentRegion();
// 작업장 선택 초기화
document.getElementById('regionWorkplaceSelect').value = '';
} else {
throw new Error(response?.message || '저장 실패');
}
} catch (error) {
console.error('영역 저장 오류:', error);
window.showToast(error.message || '영역 저장 중 오류가 발생했습니다.', 'error');
}
}
/**
* 영역 목록 렌더링
*/
function renderRegionList() {
const listDiv = document.getElementById('regionList');
if (!listDiv) return;
if (mapRegions.length === 0) {
listDiv.innerHTML = '<p style="color: #94a3b8; text-align: center; padding: 20px;">정의된 영역이 없습니다</p>';
return;
}
let listHtml = '<div style="display: flex; flex-direction: column; gap: 8px;">';
mapRegions.forEach(region => {
listHtml += `
<div style="display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; background: white; border: 1px solid #e5e7eb; border-radius: 6px;">
<div>
<span style="font-weight: 600; color: #1e293b;">${region.workplace_name}</span>
<span style="color: #94a3b8; font-size: 12px; margin-left: 8px;">
(${parseFloat(region.x_start).toFixed(1)}%, ${parseFloat(region.y_start).toFixed(1)}%) ~ (${parseFloat(region.x_end).toFixed(1)}%, ${parseFloat(region.y_end).toFixed(1)}%)
</span>
</div>
<div style="display: flex; gap: 4px;">
<button onclick="editRegion(${region.workplace_id})" class="btn-small" style="padding: 4px 8px; font-size: 12px; background: #3b82f6; color: white; border: none; border-radius: 4px; cursor: pointer;">
✏️ 수정
</button>
<button onclick="deleteRegion(${region.region_id})" class="btn-small btn-delete" style="padding: 4px 8px; font-size: 12px; border: none; border-radius: 4px; cursor: pointer;">
🗑️ 삭제
</button>
</div>
</div>
`;
});
listHtml += '</div>';
listDiv.innerHTML = listHtml;
}
/**
* 영역 수정 모드 진입
*/
function editRegion(workplaceId) {
// 작업장 드롭다운 선택
const select = document.getElementById('regionWorkplaceSelect');
if (select) {
select.value = workplaceId;
}
// 해당 영역 하이라이트
const region = mapRegions.find(r => r.workplace_id == workplaceId);
if (region && layoutMapImage) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(layoutMapImage, 0, 0, canvas.width, canvas.height);
drawExistingRegions();
// 수정 대상 영역을 주황색으로 강조
const x1 = (region.x_start / 100) * canvas.width;
const y1 = (region.y_start / 100) * canvas.height;
const x2 = (region.x_end / 100) * canvas.width;
const y2 = (region.y_end / 100) * canvas.height;
ctx.strokeStyle = '#f59e0b';
ctx.lineWidth = 3;
ctx.setLineDash([6, 4]);
ctx.strokeRect(x1, y1, x2 - x1, y2 - y1);
ctx.setLineDash([]);
ctx.fillStyle = 'rgba(245, 158, 11, 0.25)';
ctx.fillRect(x1, y1, x2 - x1, y2 - y1);
// 라벨
ctx.fillStyle = '#f59e0b';
ctx.font = 'bold 14px sans-serif';
ctx.fillText('✏️ ' + (region.workplace_name || ''), x1 + 5, y1 + 20);
}
window.showToast(`"${region?.workplace_name || '작업장'}" 위치를 수정합니다. 지도에서 새 위치를 드래그한 후 저장하세요.`, 'info');
// 캔버스 영역으로 스크롤
if (canvas) {
canvas.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
/**
* 영역 삭제
*/
async function deleteRegion(regionId) {
if (!confirm('이 영역을 삭제하시겠습니까?')) {
return;
}
try {
const response = await window.apiCall(`/workplaces/map-regions/${regionId}`, 'DELETE');
if (response && response.success) {
window.showToast('영역이 삭제되었습니다.', 'success');
await loadLayoutMapData();
} else {
throw new Error(response?.message || '삭제 실패');
}
} catch (error) {
console.error('영역 삭제 오류:', error);
window.showToast(error.message || '영역 삭제 중 오류가 발생했습니다.', 'error');
}
}
// 전역 함수로 노출
window.openLayoutMapModal = openLayoutMapModal;
window.closeLayoutMapModal = closeLayoutMapModal;
window.previewLayoutImage = previewLayoutImage;
window.uploadLayoutImage = uploadLayoutImage;
window.clearCurrentRegion = clearCurrentRegion;
window.saveRegion = saveRegion;
window.deleteRegion = deleteRegion;
window.editRegion = editRegion;

File diff suppressed because it is too large Load Diff

View File

@@ -1,324 +0,0 @@
/**
* Workplace Management - API Client
* 작업장 관리 관련 모든 API 호출을 관리
*/
class WorkplaceAPI {
constructor() {
this.state = window.WorkplaceState;
this.utils = window.WorkplaceUtils;
console.log('[WorkplaceAPI] 초기화 완료');
}
/**
* 모든 데이터 로드
*/
async loadAllData() {
try {
await Promise.all([
this.loadCategories(),
this.loadWorkplaces()
]);
} catch (error) {
console.error('데이터 로딩 오류:', error);
window.showToast?.('데이터를 불러오는데 실패했습니다.', 'error');
}
}
/**
* 카테고리 목록 로드
*/
async loadCategories() {
try {
const response = await window.apiCall('/workplaces/categories', 'GET');
let categoryData = [];
if (response && response.success && Array.isArray(response.data)) {
categoryData = response.data;
} else if (Array.isArray(response)) {
categoryData = response;
}
this.state.categories = categoryData;
return categoryData;
} catch (error) {
console.error('카테고리 로딩 오류:', error);
this.state.categories = [];
return [];
}
}
/**
* 카테고리 저장 (생성/수정)
*/
async saveCategory(categoryId, categoryData) {
try {
let response;
if (categoryId) {
response = await window.apiCall(`/workplaces/categories/${categoryId}`, 'PUT', categoryData);
} else {
response = await window.apiCall('/workplaces/categories', 'POST', categoryData);
}
if (response && (response.success || response.category_id)) {
return response;
}
throw new Error(response?.message || '저장에 실패했습니다.');
} catch (error) {
console.error('카테고리 저장 오류:', error);
throw error;
}
}
/**
* 카테고리 삭제
*/
async deleteCategory(categoryId) {
try {
const response = await window.apiCall(`/workplaces/categories/${categoryId}`, 'DELETE');
if (response && response.success) {
return response;
}
throw new Error(response?.message || '삭제에 실패했습니다.');
} catch (error) {
console.error('카테고리 삭제 오류:', error);
throw error;
}
}
/**
* 작업장 목록 로드
*/
async loadWorkplaces() {
try {
const response = await window.apiCall('/workplaces', 'GET');
let workplaceData = [];
if (response && response.success && Array.isArray(response.data)) {
workplaceData = response.data;
} else if (Array.isArray(response)) {
workplaceData = response;
}
this.state.workplaces = workplaceData;
return workplaceData;
} catch (error) {
console.error('작업장 로딩 오류:', error);
this.state.workplaces = [];
return [];
}
}
/**
* 작업장 저장 (생성/수정)
*/
async saveWorkplace(workplaceId, workplaceData) {
try {
let response;
if (workplaceId) {
response = await window.apiCall(`/workplaces/${workplaceId}`, 'PUT', workplaceData);
} else {
response = await window.apiCall('/workplaces', 'POST', workplaceData);
}
if (response && (response.success || response.workplace_id)) {
return response;
}
throw new Error(response?.message || '저장에 실패했습니다.');
} catch (error) {
console.error('작업장 저장 오류:', error);
throw error;
}
}
/**
* 작업장 삭제
*/
async deleteWorkplace(workplaceId) {
try {
const response = await window.apiCall(`/workplaces/${workplaceId}`, 'DELETE');
if (response && response.success) {
return response;
}
throw new Error(response?.message || '삭제에 실패했습니다.');
} catch (error) {
console.error('작업장 삭제 오류:', error);
throw error;
}
}
/**
* 카테고리의 지도 영역 로드
*/
async loadMapRegions(categoryId) {
try {
const response = await window.apiCall(`/workplaces/categories/${categoryId}/map-regions`, 'GET');
let regions = [];
if (response && response.success && Array.isArray(response.data)) {
regions = response.data;
} else if (Array.isArray(response)) {
regions = response;
}
return regions;
} catch (error) {
console.error('지도 영역 로드 오류:', error);
return [];
}
}
/**
* 작업장의 지도 영역 로드
*/
async loadWorkplaceMapRegion(workplaceId) {
try {
const response = await window.apiCall(`/workplaces/map-regions/workplace/${workplaceId}`, 'GET');
return response;
} catch (error) {
console.error('작업장 지도 영역 로드 오류:', error);
return null;
}
}
/**
* 작업장의 설비 목록 로드
*/
async loadWorkplaceEquipments(workplaceId) {
try {
const response = await window.apiCall(`/equipments/workplace/${workplaceId}`, 'GET');
let equipments = [];
if (response && response.success && Array.isArray(response.data)) {
equipments = response.data;
} else if (Array.isArray(response)) {
equipments = response;
}
// 지도 영역이 있는 설비만 workplaceEquipmentRegions에 추가
this.state.workplaceEquipmentRegions = equipments
.filter(eq => eq.map_x_percent != null && eq.map_y_percent != null)
.map(eq => ({
equipment_id: eq.equipment_id,
equipment_name: eq.equipment_name,
equipment_code: eq.equipment_code,
x_percent: parseFloat(eq.map_x_percent),
y_percent: parseFloat(eq.map_y_percent),
width_percent: parseFloat(eq.map_width_percent) || 10,
height_percent: parseFloat(eq.map_height_percent) || 10
}));
this.state.existingEquipments = equipments;
return equipments;
} catch (error) {
console.error('설비 로드 오류:', error);
this.state.workplaceEquipmentRegions = [];
this.state.existingEquipments = [];
return [];
}
}
/**
* 전체 설비 목록 로드
*/
async loadAllEquipments() {
try {
const response = await window.apiCall('/equipments', 'GET');
let equipments = [];
if (response && response.success && Array.isArray(response.data)) {
equipments = response.data;
} else if (Array.isArray(response)) {
equipments = response;
}
this.state.allEquipments = equipments;
return equipments;
} catch (error) {
console.error('전체 설비 로드 오류:', error);
this.state.allEquipments = [];
return [];
}
}
/**
* 설비 지도 위치 업데이트
*/
async updateEquipmentMapPosition(equipmentId, positionData) {
try {
const response = await window.apiCall(`/equipments/${equipmentId}/map-position`, 'PATCH', positionData);
if (!response || !response.success) {
throw new Error(response?.message || '위치 저장 실패');
}
return response;
} catch (error) {
console.error('설비 위치 업데이트 오류:', error);
throw error;
}
}
/**
* 새 설비 생성
*/
async createEquipment(equipmentData) {
try {
const response = await window.apiCall('/equipments', 'POST', equipmentData);
if (!response || !response.success) {
throw new Error(response?.message || '설비 생성 실패');
}
return response;
} catch (error) {
console.error('설비 생성 오류:', error);
throw error;
}
}
/**
* 다음 관리번호 조회
*/
async getNextEquipmentCode() {
try {
const response = await window.apiCall('/equipments/next-code', 'GET');
if (response && response.success) {
return response.data.next_code;
}
return null;
} catch (error) {
console.error('다음 관리번호 조회 실패:', error);
return null;
}
}
/**
* 작업장 레이아웃 이미지 업로드
*/
async uploadWorkplaceLayout(workplaceId, formData) {
try {
const response = await fetch(
`${this.utils.getApiBaseUrl()}/workplaces/${workplaceId}/layout-image`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('sso_token')}`
},
body: formData
}
);
return await response.json();
} catch (error) {
console.error('레이아웃 이미지 업로드 오류:', error);
throw error;
}
}
}
// 전역 인스턴스 생성
window.WorkplaceAPI = new WorkplaceAPI();
// 하위 호환성: 기존 함수들
window.loadCategories = () => window.WorkplaceAPI.loadCategories();
window.loadWorkplaces = () => window.WorkplaceAPI.loadWorkplaces();
window.loadWorkplaceEquipments = (id) => window.WorkplaceAPI.loadWorkplaceEquipments(id);
window.loadAllEquipments = () => window.WorkplaceAPI.loadAllEquipments();
console.log('[Module] workplace-management/api.js 로드 완료');

View File

@@ -1,551 +0,0 @@
/**
* Workplace Management - Module Loader
* 작업장 관리 모듈을 초기화하고 연결하는 메인 진입점
*
* 로드 순서:
* 1. state.js - 전역 상태 관리
* 2. utils.js - 유틸리티 함수
* 3. api.js - API 클라이언트
* 4. index.js - 이 파일 (메인 컨트롤러)
*/
class WorkplaceController {
constructor() {
this.state = window.WorkplaceState;
this.api = window.WorkplaceAPI;
this.utils = window.WorkplaceUtils;
this.initialized = false;
console.log('[WorkplaceController] 생성');
}
/**
* 초기화
*/
async init() {
if (this.initialized) {
console.log('[WorkplaceController] 이미 초기화됨');
return;
}
// API 함수가 로드될 때까지 대기
let retryCount = 0;
while (!window.apiCall && retryCount < 50) {
await new Promise(resolve => setTimeout(resolve, 100));
retryCount++;
}
if (!window.apiCall) {
window.showToast?.('시스템을 초기화할 수 없습니다. 페이지를 새로고침해주세요.', 'error');
return;
}
// 모든 데이터 로드
await this.loadAllData();
this.initialized = true;
console.log('[WorkplaceController] 초기화 완료');
}
/**
* 모든 데이터 로드
*/
async loadAllData() {
try {
await this.api.loadAllData();
this.renderCategoryTabs();
this.renderWorkplaces();
this.updateStatistics();
} catch (error) {
console.error('데이터 로딩 오류:', error);
window.showToast?.('데이터를 불러오는데 실패했습니다.', 'error');
}
}
/**
* 카테고리 탭 렌더링
*/
renderCategoryTabs() {
const tabsContainer = document.getElementById('categoryTabs');
if (!tabsContainer) return;
const categories = this.state.categories;
const workplaces = this.state.workplaces;
const currentCategoryId = this.state.currentCategoryId;
let tabsHtml = `
<button class="wp-tab-btn ${currentCategoryId === '' ? 'active' : ''}"
data-category=""
onclick="switchCategory('')">
<span class="wp-tab-icon">🏗️</span>
전체
<span class="wp-tab-count">${workplaces.length}</span>
</button>
`;
categories.forEach(category => {
const count = workplaces.filter(w => w.category_id === category.category_id).length;
const isActive = currentCategoryId === category.category_id;
tabsHtml += `
<button class="wp-tab-btn ${isActive ? 'active' : ''}"
data-category="${category.category_id}"
onclick="switchCategory(${category.category_id})">
<span class="wp-tab-icon">🏭</span>
${category.category_name}
<span class="wp-tab-count">${count}</span>
</button>
`;
});
tabsContainer.innerHTML = tabsHtml;
}
/**
* 카테고리 전환
*/
async switchCategory(categoryId) {
this.state.setCurrentCategory(categoryId);
this.renderCategoryTabs();
this.renderWorkplaces();
const layoutMapSection = document.getElementById('layoutMapSection');
const selectedCategoryName = document.getElementById('selectedCategoryName');
if (categoryId && layoutMapSection) {
const category = this.state.getCurrentCategory();
if (category) {
layoutMapSection.style.display = 'block';
if (selectedCategoryName) {
selectedCategoryName.textContent = category.category_name;
}
await this.updateLayoutPreview(category);
}
} else if (layoutMapSection) {
layoutMapSection.style.display = 'none';
}
}
/**
* 레이아웃 미리보기 업데이트
*/
async updateLayoutPreview(category) {
const previewDiv = document.getElementById('layoutMapPreview');
if (!previewDiv) return;
if (category.layout_image) {
const fullImageUrl = this.utils.getFullImageUrl(category.layout_image);
previewDiv.innerHTML = `
<div style="text-align: center;">
<canvas id="previewCanvas" style="max-width: 100%; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); cursor: default;"></canvas>
<p style="color: #64748b; margin-top: 12px; font-size: 14px;">
클릭하여 작업장 영역을 수정하려면 "지도 설정" 버튼을 누르세요
</p>
</div>
`;
await this.loadImageWithRegions(fullImageUrl, category.category_id);
} else {
previewDiv.innerHTML = `
<div style="padding: 40px;">
<span style="font-size: 48px;">🗺️</span>
<p style="color: #94a3b8; margin-top: 16px;">
이 공장의 레이아웃 이미지가 아직 등록되지 않았습니다
</p>
<p style="color: #cbd5e1; font-size: 14px; margin-top: 8px;">
"지도 설정" 버튼을 눌러 레이아웃 이미지를 업로드하고 작업장 위치를 지정하세요
</p>
</div>
`;
}
}
/**
* 이미지와 영역을 캔버스에 로드
*/
async loadImageWithRegions(imageUrl, categoryId) {
const canvas = document.getElementById('previewCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const img = new Image();
const self = this;
img.onload = async function() {
const maxWidth = 800;
const scale = img.width > maxWidth ? maxWidth / img.width : 1;
canvas.width = img.width * scale;
canvas.height = img.height * scale;
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
try {
const regions = await self.api.loadMapRegions(categoryId);
regions.forEach(region => {
const x1 = (region.x_start / 100) * canvas.width;
const y1 = (region.y_start / 100) * canvas.height;
const x2 = (region.x_end / 100) * canvas.width;
const y2 = (region.y_end / 100) * canvas.height;
const width = x2 - x1;
const height = y2 - y1;
ctx.strokeStyle = '#10b981';
ctx.lineWidth = 2;
ctx.strokeRect(x1, y1, width, height);
ctx.fillStyle = 'rgba(16, 185, 129, 0.15)';
ctx.fillRect(x1, y1, width, height);
if (region.workplace_name) {
ctx.font = 'bold 14px sans-serif';
const textMetrics = ctx.measureText(region.workplace_name);
const textPadding = 4;
ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
ctx.fillRect(x1 + 5, y1 + 5, textMetrics.width + textPadding * 2, 20);
ctx.fillStyle = '#10b981';
ctx.fillText(region.workplace_name, x1 + 5 + textPadding, y1 + 20);
}
});
if (regions.length > 0) {
}
} catch (error) {
console.error('영역 로드 오류:', error);
}
};
img.onerror = function() {
console.error('이미지 로드 실패:', imageUrl);
};
img.src = imageUrl;
}
/**
* 작업장 렌더링
*/
renderWorkplaces() {
const grid = document.getElementById('workplaceGrid');
if (!grid) return;
const filtered = this.state.getFilteredWorkplaces();
if (filtered.length === 0) {
grid.innerHTML = `
<div class="wp-empty-state">
<div class="wp-empty-icon">🏗️</div>
<h3 class="wp-empty-title">등록된 작업장이 없습니다</h3>
<p class="wp-empty-description">"작업장 추가" 버튼을 눌러 작업장을 등록해보세요</p>
<button class="wp-btn wp-btn-primary" onclick="openWorkplaceModal()">
<span class="wp-btn-icon"></span>
첫 작업장 추가하기
</button>
</div>
`;
return;
}
let gridHtml = '';
filtered.forEach(workplace => {
const categoryName = workplace.category_name || '미분류';
const isActive = workplace.is_active === 1 || workplace.is_active === true;
const purposeIcon = this.utils.getPurposeIcon(workplace.workplace_purpose);
gridHtml += `
<div class="wp-card ${isActive ? '' : 'inactive'}" onclick="editWorkplace(${workplace.workplace_id})">
<div class="wp-card-header">
<div class="wp-card-icon">${purposeIcon}</div>
<div class="wp-card-info">
<h3 class="wp-card-title">${workplace.workplace_name}</h3>
<div class="wp-card-tags">
${workplace.category_id ? `<span class="wp-card-tag factory">🏭 ${categoryName}</span>` : ''}
${workplace.workplace_purpose ? `<span class="wp-card-tag purpose">${workplace.workplace_purpose}</span>` : ''}
</div>
</div>
<div class="wp-card-actions">
<button class="wp-card-btn map" onclick="event.stopPropagation(); openWorkplaceMapModal(${workplace.workplace_id})" title="지도 관리">
🗺️
</button>
<button class="wp-card-btn edit" onclick="event.stopPropagation(); editWorkplace(${workplace.workplace_id})" title="수정">
✏️
</button>
<button class="wp-card-btn delete" onclick="event.stopPropagation(); confirmDeleteWorkplace(${workplace.workplace_id})" title="삭제">
🗑️
</button>
</div>
</div>
${workplace.description ? `<p class="wp-card-description">${workplace.description}</p>` : ''}
<div class="wp-card-map" id="workplace-map-${workplace.workplace_id}"></div>
<div class="wp-card-meta">
<span class="wp-card-date">등록: ${this.utils.formatDate(workplace.created_at)}</span>
${workplace.updated_at !== workplace.created_at ? `<span class="wp-card-date">수정: ${this.utils.formatDate(workplace.updated_at)}</span>` : ''}
</div>
</div>
`;
});
grid.innerHTML = gridHtml;
filtered.forEach(workplace => {
if (workplace.category_id) {
this.loadWorkplaceMapThumbnail(workplace);
}
});
}
/**
* 작업장 카드에 지도 썸네일 로드
*/
async loadWorkplaceMapThumbnail(workplace) {
const thumbnailDiv = document.getElementById(`workplace-map-${workplace.workplace_id}`);
if (!thumbnailDiv) return;
if (workplace.layout_image) {
const fullImageUrl = this.utils.getFullImageUrl(workplace.layout_image);
let equipmentCount = 0;
try {
const eqResponse = await window.apiCall(`/equipments/workplace/${workplace.workplace_id}`, 'GET');
if (eqResponse && eqResponse.success && Array.isArray(eqResponse.data)) {
equipmentCount = eqResponse.data.filter(eq => eq.map_x_percent != null).length;
}
} catch (e) {
console.debug('설비 정보 로드 실패');
}
const canvasId = `layout-canvas-${workplace.workplace_id}`;
thumbnailDiv.innerHTML = `
<div style="text-align: center; padding: 10px; background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%); border-radius: 8px; border: 1px solid #bae6fd; cursor: pointer;" onclick="openWorkplaceMapModal(${workplace.workplace_id})">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<span style="font-size: 12px; color: #0369a1; font-weight: 600;">📍 작업장 지도</span>
${equipmentCount > 0 ? `<span style="font-size: 11px; background: #10b981; color: white; padding: 2px 8px; border-radius: 10px;">설비 ${equipmentCount}개</span>` : ''}
</div>
<canvas id="${canvasId}" style="max-width: 100%; border-radius: 6px; box-shadow: 0 2px 8px rgba(0,0,0,0.12);"></canvas>
<div style="font-size: 11px; color: #64748b; margin-top: 8px;">클릭하여 지도 관리</div>
</div>
`;
await this.loadWorkplaceCanvasWithEquipments(workplace.workplace_id, fullImageUrl, canvasId);
return;
}
try {
const response = await this.api.loadWorkplaceMapRegion(workplace.workplace_id);
if (!response || (!response.success && !response.region_id)) {
thumbnailDiv.innerHTML = `
<div style="text-align: center; padding: 16px; background: #f9fafb; border-radius: 8px; border: 2px dashed #cbd5e1; cursor: pointer;" onclick="openWorkplaceMapModal(${workplace.workplace_id})">
<div style="font-size: 24px; margin-bottom: 8px;">🗺️</div>
<div style="font-size: 12px; color: #64748b;">클릭하여 지도 설정</div>
</div>
`;
return;
}
const region = response.success ? response.data : response;
if (!region || region.x_start === undefined || region.y_start === undefined ||
region.x_end === undefined || region.y_end === undefined) {
return;
}
const category = this.state.categories.find(c => c.category_id === workplace.category_id);
if (!category || !category.layout_image) return;
const fullImageUrl = this.utils.getFullImageUrl(category.layout_image);
const canvasId = `thumbnail-canvas-${workplace.workplace_id}`;
thumbnailDiv.innerHTML = `
<div style="text-align: center; padding: 10px; background: #f9fafb; border-radius: 8px; border: 1px solid #e5e7eb; cursor: pointer;" onclick="openWorkplaceMapModal(${workplace.workplace_id})">
<div style="font-size: 12px; color: #64748b; margin-bottom: 6px; font-weight: 500;">📍 공장 지도 내 위치</div>
<canvas id="${canvasId}" style="max-width: 100%; border-radius: 6px; box-shadow: 0 2px 6px rgba(0,0,0,0.1);"></canvas>
<div style="font-size: 11px; color: #94a3b8; margin-top: 6px;">클릭하여 상세 지도 설정</div>
</div>
`;
const img = new Image();
img.onload = function() {
const canvas = document.getElementById(canvasId);
if (!canvas) return;
const ctx = canvas.getContext('2d');
const x1 = (region.x_start / 100) * img.width;
const y1 = (region.y_start / 100) * img.height;
const x2 = (region.x_end / 100) * img.width;
const y2 = (region.y_end / 100) * img.height;
const regionWidth = x2 - x1;
const regionHeight = y2 - y1;
const maxThumbWidth = 350;
const scale = regionWidth > maxThumbWidth ? maxThumbWidth / regionWidth : 1;
canvas.width = regionWidth * scale;
canvas.height = regionHeight * scale;
ctx.drawImage(
img,
x1, y1, regionWidth, regionHeight,
0, 0, canvas.width, canvas.height
);
ctx.strokeStyle = '#10b981';
ctx.lineWidth = 3;
ctx.strokeRect(0, 0, canvas.width, canvas.height);
};
img.onerror = function() {
thumbnailDiv.innerHTML = '';
};
img.src = fullImageUrl;
} catch (error) {
console.debug(`작업장 ${workplace.workplace_id}의 지도 영역 없음`);
}
}
/**
* 작업장 캔버스에 설비 영역 함께 그리기
*/
async loadWorkplaceCanvasWithEquipments(workplaceId, imageUrl, canvasId) {
const img = new Image();
img.onload = async function() {
const canvas = document.getElementById(canvasId);
if (!canvas) return;
const ctx = canvas.getContext('2d');
const maxThumbWidth = 400;
const scale = img.width > maxThumbWidth ? maxThumbWidth / img.width : 1;
canvas.width = img.width * scale;
canvas.height = img.height * scale;
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
try {
const response = await window.apiCall(`/equipments/workplace/${workplaceId}`, 'GET');
let equipments = [];
if (response && response.success && Array.isArray(response.data)) {
equipments = response.data.filter(eq => eq.map_x_percent != null);
}
equipments.forEach(eq => {
const x = (parseFloat(eq.map_x_percent) / 100) * canvas.width;
const y = (parseFloat(eq.map_y_percent) / 100) * canvas.height;
const width = (parseFloat(eq.map_width_percent || 10) / 100) * canvas.width;
const height = (parseFloat(eq.map_height_percent || 10) / 100) * canvas.height;
ctx.fillStyle = 'rgba(16, 185, 129, 0.2)';
ctx.fillRect(x, y, width, height);
ctx.strokeStyle = '#10b981';
ctx.lineWidth = 2;
ctx.strokeRect(x, y, width, height);
if (eq.equipment_code) {
ctx.font = 'bold 10px sans-serif';
const textMetrics = ctx.measureText(eq.equipment_code);
ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
ctx.fillRect(x + 2, y + 2, textMetrics.width + 6, 14);
ctx.fillStyle = '#047857';
ctx.fillText(eq.equipment_code, x + 5, y + 12);
}
});
} catch (error) {
console.debug('설비 영역 로드 실패');
}
};
img.src = imageUrl;
}
/**
* 통계 업데이트
*/
async updateStatistics() {
const stats = this.state.getStatistics();
const factoryCountEl = document.getElementById('factoryCount');
const totalCountEl = document.getElementById('totalCount');
const activeCountEl = document.getElementById('activeCount');
const equipmentCountEl = document.getElementById('equipmentCount');
if (factoryCountEl) factoryCountEl.textContent = stats.factoryTotal;
if (totalCountEl) totalCountEl.textContent = stats.total;
if (activeCountEl) activeCountEl.textContent = stats.active;
if (equipmentCountEl) {
try {
const equipments = await this.api.loadAllEquipments();
equipmentCountEl.textContent = equipments.length;
} catch (e) {
equipmentCountEl.textContent = '-';
}
}
const sectionTotalEl = document.getElementById('sectionTotalCount');
const sectionActiveEl = document.getElementById('sectionActiveCount');
if (sectionTotalEl) sectionTotalEl.textContent = stats.filteredTotal;
if (sectionActiveEl) sectionActiveEl.textContent = stats.filteredActive;
}
/**
* 전체 새로고침
*/
async refreshWorkplaces() {
const refreshBtn = document.querySelector('.btn-secondary');
if (refreshBtn) {
const originalText = refreshBtn.innerHTML;
refreshBtn.innerHTML = '<span class="btn-icon">⏳</span>새로고침 중...';
refreshBtn.disabled = true;
await this.loadAllData();
refreshBtn.innerHTML = originalText;
refreshBtn.disabled = false;
} else {
await this.loadAllData();
}
window.showToast?.('데이터가 새로고침되었습니다.', 'success');
}
/**
* 디버그
*/
debug() {
console.log('[WorkplaceController] 상태 디버그:');
this.state.debug();
}
}
// 전역 인스턴스 생성
window.WorkplaceController = new WorkplaceController();
// 하위 호환성: 기존 전역 함수들
window.switchCategory = (categoryId) => window.WorkplaceController.switchCategory(categoryId);
window.renderCategoryTabs = () => window.WorkplaceController.renderCategoryTabs();
window.renderWorkplaces = () => window.WorkplaceController.renderWorkplaces();
window.updateStatistics = () => window.WorkplaceController.updateStatistics();
window.refreshWorkplaces = () => window.WorkplaceController.refreshWorkplaces();
window.loadAllData = () => window.WorkplaceController.loadAllData();
window.updateLayoutPreview = (category) => window.WorkplaceController.updateLayoutPreview(category);
// DOMContentLoaded 이벤트에서 초기화
document.addEventListener('DOMContentLoaded', () => {
setTimeout(() => {
window.WorkplaceController.init();
}, 100);
});
console.log('[Module] workplace-management/index.js 로드 완료');

View File

@@ -1,284 +0,0 @@
/**
* Workplace Management - State Manager
* 작업장 관리 페이지의 전역 상태 관리
*/
class WorkplaceState {
constructor() {
// 마스터 데이터
this.categories = [];
this.workplaces = [];
this.allEquipments = [];
this.existingEquipments = [];
// 현재 상태
this.currentCategoryId = '';
this.currentEditingCategory = null;
this.currentEditingWorkplace = null;
this.currentWorkplaceMapId = null;
// 작업장 지도 관련
this.workplaceCanvas = null;
this.workplaceCtx = null;
this.workplaceImage = null;
this.workplaceIsDrawing = false;
this.workplaceStartX = 0;
this.workplaceStartY = 0;
this.workplaceCurrentRect = null;
this.workplaceEquipmentRegions = [];
// 전체화면 편집기 관련
this.fsCanvas = null;
this.fsCtx = null;
this.fsImage = null;
this.fsIsDrawing = false;
this.fsStartX = 0;
this.fsStartY = 0;
this.fsCurrentRect = null;
this.fsSidebarVisible = true;
// 리스너
this.listeners = new Map();
console.log('[WorkplaceState] 초기화 완료');
}
/**
* 상태 업데이트
*/
update(key, value) {
const prevValue = this[key];
this[key] = value;
this.notifyListeners(key, value, prevValue);
}
/**
* 리스너 등록
*/
subscribe(key, callback) {
if (!this.listeners.has(key)) {
this.listeners.set(key, []);
}
this.listeners.get(key).push(callback);
}
/**
* 리스너 알림
*/
notifyListeners(key, newValue, prevValue) {
const keyListeners = this.listeners.get(key) || [];
keyListeners.forEach(callback => {
try {
callback(newValue, prevValue);
} catch (error) {
console.error(`[WorkplaceState] 리스너 오류 (${key}):`, error);
}
});
}
/**
* 현재 카테고리 변경
*/
setCurrentCategory(categoryId) {
const prevId = this.currentCategoryId;
this.currentCategoryId = categoryId === '' ? '' : categoryId;
this.notifyListeners('currentCategoryId', this.currentCategoryId, prevId);
}
/**
* 현재 카테고리 정보 가져오기
*/
getCurrentCategory() {
if (!this.currentCategoryId) return null;
return this.categories.find(c => c.category_id == this.currentCategoryId);
}
/**
* 현재 카테고리별 작업장 가져오기
*/
getFilteredWorkplaces() {
if (this.currentCategoryId === '') {
return this.workplaces;
}
return this.workplaces.filter(w => w.category_id == this.currentCategoryId);
}
/**
* 작업장 지도 상태 초기화
*/
resetWorkplaceMapState() {
this.workplaceCanvas = null;
this.workplaceCtx = null;
this.workplaceImage = null;
this.workplaceIsDrawing = false;
this.workplaceCurrentRect = null;
}
/**
* 전체화면 편집기 상태 초기화
*/
resetFullscreenState() {
this.fsCanvas = null;
this.fsCtx = null;
this.fsImage = null;
this.fsIsDrawing = false;
this.fsCurrentRect = null;
}
/**
* 통계 계산
*/
getStatistics() {
const total = this.workplaces.length;
const active = this.workplaces.filter(w =>
w.is_active === 1 || w.is_active === true
).length;
const factoryTotal = this.categories.length;
const filtered = this.getFilteredWorkplaces();
const filteredActive = filtered.filter(w =>
w.is_active === 1 || w.is_active === true
).length;
return {
total,
active,
factoryTotal,
filteredTotal: filtered.length,
filteredActive
};
}
/**
* 상태 초기화
*/
reset() {
this.currentEditingCategory = null;
this.currentEditingWorkplace = null;
this.currentWorkplaceMapId = null;
this.resetWorkplaceMapState();
this.resetFullscreenState();
}
/**
* 디버그 출력
*/
debug() {
console.log('[WorkplaceState] 현재 상태:', {
categories: this.categories.length,
workplaces: this.workplaces.length,
currentCategoryId: this.currentCategoryId,
allEquipments: this.allEquipments.length,
workplaceEquipmentRegions: this.workplaceEquipmentRegions.length
});
}
}
// 전역 인스턴스 생성
window.WorkplaceState = new WorkplaceState();
// 하위 호환성을 위한 전역 변수 프록시
const wpStateProxy = window.WorkplaceState;
Object.defineProperties(window, {
categories: {
get: () => wpStateProxy.categories,
set: (v) => { wpStateProxy.categories = v; }
},
workplaces: {
get: () => wpStateProxy.workplaces,
set: (v) => { wpStateProxy.workplaces = v; }
},
currentCategoryId: {
get: () => wpStateProxy.currentCategoryId,
set: (v) => { wpStateProxy.currentCategoryId = v; }
},
currentEditingCategory: {
get: () => wpStateProxy.currentEditingCategory,
set: (v) => { wpStateProxy.currentEditingCategory = v; }
},
currentEditingWorkplace: {
get: () => wpStateProxy.currentEditingWorkplace,
set: (v) => { wpStateProxy.currentEditingWorkplace = v; }
},
workplaceCanvas: {
get: () => wpStateProxy.workplaceCanvas,
set: (v) => { wpStateProxy.workplaceCanvas = v; }
},
workplaceCtx: {
get: () => wpStateProxy.workplaceCtx,
set: (v) => { wpStateProxy.workplaceCtx = v; }
},
workplaceImage: {
get: () => wpStateProxy.workplaceImage,
set: (v) => { wpStateProxy.workplaceImage = v; }
},
workplaceIsDrawing: {
get: () => wpStateProxy.workplaceIsDrawing,
set: (v) => { wpStateProxy.workplaceIsDrawing = v; }
},
workplaceStartX: {
get: () => wpStateProxy.workplaceStartX,
set: (v) => { wpStateProxy.workplaceStartX = v; }
},
workplaceStartY: {
get: () => wpStateProxy.workplaceStartY,
set: (v) => { wpStateProxy.workplaceStartY = v; }
},
workplaceCurrentRect: {
get: () => wpStateProxy.workplaceCurrentRect,
set: (v) => { wpStateProxy.workplaceCurrentRect = v; }
},
workplaceEquipmentRegions: {
get: () => wpStateProxy.workplaceEquipmentRegions,
set: (v) => { wpStateProxy.workplaceEquipmentRegions = v; }
},
existingEquipments: {
get: () => wpStateProxy.existingEquipments,
set: (v) => { wpStateProxy.existingEquipments = v; }
},
allEquipments: {
get: () => wpStateProxy.allEquipments,
set: (v) => { wpStateProxy.allEquipments = v; }
},
fsCanvas: {
get: () => wpStateProxy.fsCanvas,
set: (v) => { wpStateProxy.fsCanvas = v; }
},
fsCtx: {
get: () => wpStateProxy.fsCtx,
set: (v) => { wpStateProxy.fsCtx = v; }
},
fsImage: {
get: () => wpStateProxy.fsImage,
set: (v) => { wpStateProxy.fsImage = v; }
},
fsIsDrawing: {
get: () => wpStateProxy.fsIsDrawing,
set: (v) => { wpStateProxy.fsIsDrawing = v; }
},
fsStartX: {
get: () => wpStateProxy.fsStartX,
set: (v) => { wpStateProxy.fsStartX = v; }
},
fsStartY: {
get: () => wpStateProxy.fsStartY,
set: (v) => { wpStateProxy.fsStartY = v; }
},
fsCurrentRect: {
get: () => wpStateProxy.fsCurrentRect,
set: (v) => { wpStateProxy.fsCurrentRect = v; }
},
fsSidebarVisible: {
get: () => wpStateProxy.fsSidebarVisible,
set: (v) => { wpStateProxy.fsSidebarVisible = v; }
}
});
// currentWorkplaceMapId를 window에도 설정
Object.defineProperty(window, 'currentWorkplaceMapId', {
get: () => wpStateProxy.currentWorkplaceMapId,
set: (v) => { wpStateProxy.currentWorkplaceMapId = v; }
});
console.log('[Module] workplace-management/state.js 로드 완료');

View File

@@ -1,99 +0,0 @@
/**
* Workplace Management - Utilities
* 작업장 관리 관련 유틸리티 함수들
*/
class WorkplaceUtils {
constructor() {
console.log('[WorkplaceUtils] 초기화 완료');
}
/**
* 날짜 포맷팅
*/
formatDate(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
}
/**
* API URL 생성
*/
getApiBaseUrl() {
return window.API_BASE_URL || 'http://localhost:30005/api';
}
/**
* 이미지 URL 생성
*/
getFullImageUrl(imagePath) {
if (!imagePath) return null;
if (imagePath.startsWith('http')) return imagePath;
return `${this.getApiBaseUrl()}${imagePath}`.replace('/api/', '/');
}
/**
* 작업장 용도 아이콘 반환
*/
getPurposeIcon(purpose) {
const icons = {
'작업구역': '🔧',
'설비': '⚙️',
'휴게시설': '☕',
'회의실': '💼',
'창고': '📦',
'기타': '📍'
};
return purpose ? (icons[purpose] || '📍') : '🏗️';
}
/**
* 퍼센트를 픽셀로 변환
*/
percentToPixel(percent, canvasSize) {
return (percent / 100) * canvasSize;
}
/**
* 픽셀을 퍼센트로 변환
*/
pixelToPercent(pixel, canvasSize) {
return (pixel / canvasSize) * 100;
}
/**
* 영역 좌표 정규화 (음수 처리)
*/
normalizeRect(rect, canvasWidth, canvasHeight) {
const xPercent = this.pixelToPercent(
Math.min(rect.x, rect.x + rect.width),
canvasWidth
);
const yPercent = this.pixelToPercent(
Math.min(rect.y, rect.y + rect.height),
canvasHeight
);
const widthPercent = this.pixelToPercent(
Math.abs(rect.width),
canvasWidth
);
const heightPercent = this.pixelToPercent(
Math.abs(rect.height),
canvasHeight
);
return { xPercent, yPercent, widthPercent, heightPercent };
}
}
// 전역 인스턴스 생성
window.WorkplaceUtils = new WorkplaceUtils();
// showToast, formatDate → api-base.js 전역 사용
console.log('[Module] workplace-management/utils.js 로드 완료');

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff