fix: 보안 취약점 수정 및 XSS 방지 적용
## 백엔드 보안 수정 - 하드코딩된 비밀번호 및 JWT 시크릿 폴백 제거 - SQL Injection 방지를 위한 화이트리스트 검증 추가 - 인증 미적용 API 라우트에 requireAuth 미들웨어 적용 - CSRF 보호 미들웨어 구현 (csrf.js) - 파일 업로드 보안 유틸리티 추가 (fileUploadSecurity.js) - 비밀번호 정책 검증 유틸리티 추가 (passwordValidator.js) ## 프론트엔드 XSS 방지 - api-base.js에 전역 escapeHtml() 함수 추가 - 17개 주요 JS 파일에 escapeHtml 적용: - tbm.js, daily-patrol.js, daily-work-report.js - task-management.js, workplace-status.js - equipment-detail.js, equipment-management.js - issue-detail.js, issue-report.js - vacation-common.js, worker-management.js - safety-report-list.js, nonconformity-list.js - project-management.js, workplace-management.js ## 정리 - 백업 폴더 및 빈 파일 삭제 - SECURITY_GUIDE.md 문서 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,48 @@
|
||||
// /js/api-base.js
|
||||
// API 기본 설정 (비모듈 - 빠른 로딩용)
|
||||
// API 기본 설정 및 보안 유틸리티 (비모듈 - 빠른 로딩용)
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// ==================== 보안 유틸리티 (XSS 방지) ====================
|
||||
|
||||
/**
|
||||
* HTML 특수문자 이스케이프 (XSS 방지)
|
||||
* innerHTML에 사용자 입력/API 데이터를 삽입할 때 반드시 사용
|
||||
*
|
||||
* @param {string} str - 이스케이프할 문자열
|
||||
* @returns {string} 이스케이프된 문자열
|
||||
*/
|
||||
window.escapeHtml = function(str) {
|
||||
if (str === null || str === undefined) return '';
|
||||
if (typeof str !== 'string') str = String(str);
|
||||
|
||||
const htmlEntities = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
'/': '/',
|
||||
'`': '`',
|
||||
'=': '='
|
||||
};
|
||||
|
||||
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 설정 ====================
|
||||
|
||||
const API_PORT = 20005;
|
||||
const API_PATH = '/api';
|
||||
|
||||
|
||||
@@ -44,20 +44,18 @@ function waitForAxiosConfig() {
|
||||
});
|
||||
}
|
||||
|
||||
// 자동 날짜/시간대 결정
|
||||
function getAutoPatrolDateTime() {
|
||||
const now = new Date();
|
||||
const patrolDate = now.toISOString().slice(0, 10);
|
||||
const hour = now.getHours();
|
||||
// 오전(~12시), 오후(12시~)
|
||||
const patrolTime = hour < 12 ? 'morning' : 'afternoon';
|
||||
return { patrolDate, patrolTime };
|
||||
}
|
||||
|
||||
// 페이지 초기화
|
||||
async function initializePage() {
|
||||
// 오늘 날짜 설정
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
document.getElementById('patrolDate').value = today;
|
||||
|
||||
// 시간대 버튼 이벤트
|
||||
document.querySelectorAll('.patrol-time-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.patrol-time-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
// 데이터 로드
|
||||
await Promise.all([
|
||||
loadCategories(),
|
||||
@@ -72,15 +70,74 @@ async function loadCategories() {
|
||||
const response = await axios.get('/workplaces/categories');
|
||||
if (response.data.success) {
|
||||
categories = response.data.data;
|
||||
const select = document.getElementById('categorySelect');
|
||||
select.innerHTML = '<option value="">공장 선택...</option>' +
|
||||
categories.map(c => `<option value="${c.category_id}">${c.category_name}</option>`).join('');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('공장 목록 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 공장 선택 화면 표시
|
||||
function showFactorySelection() {
|
||||
const { patrolDate, patrolTime } = getAutoPatrolDateTime();
|
||||
const timeLabel = patrolTime === 'morning' ? '오전' : '오후';
|
||||
const dateObj = new Date(patrolDate);
|
||||
const dateLabel = dateObj.toLocaleDateString('ko-KR', { month: 'long', day: 'numeric', weekday: 'short' });
|
||||
|
||||
// 세션 정보 표시
|
||||
document.getElementById('patrolSessionInfo').textContent = `${dateLabel} ${timeLabel} 순회점검`;
|
||||
|
||||
// 공장 카드 렌더링
|
||||
const container = document.getElementById('factoryCardsContainer');
|
||||
container.innerHTML = categories.map(c => `
|
||||
<div class="factory-card" onclick="selectFactory(${parseInt(c.category_id) || 0})">
|
||||
<div class="factory-card-icon">
|
||||
${c.layout_image ? `<img src="${escapeHtml(getImageUrl(c.layout_image))}" alt="${escapeHtml(c.category_name)}">` : '<span>🏭</span>'}
|
||||
</div>
|
||||
<div class="factory-card-name">${escapeHtml(c.category_name)}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// 시작 버튼 숨기고 공장 선택 영역 표시
|
||||
document.getElementById('startPatrolBtn').style.display = 'none';
|
||||
document.getElementById('factorySelectionArea').style.display = 'block';
|
||||
}
|
||||
|
||||
// 공장 선택 후 점검 시작
|
||||
async function selectFactory(categoryId) {
|
||||
const { patrolDate, patrolTime } = getAutoPatrolDateTime();
|
||||
|
||||
try {
|
||||
// 세션 생성 또는 조회
|
||||
const response = await axios.post('/patrol/sessions', {
|
||||
patrol_date: patrolDate,
|
||||
patrol_time: patrolTime,
|
||||
category_id: categoryId
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
currentSession = response.data.data;
|
||||
currentSession.patrol_date = patrolDate;
|
||||
currentSession.patrol_time = patrolTime;
|
||||
currentSession.category_id = categoryId;
|
||||
|
||||
// 작업장 목록 로드
|
||||
await loadWorkplaces(categoryId);
|
||||
|
||||
// 체크리스트 항목 로드
|
||||
await loadChecklistItems(categoryId);
|
||||
|
||||
// 공장 선택 영역 숨기고 점검 영역 표시
|
||||
document.getElementById('factorySelectionArea').style.display = 'none';
|
||||
document.getElementById('patrolArea').style.display = 'block';
|
||||
renderSessionInfo();
|
||||
renderWorkplaceMap();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('순회점검 시작 실패:', error);
|
||||
alert('순회점검을 시작할 수 없습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
// 물품 유형 로드
|
||||
async function loadItemTypes() {
|
||||
try {
|
||||
@@ -133,63 +190,18 @@ function renderTodayStatus(statusList) {
|
||||
<div class="status-value ${morning?.status === 'completed' ? 'completed' : 'pending'}">
|
||||
${morning ? (morning.status === 'completed' ? '완료' : '진행중') : '미점검'}
|
||||
</div>
|
||||
${morning ? `<div class="status-sub">${morning.inspector_name || ''}</div>` : ''}
|
||||
${morning ? `<div class="status-sub">${escapeHtml(morning.inspector_name || '')}</div>` : ''}
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<div class="status-label">오후</div>
|
||||
<div class="status-value ${afternoon?.status === 'completed' ? 'completed' : 'pending'}">
|
||||
${afternoon ? (afternoon.status === 'completed' ? '완료' : '진행중') : '미점검'}
|
||||
</div>
|
||||
${afternoon ? `<div class="status-sub">${afternoon.inspector_name || ''}</div>` : ''}
|
||||
${afternoon ? `<div class="status-sub">${escapeHtml(afternoon.inspector_name || '')}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 순회점검 시작
|
||||
async function startPatrol() {
|
||||
const patrolDate = document.getElementById('patrolDate').value;
|
||||
const patrolTime = document.querySelector('.patrol-time-btn.active')?.dataset.time;
|
||||
const categoryId = document.getElementById('categorySelect').value;
|
||||
|
||||
if (!patrolDate || !patrolTime || !categoryId) {
|
||||
alert('점검 일자, 시간대, 공장을 모두 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 세션 생성 또는 조회
|
||||
const response = await axios.post('/patrol/sessions', {
|
||||
patrol_date: patrolDate,
|
||||
patrol_time: patrolTime,
|
||||
category_id: categoryId
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
currentSession = response.data.data;
|
||||
currentSession.patrol_date = patrolDate;
|
||||
currentSession.patrol_time = patrolTime;
|
||||
currentSession.category_id = categoryId;
|
||||
|
||||
// 작업장 목록 로드
|
||||
await loadWorkplaces(categoryId);
|
||||
|
||||
// 체크리스트 항목 로드
|
||||
await loadChecklistItems(categoryId);
|
||||
|
||||
// 점검 영역 표시
|
||||
document.getElementById('patrolArea').style.display = 'block';
|
||||
renderSessionInfo();
|
||||
renderWorkplaceMap();
|
||||
|
||||
// 시작 버튼 비활성화
|
||||
document.getElementById('startPatrolBtn').textContent = '점검 진행중...';
|
||||
document.getElementById('startPatrolBtn').disabled = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('순회점검 시작 실패:', error);
|
||||
alert('순회점검을 시작할 수 없습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
// 작업장 목록 로드
|
||||
async function loadWorkplaces(categoryId) {
|
||||
@@ -227,7 +239,7 @@ function renderSessionInfo() {
|
||||
<div class="session-info">
|
||||
<div class="session-info-item">
|
||||
<span class="session-info-label">점검일자</span>
|
||||
<span class="session-info-value">${formatDate(currentSession.patrol_date)}</span>
|
||||
<span class="session-info-value">${escapeHtml(formatDate(currentSession.patrol_date))}</span>
|
||||
</div>
|
||||
<div class="session-info-item">
|
||||
<span class="session-info-label">시간대</span>
|
||||
@@ -235,14 +247,14 @@ function renderSessionInfo() {
|
||||
</div>
|
||||
<div class="session-info-item">
|
||||
<span class="session-info-label">공장</span>
|
||||
<span class="session-info-value">${category?.category_name || ''}</span>
|
||||
<span class="session-info-value">${escapeHtml(category?.category_name || '')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-progress">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: ${progress}%"></div>
|
||||
<div class="progress-fill" style="width: ${parseInt(progress) || 0}%"></div>
|
||||
</div>
|
||||
<span class="progress-text">${progress}%</span>
|
||||
<span class="progress-text">${parseInt(progress) || 0}%</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -255,18 +267,19 @@ function renderWorkplaceMap() {
|
||||
|
||||
// 지도 이미지가 있으면 지도 표시
|
||||
if (category?.layout_image) {
|
||||
mapContainer.innerHTML = `<img src="${getImageUrl(category.layout_image)}" alt="${category.category_name} 지도">`;
|
||||
mapContainer.innerHTML = `<img src="${escapeHtml(getImageUrl(category.layout_image))}" alt="${escapeHtml(category.category_name)} 지도">`;
|
||||
mapContainer.style.display = 'block';
|
||||
listContainer.style.display = 'none';
|
||||
|
||||
// 작업장 마커 추가
|
||||
// 좌표가 있는 작업장만 마커 추가
|
||||
const hasMarkers = workplaces.some(wp => wp.x_percent && wp.y_percent);
|
||||
|
||||
workplaces.forEach(wp => {
|
||||
if (wp.x_percent && wp.y_percent) {
|
||||
const marker = document.createElement('div');
|
||||
marker.className = 'workplace-marker';
|
||||
marker.style.left = `${wp.x_percent}%`;
|
||||
marker.style.top = `${wp.y_percent}%`;
|
||||
marker.textContent = wp.workplace_name;
|
||||
marker.style.left = `${parseFloat(wp.x_percent) || 0}%`;
|
||||
marker.style.top = `${parseFloat(wp.y_percent) || 0}%`;
|
||||
marker.textContent = wp.workplace_name; // textContent는 자동 이스케이프
|
||||
marker.dataset.workplaceId = wp.workplace_id;
|
||||
marker.onclick = () => selectWorkplace(wp.workplace_id);
|
||||
|
||||
@@ -279,30 +292,43 @@ function renderWorkplaceMap() {
|
||||
mapContainer.appendChild(marker);
|
||||
}
|
||||
});
|
||||
|
||||
// 좌표가 없는 작업장이 있으면 카드 목록도 표시
|
||||
if (!hasMarkers || workplaces.some(wp => !wp.x_percent || !wp.y_percent)) {
|
||||
listContainer.style.display = 'grid';
|
||||
renderWorkplaceCards(listContainer);
|
||||
} else {
|
||||
listContainer.style.display = 'none';
|
||||
}
|
||||
} else {
|
||||
// 지도 없으면 카드 목록으로 표시
|
||||
mapContainer.style.display = 'none';
|
||||
listContainer.style.display = 'grid';
|
||||
|
||||
listContainer.innerHTML = workplaces.map(wp => {
|
||||
const records = checkRecords[wp.workplace_id];
|
||||
const isCompleted = records && records.length > 0 && records.every(r => r.is_checked);
|
||||
const isInProgress = records && records.some(r => r.is_checked);
|
||||
|
||||
return `
|
||||
<div class="workplace-card ${isCompleted ? 'completed' : ''} ${selectedWorkplace?.workplace_id === wp.workplace_id ? 'selected' : ''}"
|
||||
data-workplace-id="${wp.workplace_id}"
|
||||
onclick="selectWorkplace(${wp.workplace_id})">
|
||||
<div class="workplace-card-name">${wp.workplace_name}</div>
|
||||
<div class="workplace-card-status">
|
||||
${isCompleted ? '점검완료' : (isInProgress ? '점검중' : '미점검')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
renderWorkplaceCards(listContainer);
|
||||
}
|
||||
}
|
||||
|
||||
// 작업장 카드 렌더링
|
||||
function renderWorkplaceCards(container) {
|
||||
container.innerHTML = workplaces.map(wp => {
|
||||
const records = checkRecords[wp.workplace_id];
|
||||
const isCompleted = records && records.length > 0 && records.every(r => r.is_checked);
|
||||
const isInProgress = records && records.some(r => r.is_checked);
|
||||
const workplaceId = parseInt(wp.workplace_id) || 0;
|
||||
|
||||
return `
|
||||
<div class="workplace-card ${isCompleted ? 'completed' : ''} ${isInProgress && !isCompleted ? 'in-progress' : ''} ${selectedWorkplace?.workplace_id === wp.workplace_id ? 'selected' : ''}"
|
||||
data-workplace-id="${workplaceId}"
|
||||
onclick="selectWorkplace(${workplaceId})">
|
||||
<div class="workplace-card-name">${escapeHtml(wp.workplace_name)}</div>
|
||||
<div class="workplace-card-status">
|
||||
${isCompleted ? '점검완료' : (isInProgress ? '점검중' : '미점검')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// 작업장 선택
|
||||
async function selectWorkplace(workplaceId) {
|
||||
selectedWorkplace = workplaces.find(w => w.workplace_id === workplaceId);
|
||||
@@ -345,7 +371,7 @@ function renderChecklist(workplaceId) {
|
||||
const workplace = workplaces.find(w => w.workplace_id === workplaceId);
|
||||
|
||||
header.innerHTML = `
|
||||
<h3>${workplace?.workplace_name || ''} 체크리스트</h3>
|
||||
<h3>${escapeHtml(workplace?.workplace_name || '')} 체크리스트</h3>
|
||||
<p class="checklist-subtitle">각 항목을 점검하고 체크해주세요</p>
|
||||
`;
|
||||
|
||||
@@ -362,32 +388,34 @@ function renderChecklist(workplaceId) {
|
||||
|
||||
content.innerHTML = Object.entries(grouped).map(([category, items]) => `
|
||||
<div class="checklist-category">
|
||||
<div class="checklist-category-title">${getCategoryName(category)}</div>
|
||||
<div class="checklist-category-title">${escapeHtml(getCategoryName(category))}</div>
|
||||
${items.map(item => {
|
||||
const record = records.find(r => r.check_item_id === item.item_id);
|
||||
const isChecked = record?.is_checked;
|
||||
const checkResult = record?.check_result;
|
||||
const itemId = parseInt(item.item_id) || 0;
|
||||
const wpId = parseInt(workplaceId) || 0;
|
||||
|
||||
return `
|
||||
<div class="check-item ${isChecked ? 'checked' : ''}"
|
||||
data-item-id="${item.item_id}"
|
||||
onclick="toggleCheckItem(${workplaceId}, ${item.item_id})">
|
||||
data-item-id="${itemId}"
|
||||
onclick="toggleCheckItem(${wpId}, ${itemId})">
|
||||
<div class="check-item-checkbox">
|
||||
${isChecked ? '✓' : ''}
|
||||
</div>
|
||||
<div class="check-item-content">
|
||||
<div class="check-item-text">
|
||||
${item.check_item}
|
||||
${escapeHtml(item.check_item)}
|
||||
${item.is_required ? '<span class="check-item-required">*</span>' : ''}
|
||||
</div>
|
||||
${isChecked ? `
|
||||
<div class="check-result-selector" onclick="event.stopPropagation()">
|
||||
<button class="check-result-btn good ${checkResult === 'good' ? 'active' : ''}"
|
||||
onclick="setCheckResult(${workplaceId}, ${item.item_id}, 'good')">양호</button>
|
||||
onclick="setCheckResult(${wpId}, ${itemId}, 'good')">양호</button>
|
||||
<button class="check-result-btn warning ${checkResult === 'warning' ? 'active' : ''}"
|
||||
onclick="setCheckResult(${workplaceId}, ${item.item_id}, 'warning')">주의</button>
|
||||
onclick="setCheckResult(${wpId}, ${itemId}, 'warning')">주의</button>
|
||||
<button class="check-result-btn bad ${checkResult === 'bad' ? 'active' : ''}"
|
||||
onclick="setCheckResult(${workplaceId}, ${item.item_id}, 'bad')">불량</button>
|
||||
onclick="setCheckResult(${wpId}, ${itemId}, 'bad')">불량</button>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
@@ -551,19 +579,21 @@ function renderItemsSection(workplaceId) {
|
||||
|
||||
// 작업장 레이아웃 이미지가 있으면 표시
|
||||
if (workplace?.layout_image) {
|
||||
container.innerHTML = `<img src="${getImageUrl(workplace.layout_image)}" alt="${workplace.workplace_name}">`;
|
||||
container.innerHTML = `<img src="${escapeHtml(getImageUrl(workplace.layout_image))}" alt="${escapeHtml(workplace.workplace_name)}">`;
|
||||
|
||||
// 물품 마커 추가
|
||||
workplaceItems.forEach(item => {
|
||||
if (item.x_percent && item.y_percent) {
|
||||
const marker = document.createElement('div');
|
||||
marker.className = `item-marker ${item.item_type}`;
|
||||
marker.style.left = `${item.x_percent}%`;
|
||||
marker.style.top = `${item.y_percent}%`;
|
||||
marker.style.width = `${item.width_percent || 5}%`;
|
||||
marker.style.height = `${item.height_percent || 5}%`;
|
||||
marker.innerHTML = item.icon || getItemTypeIcon(item.item_type);
|
||||
marker.title = `${item.item_name || item.type_name} (${item.quantity}개)`;
|
||||
// item_type은 화이트리스트로 검증
|
||||
const safeItemType = ['container', 'plate', 'material', 'tool', 'other'].includes(item.item_type) ? item.item_type : 'other';
|
||||
marker.className = `item-marker ${safeItemType}`;
|
||||
marker.style.left = `${parseFloat(item.x_percent) || 0}%`;
|
||||
marker.style.top = `${parseFloat(item.y_percent) || 0}%`;
|
||||
marker.style.width = `${parseFloat(item.width_percent) || 5}%`;
|
||||
marker.style.height = `${parseFloat(item.height_percent) || 5}%`;
|
||||
marker.textContent = item.icon || getItemTypeIcon(item.item_type); // textContent 사용
|
||||
marker.title = `${item.item_name || item.type_name} (${parseInt(item.quantity) || 0}개)`;
|
||||
marker.dataset.itemId = item.item_id;
|
||||
marker.onclick = () => openItemModal(item);
|
||||
container.appendChild(marker);
|
||||
@@ -593,7 +623,7 @@ function renderItemTypesSelect() {
|
||||
const select = document.getElementById('itemType');
|
||||
if (!select) return;
|
||||
select.innerHTML = itemTypes.map(t =>
|
||||
`<option value="${t.type_code}">${t.icon} ${t.type_name}</option>`
|
||||
`<option value="${escapeHtml(t.type_code)}">${escapeHtml(t.icon)} ${escapeHtml(t.type_name)}</option>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
@@ -601,14 +631,19 @@ function renderItemTypesSelect() {
|
||||
function renderItemsLegend() {
|
||||
const container = document.getElementById('itemsLegend');
|
||||
if (!container) return;
|
||||
container.innerHTML = itemTypes.map(t => `
|
||||
// 색상 값 검증 (hex color만 허용)
|
||||
const isValidColor = (color) => /^#[0-9A-Fa-f]{3,6}$/.test(color);
|
||||
container.innerHTML = itemTypes.map(t => {
|
||||
const safeColor = isValidColor(t.color) ? t.color : '#888888';
|
||||
return `
|
||||
<div class="item-legend-item">
|
||||
<div class="item-legend-icon" style="background: ${t.color}20; border: 1px solid ${t.color};">
|
||||
${t.icon}
|
||||
<div class="item-legend-icon" style="background: ${safeColor}20; border: 1px solid ${safeColor};">
|
||||
${escapeHtml(t.icon)}
|
||||
</div>
|
||||
<span>${t.type_name}</span>
|
||||
<span>${escapeHtml(t.type_name)}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// 편집 모드 토글
|
||||
|
||||
@@ -843,22 +843,22 @@ window.addManualWorkRow = function() {
|
||||
<td>
|
||||
<select class="form-input-compact" id="worker_${manualIndex}" style="width: 120px;" required>
|
||||
<option value="">작업자 선택</option>
|
||||
${workers.map(w => `<option value="${w.worker_id}">${w.worker_name} (${w.job_type || '-'})</option>`).join('')}
|
||||
${workers.map(w => `<option value="${escapeHtml(String(w.worker_id))}">${escapeHtml(w.worker_name)} (${escapeHtml(w.job_type || '-')})</option>`).join('')}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input type="date" class="form-input-compact" id="date_${manualIndex}" value="${getKoreaToday()}" required style="width: 130px;">
|
||||
<input type="date" class="form-input-compact" id="date_${manualIndex}" value="${escapeHtml(getKoreaToday())}" required style="width: 130px;">
|
||||
</td>
|
||||
<td>
|
||||
<select class="form-input-compact" id="project_${manualIndex}" style="width: 120px;" required>
|
||||
<option value="">프로젝트 선택</option>
|
||||
${projects.map(p => `<option value="${p.project_id}">${p.project_name}</option>`).join('')}
|
||||
${projects.map(p => `<option value="${escapeHtml(String(p.project_id))}">${escapeHtml(p.project_name)}</option>`).join('')}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<select class="form-input-compact" id="workType_${manualIndex}" style="width: 120px;" required onchange="loadTasksForWorkType('${manualIndex}')">
|
||||
<option value="">공정 선택</option>
|
||||
${workTypes.map(wt => `<option value="${wt.id}">${wt.name}</option>`).join('')}
|
||||
${workTypes.map(wt => `<option value="${escapeHtml(String(wt.id))}">${escapeHtml(wt.name)}</option>`).join('')}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
@@ -969,7 +969,7 @@ window.loadTasksForWorkType = async function(manualIndex) {
|
||||
taskSelect.disabled = false;
|
||||
taskSelect.innerHTML = `
|
||||
<option value="">작업 선택</option>
|
||||
${tasks.map(task => `<option value="${task.task_id}">${task.task_name}</option>`).join('')}
|
||||
${tasks.map(task => `<option value="${escapeHtml(String(task.task_id))}">${escapeHtml(task.task_name)}</option>`).join('')}
|
||||
`;
|
||||
} else {
|
||||
taskSelect.disabled = true;
|
||||
@@ -1022,12 +1022,17 @@ window.openWorkplaceMapForManual = async function(manualIndex) {
|
||||
const modal = document.getElementById('workplaceModal');
|
||||
const categoryList = document.getElementById('workplaceCategoryList');
|
||||
|
||||
categoryList.innerHTML = categories.map(cat => `
|
||||
<button type="button" class="btn btn-secondary" style="width: 100%; text-align: left;" onclick='selectWorkplaceCategory(${cat.category_id}, "${cat.category_name.replace(/"/g, '"')}", "${(cat.layout_image || '').replace(/"/g, '"')}")'>
|
||||
categoryList.innerHTML = categories.map(cat => {
|
||||
const safeId = parseInt(cat.category_id) || 0;
|
||||
const safeName = escapeHtml(cat.category_name);
|
||||
const safeImage = escapeHtml(cat.layout_image || '');
|
||||
return `
|
||||
<button type="button" class="btn btn-secondary" style="width: 100%; text-align: left;" onclick='selectWorkplaceCategory(${safeId}, "${safeName.replace(/"/g, '"')}", "${safeImage.replace(/"/g, '"')}")'>
|
||||
<span style="margin-right: 0.5rem;">🏭</span>
|
||||
${cat.category_name}
|
||||
${safeName}
|
||||
</button>
|
||||
`).join('');
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// 카테고리 선택 화면 표시
|
||||
document.getElementById('categorySelectionArea').style.display = 'block';
|
||||
@@ -1071,12 +1076,16 @@ window.selectWorkplaceCategory = async function(categoryId, categoryName, layout
|
||||
|
||||
// 리스트 항상 표시
|
||||
const workplaceListArea = document.getElementById('workplaceListArea');
|
||||
workplaceListArea.innerHTML = workplaces.map(wp => `
|
||||
<button type="button" id="workplace-${wp.workplace_id}" class="btn btn-secondary" style="width: 100%; text-align: left;" onclick='selectWorkplaceFromList(${wp.workplace_id}, "${wp.workplace_name.replace(/"/g, '"')}")'>
|
||||
workplaceListArea.innerHTML = workplaces.map(wp => {
|
||||
const safeId = parseInt(wp.workplace_id) || 0;
|
||||
const safeName = escapeHtml(wp.workplace_name);
|
||||
return `
|
||||
<button type="button" id="workplace-${safeId}" class="btn btn-secondary" style="width: 100%; text-align: left;" onclick='selectWorkplaceFromList(${safeId}, "${safeName.replace(/"/g, '"')}")'>
|
||||
<span style="margin-right: 0.5rem;">📍</span>
|
||||
${wp.workplace_name}
|
||||
${safeName}
|
||||
</button>
|
||||
`).join('');
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
} catch (error) {
|
||||
console.error('작업장소 로드 오류:', error);
|
||||
@@ -1270,8 +1279,8 @@ window.confirmWorkplaceSelection = function() {
|
||||
<span>작업장소 선택됨</span>
|
||||
</div>
|
||||
<div style="font-size: 0.8rem; color: #111827; font-weight: 500;">
|
||||
<div style="color: #6b7280; font-size: 0.7rem; margin-bottom: 2px;">🏭 ${selectedWorkplaceCategoryName}</div>
|
||||
<div>📍 ${selectedWorkplaceName}</div>
|
||||
<div style="color: #6b7280; font-size: 0.7rem; margin-bottom: 2px;">🏭 ${escapeHtml(selectedWorkplaceCategoryName)}</div>
|
||||
<div>📍 ${escapeHtml(selectedWorkplaceName)}</div>
|
||||
</div>
|
||||
`;
|
||||
displayDiv.style.background = '#ecfdf5';
|
||||
@@ -1482,49 +1491,49 @@ function renderCompletedReports(reports) {
|
||||
<div class="completed-report-card">
|
||||
<div class="report-header">
|
||||
<div>
|
||||
<h4>${report.worker_name || '작업자'}</h4>
|
||||
<h4>${escapeHtml(report.worker_name || '작업자')}</h4>
|
||||
${report.tbm_session_id ? '<span class="tbm-badge">TBM 연동</span>' : '<span class="manual-badge">수동 입력</span>'}
|
||||
</div>
|
||||
<span class="report-date">${formatDate(report.report_date)}</span>
|
||||
<span class="report-date">${escapeHtml(formatDate(report.report_date))}</span>
|
||||
</div>
|
||||
|
||||
<div class="report-info">
|
||||
<div class="info-row">
|
||||
<span class="label">프로젝트:</span>
|
||||
<span class="value">${report.project_name || '-'}</span>
|
||||
<span class="value">${escapeHtml(report.project_name || '-')}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">공정:</span>
|
||||
<span class="value">${report.work_type_name || '-'}</span>
|
||||
<span class="value">${escapeHtml(report.work_type_name || '-')}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">작업시간:</span>
|
||||
<span class="value">${report.total_hours || report.work_hours || 0}시간</span>
|
||||
<span class="value">${parseFloat(report.total_hours || report.work_hours || 0)}시간</span>
|
||||
</div>
|
||||
${report.regular_hours !== undefined && report.regular_hours !== null ? `
|
||||
<div class="info-row">
|
||||
<span class="label">정규 시간:</span>
|
||||
<span class="value">${report.regular_hours}시간</span>
|
||||
<span class="value">${parseFloat(report.regular_hours)}시간</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${report.error_hours && report.error_hours > 0 ? `
|
||||
<div class="info-row">
|
||||
<span class="label">부적합 처리:</span>
|
||||
<span class="value" style="color: #dc2626;">${report.error_hours}시간</span>
|
||||
<span class="value" style="color: #dc2626;">${parseFloat(report.error_hours)}시간</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">부적합 원인:</span>
|
||||
<span class="value">${report.error_type_name || '-'}</span>
|
||||
<span class="value">${escapeHtml(report.error_type_name || '-')}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="info-row">
|
||||
<span class="label">작성자:</span>
|
||||
<span class="value">${report.created_by_name || '-'}</span>
|
||||
<span class="value">${escapeHtml(report.created_by_name || '-')}</span>
|
||||
</div>
|
||||
${report.start_time && report.end_time ? `
|
||||
<div class="info-row">
|
||||
<span class="label">작업 시간:</span>
|
||||
<span class="value">${report.start_time} ~ ${report.end_time}</span>
|
||||
<span class="value">${escapeHtml(report.start_time)} ~ ${escapeHtml(report.end_time)}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
@@ -1972,10 +1981,10 @@ function addWorkEntry() {
|
||||
</div>
|
||||
<select class="form-select project-select" required>
|
||||
<option value="">프로젝트를 선택하세요</option>
|
||||
${projects.map(p => `<option value="${p.project_id}">${p.project_name}</option>`).join('')}
|
||||
${projects.map(p => `<option value="${escapeHtml(String(p.project_id))}">${escapeHtml(p.project_name)}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="form-field-group">
|
||||
<div class="form-field-label">
|
||||
<span class="form-field-icon">⚙️</span>
|
||||
@@ -1983,7 +1992,7 @@ function addWorkEntry() {
|
||||
</div>
|
||||
<select class="form-select work-type-select" required>
|
||||
<option value="">작업 유형을 선택하세요</option>
|
||||
${workTypes.map(wt => `<option value="${wt.id}">${wt.name}</option>`).join('')}
|
||||
${workTypes.map(wt => `<option value="${escapeHtml(String(wt.id))}">${escapeHtml(wt.name)}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1996,7 +2005,7 @@ function addWorkEntry() {
|
||||
</div>
|
||||
<select class="form-select work-status-select" required>
|
||||
<option value="">업무 상태를 선택하세요</option>
|
||||
${workStatusTypes.map(ws => `<option value="${ws.id}">${ws.name}</option>`).join('')}
|
||||
${workStatusTypes.map(ws => `<option value="${escapeHtml(String(ws.id))}">${escapeHtml(ws.name)}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2422,7 +2431,7 @@ function displayMyDailyWorkers(data, date) {
|
||||
|
||||
const headerHtml = `
|
||||
<div class="daily-workers-header">
|
||||
<h4>📊 내가 입력한 오늘(${date}) 작업 현황 - 총 ${totalWorkers}명, ${totalWorks}개 작업</h4>
|
||||
<h4>📊 내가 입력한 오늘(${escapeHtml(date)}) 작업 현황 - 총 ${parseInt(totalWorkers) || 0}명, ${parseInt(totalWorks) || 0}개 작업</h4>
|
||||
<button class="refresh-btn" onclick="refreshTodayWorkers()">
|
||||
🔄 새로고침
|
||||
</button>
|
||||
@@ -2436,12 +2445,12 @@ function displayMyDailyWorkers(data, date) {
|
||||
|
||||
// 개별 작업 항목들 (수정/삭제 버튼 포함)
|
||||
const individualWorksHtml = works.map((work) => {
|
||||
const projectName = work.project_name || '미지정';
|
||||
const workTypeName = work.work_type_name || '미지정';
|
||||
const workStatusName = work.work_status_name || '미지정';
|
||||
const workHours = work.work_hours || 0;
|
||||
const errorTypeName = work.error_type_name || null;
|
||||
const workId = work.id;
|
||||
const projectName = escapeHtml(work.project_name || '미지정');
|
||||
const workTypeName = escapeHtml(work.work_type_name || '미지정');
|
||||
const workStatusName = escapeHtml(work.work_status_name || '미지정');
|
||||
const workHours = parseFloat(work.work_hours || 0);
|
||||
const errorTypeName = work.error_type_name ? escapeHtml(work.error_type_name) : null;
|
||||
const workId = parseInt(work.id) || 0;
|
||||
|
||||
return `
|
||||
<div class="individual-work-item">
|
||||
@@ -2484,8 +2493,8 @@ function displayMyDailyWorkers(data, date) {
|
||||
return `
|
||||
<div class="worker-status-item">
|
||||
<div class="worker-header">
|
||||
<div class="worker-name">👤 ${workerName}</div>
|
||||
<div class="worker-total-hours">총 ${totalHours}시간</div>
|
||||
<div class="worker-name">👤 ${escapeHtml(workerName)}</div>
|
||||
<div class="worker-total-hours">총 ${parseFloat(totalHours)}시간</div>
|
||||
</div>
|
||||
<div class="individual-works-container">
|
||||
${individualWorksHtml}
|
||||
@@ -2981,8 +2990,8 @@ function renderInlineDefectList(index) {
|
||||
let html = `
|
||||
<div class="defect-issue-section">
|
||||
<div class="defect-issue-header">
|
||||
<span class="defect-issue-title">📋 ${workerWorkplaceName || '작업장소'} 관련 부적합</span>
|
||||
<span class="defect-issue-count">${nonconformityIssues.length}건</span>
|
||||
<span class="defect-issue-title">📋 ${escapeHtml(workerWorkplaceName || '작업장소')} 관련 부적합</span>
|
||||
<span class="defect-issue-count">${parseInt(nonconformityIssues.length) || 0}건</span>
|
||||
</div>
|
||||
<div class="defect-issue-list">
|
||||
`;
|
||||
@@ -2999,23 +3008,24 @@ function renderInlineDefectList(index) {
|
||||
itemText = itemText ? `${itemText} - ${issue.additional_description}` : issue.additional_description;
|
||||
}
|
||||
|
||||
const safeReportId = parseInt(issue.report_id) || 0;
|
||||
html += `
|
||||
<div class="defect-issue-item ${isSelected ? 'selected' : ''}" data-issue-id="${issue.report_id}">
|
||||
<div class="defect-issue-item ${isSelected ? 'selected' : ''}" data-issue-id="${safeReportId}">
|
||||
<div class="defect-issue-checkbox">
|
||||
<input type="checkbox"
|
||||
id="issueCheck_${index}_${issue.report_id}"
|
||||
id="issueCheck_${index}_${safeReportId}"
|
||||
${isSelected ? 'checked' : ''}
|
||||
onchange="toggleIssueDefect('${index}', ${issue.report_id}, this.checked)">
|
||||
onchange="toggleIssueDefect('${index}', ${safeReportId}, this.checked)">
|
||||
</div>
|
||||
<div class="defect-issue-info">
|
||||
<span class="defect-issue-category">${issue.issue_category_name || '부적합'}</span>
|
||||
<span class="defect-issue-item-name">${itemText || '-'}</span>
|
||||
<span class="defect-issue-location">${issue.workplace_name || issue.custom_location || ''}</span>
|
||||
<span class="defect-issue-category">${escapeHtml(issue.issue_category_name || '부적합')}</span>
|
||||
<span class="defect-issue-item-name">${escapeHtml(itemText || '-')}</span>
|
||||
<span class="defect-issue-location">${escapeHtml(issue.workplace_name || issue.custom_location || '')}</span>
|
||||
</div>
|
||||
<div class="defect-issue-time ${isSelected ? 'active' : ''}">
|
||||
<div class="defect-time-input ${isSelected ? '' : 'disabled'}"
|
||||
onclick="${isSelected ? `openIssueDefectTimePicker('${index}', ${issue.report_id})` : ''}">
|
||||
<span class="defect-time-value" id="issueDefectTime_${index}_${issue.report_id}">${defectHours}</span>
|
||||
onclick="${isSelected ? `openIssueDefectTimePicker('${index}', ${safeReportId})` : ''}">
|
||||
<span class="defect-time-value" id="issueDefectTime_${index}_${safeReportId}">${parseFloat(defectHours) || 0}</span>
|
||||
<span class="defect-time-unit">시간</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3052,7 +3062,7 @@ function renderInlineDefectList(index) {
|
||||
} else {
|
||||
// 이슈가 없으면 레거시 UI (error_types 선택)
|
||||
const noIssueMsg = workerWorkplaceName
|
||||
? `${workerWorkplaceName}에 신고된 부적합이 없습니다.`
|
||||
? `${escapeHtml(workerWorkplaceName)}에 신고된 부적합이 없습니다.`
|
||||
: '신고된 부적합이 없습니다.';
|
||||
listContainer.innerHTML = `
|
||||
<div class="defect-no-issues">
|
||||
|
||||
@@ -99,27 +99,27 @@ function renderEquipmentInfo() {
|
||||
<div class="eq-info-grid">
|
||||
<div class="eq-info-item">
|
||||
<span class="eq-info-label">관리번호</span>
|
||||
<span class="eq-info-value">${eq.equipment_code}</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">${eq.equipment_name}</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">${eq.model_name || '-'}</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">${eq.specifications || '-'}</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">${eq.manufacturer || '-'}</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">${eq.supplier || '-'}</span>
|
||||
<span class="eq-info-value">${escapeHtml(eq.supplier || '-')}</span>
|
||||
</div>
|
||||
<div class="eq-info-item">
|
||||
<span class="eq-info-label">구입일</span>
|
||||
@@ -131,11 +131,11 @@ function renderEquipmentInfo() {
|
||||
</div>
|
||||
<div class="eq-info-item">
|
||||
<span class="eq-info-label">시리얼번호</span>
|
||||
<span class="eq-info-value">${eq.serial_number || '-'}</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">${eq.equipment_type || '-'}</span>
|
||||
<span class="eq-info-value">${escapeHtml(eq.equipment_type || '-')}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -219,12 +219,17 @@ function renderPhotos(photos) {
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = photos.map(photo => `
|
||||
<div class="eq-photo-item" onclick="viewPhoto('${window.API_BASE_URL}${photo.photo_path}')">
|
||||
<img src="${window.API_BASE_URL}${photo.photo_path}" alt="${photo.description || '설비 사진'}">
|
||||
<button class="eq-photo-delete" onclick="event.stopPropagation(); deletePhoto(${photo.photo_id})">×</button>
|
||||
</div>
|
||||
`).join('');
|
||||
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})">×</button>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function openPhotoModal() {
|
||||
@@ -323,7 +328,8 @@ function openMoveModal() {
|
||||
const factorySelect = document.getElementById('moveFactorySelect');
|
||||
factorySelect.innerHTML = '<option value="">공장을 선택하세요</option>';
|
||||
factories.forEach(f => {
|
||||
factorySelect.innerHTML += `<option value="${f.category_id}">${f.category_name}</option>`;
|
||||
const safeCategoryId = parseInt(f.category_id) || 0;
|
||||
factorySelect.innerHTML += `<option value="${safeCategoryId}">${escapeHtml(f.category_name || '-')}</option>`;
|
||||
});
|
||||
|
||||
document.getElementById('moveWorkplaceSelect').innerHTML = '<option value="">작업장을 선택하세요</option>';
|
||||
@@ -354,7 +360,8 @@ async function loadMoveWorkplaces() {
|
||||
workplaces = response.data.data;
|
||||
workplaces.forEach(wp => {
|
||||
if (wp.map_image_url) {
|
||||
workplaceSelect.innerHTML += `<option value="${wp.workplace_id}">${wp.workplace_name}</option>`;
|
||||
const safeWorkplaceId = parseInt(wp.workplace_id) || 0;
|
||||
workplaceSelect.innerHTML += `<option value="${safeWorkplaceId}">${escapeHtml(wp.workplace_name || '-')}</option>`;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -475,7 +482,8 @@ function openRepairModal() {
|
||||
const select = document.getElementById('repairItemSelect');
|
||||
select.innerHTML = '<option value="">선택하세요</option>';
|
||||
repairCategories.forEach(item => {
|
||||
select.innerHTML += `<option value="${item.item_id}">${item.item_name}</option>`;
|
||||
const safeItemId = parseInt(item.item_id) || 0;
|
||||
select.innerHTML += `<option value="${safeItemId}">${escapeHtml(item.item_name || '-')}</option>`;
|
||||
});
|
||||
|
||||
document.getElementById('repairDescription').value = '';
|
||||
@@ -557,16 +565,20 @@ function renderRepairHistory(history) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = history.map(h => `
|
||||
<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">${h.item_name || '수리 요청'}</div>
|
||||
<div class="eq-history-detail">${h.description || '-'}</div>
|
||||
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>
|
||||
<span class="eq-history-status ${h.status}">${getRepairStatusLabel(h.status)}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function getRepairStatusLabel(status) {
|
||||
@@ -664,16 +676,17 @@ function renderExternalLogs(logs) {
|
||||
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">${log.destination || '외부'}</div>
|
||||
<div class="eq-history-detail">${log.reason || '-'}</div>
|
||||
<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(${log.log_id})">반입처리</button>` : ''}
|
||||
${!isReturned ? `<button class="eq-history-action" onclick="openReturnModal(${safeLogId})">반입처리</button>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
@@ -748,15 +761,15 @@ function renderMoveLogs(logs) {
|
||||
container.innerHTML = logs.map(log => {
|
||||
const typeLabel = log.move_type === 'temporary' ? '임시이동' : '복귀';
|
||||
const location = log.move_type === 'temporary'
|
||||
? `${log.to_workplace_name || '-'}`
|
||||
: `원위치 복귀`;
|
||||
? 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">${log.reason || '-'} (${log.moved_by_name || '시스템'})</div>
|
||||
<div class="eq-history-detail">${escapeHtml(log.reason || '-')} (${escapeHtml(log.moved_by_name || '시스템')})</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -135,9 +135,13 @@ function populateWorkplaceFilters() {
|
||||
const filterWorkplace = document.getElementById('filterWorkplace');
|
||||
const modalWorkplace = document.getElementById('workplaceId');
|
||||
|
||||
const workplaceOptions = workplaces.map(w =>
|
||||
`<option value="${w.workplace_id}">${w.category_name ? w.category_name + ' - ' : ''}${w.workplace_name}</option>`
|
||||
).join('');
|
||||
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;
|
||||
@@ -148,9 +152,10 @@ function populateTypeFilter() {
|
||||
const filterType = document.getElementById('filterType');
|
||||
if (!filterType) return;
|
||||
|
||||
const typeOptions = equipmentTypes.map(type =>
|
||||
`<option value="${type}">${type}</option>`
|
||||
).join('');
|
||||
const typeOptions = equipmentTypes.map(type => {
|
||||
const safeType = escapeHtml(type || '');
|
||||
return `<option value="${safeType}">${safeType}</option>`;
|
||||
}).join('');
|
||||
filterType.innerHTML = '<option value="">전체</option>' + typeOptions;
|
||||
}
|
||||
|
||||
@@ -189,33 +194,44 @@ function renderEquipmentList() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${equipments.map(eq => `
|
||||
<tr>
|
||||
<td class="eq-col-code">${eq.equipment_code || '-'}</td>
|
||||
<td class="eq-col-name" title="${eq.equipment_name || ''}">${eq.equipment_name || '-'}</td>
|
||||
<td class="eq-col-model" title="${eq.model_name || ''}">${eq.model_name || '-'}</td>
|
||||
<td class="eq-col-spec" title="${eq.specifications || ''}">${eq.specifications || '-'}</td>
|
||||
<td>${eq.manufacturer || '-'}</td>
|
||||
<td>${eq.supplier || '-'}</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-${eq.status}">
|
||||
${getStatusText(eq.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="eq-actions">
|
||||
<button class="eq-btn-action eq-btn-edit" onclick="editEquipment(${eq.equipment_id})" title="수정">
|
||||
✏️
|
||||
</button>
|
||||
<button class="eq-btn-action eq-btn-delete" onclick="deleteEquipment(${eq.equipment_id})" title="삭제">
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
${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>
|
||||
|
||||
@@ -146,11 +146,17 @@ function renderBasicInfo(d) {
|
||||
});
|
||||
};
|
||||
|
||||
const validTypes = ['nonconformity', 'safety'];
|
||||
const safeType = validTypes.includes(d.category_type) ? d.category_type : '';
|
||||
const reporterName = escapeHtml(d.reporter_full_name || d.reporter_name || '-');
|
||||
const locationText = escapeHtml(d.custom_location || d.workplace_name || '-');
|
||||
const factoryText = d.factory_name ? ` (${escapeHtml(d.factory_name)})` : '';
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="info-item">
|
||||
<div class="info-label">신고 유형</div>
|
||||
<div class="info-value">
|
||||
<span class="type-badge ${d.category_type}">${typeNames[d.category_type] || d.category_type}</span>
|
||||
<span class="type-badge ${safeType}">${typeNames[d.category_type] || escapeHtml(d.category_type || '-')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
@@ -159,11 +165,11 @@ function renderBasicInfo(d) {
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">신고자</div>
|
||||
<div class="info-value">${d.reporter_full_name || d.reporter_name || '-'}</div>
|
||||
<div class="info-value">${reporterName}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">위치</div>
|
||||
<div class="info-value">${d.custom_location || d.workplace_name || '-'}${d.factory_name ? ` (${d.factory_name})` : ''}</div>
|
||||
<div class="info-value">${locationText}${factoryText}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -174,17 +180,20 @@ function renderBasicInfo(d) {
|
||||
function renderIssueContent(d) {
|
||||
const container = document.getElementById('issueContent');
|
||||
|
||||
const validSeverities = ['critical', 'high', 'medium', 'low'];
|
||||
const safeSeverity = validSeverities.includes(d.severity) ? d.severity : '';
|
||||
|
||||
let html = `
|
||||
<div class="info-grid" style="margin-bottom: 1rem;">
|
||||
<div class="info-item">
|
||||
<div class="info-label">카테고리</div>
|
||||
<div class="info-value">${d.issue_category_name || '-'}</div>
|
||||
<div class="info-value">${escapeHtml(d.issue_category_name || '-')}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">항목</div>
|
||||
<div class="info-value">
|
||||
${d.issue_item_name || '-'}
|
||||
${d.severity ? `<span class="severity-badge ${d.severity}">${severityNames[d.severity]}</span>` : ''}
|
||||
${escapeHtml(d.issue_item_name || '-')}
|
||||
${d.severity ? `<span class="severity-badge ${safeSeverity}">${severityNames[d.severity] || escapeHtml(d.severity)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -262,11 +271,11 @@ function renderProcessInfo(d) {
|
||||
html += `
|
||||
<div class="info-item">
|
||||
<div class="info-label">담당자</div>
|
||||
<div class="info-value">${d.assigned_full_name || d.assigned_user_name || '-'}</div>
|
||||
<div class="info-value">${escapeHtml(d.assigned_full_name || d.assigned_user_name || '-')}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">담당 부서</div>
|
||||
<div class="info-value">${d.assigned_department || '-'}</div>
|
||||
<div class="info-value">${escapeHtml(d.assigned_department || '-')}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -279,7 +288,7 @@ function renderProcessInfo(d) {
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">처리자</div>
|
||||
<div class="info-value">${d.resolved_by_name || '-'}</div>
|
||||
<div class="info-value">${escapeHtml(d.resolved_by_name || '-')}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -402,10 +411,10 @@ function renderStatusTimeline(logs) {
|
||||
container.innerHTML = logs.map(log => `
|
||||
<div class="timeline-item">
|
||||
<div class="timeline-status">
|
||||
${log.previous_status ? `${statusNames[log.previous_status]} → ` : ''}${statusNames[log.new_status]}
|
||||
${log.previous_status ? `${statusNames[log.previous_status] || escapeHtml(log.previous_status)} → ` : ''}${statusNames[log.new_status] || escapeHtml(log.new_status)}
|
||||
</div>
|
||||
<div class="timeline-meta">
|
||||
${log.changed_by_full_name || log.changed_by_name} | ${formatDate(log.changed_at)}
|
||||
${escapeHtml(log.changed_by_full_name || log.changed_by_name || '-')} | ${formatDate(log.changed_at)}
|
||||
${log.change_reason ? `<br><small>${escapeHtml(log.change_reason)}</small>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
@@ -530,7 +539,8 @@ async function openAssignModal() {
|
||||
|
||||
if (data.success && data.data) {
|
||||
data.data.forEach(user => {
|
||||
select.innerHTML += `<option value="${user.user_id}">${user.name} (${user.username})</option>`;
|
||||
const safeUserId = parseInt(user.user_id) || 0;
|
||||
select.innerHTML += `<option value="${safeUserId}">${escapeHtml(user.name || '-')} (${escapeHtml(user.username || '-')})</option>`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -490,9 +490,12 @@ function showWorkSelectionModal(workers, visitors) {
|
||||
workers.forEach(w => {
|
||||
const option = document.createElement('div');
|
||||
option.className = 'work-option';
|
||||
const safeTaskName = escapeHtml(w.task_name || '작업');
|
||||
const safeProjectName = escapeHtml(w.project_name || '');
|
||||
const memberCount = parseInt(w.member_count) || 0;
|
||||
option.innerHTML = `
|
||||
<div class="work-option-title">TBM: ${w.task_name || '작업'}</div>
|
||||
<div class="work-option-desc">${w.project_name || ''} - ${w.member_count || 0}명</div>
|
||||
<div class="work-option-title">TBM: ${safeTaskName}</div>
|
||||
<div class="work-option-desc">${safeProjectName} - ${memberCount}명</div>
|
||||
`;
|
||||
option.onclick = () => {
|
||||
selectedTbmSessionId = w.session_id;
|
||||
@@ -507,9 +510,12 @@ function showWorkSelectionModal(workers, visitors) {
|
||||
visitors.forEach(v => {
|
||||
const option = document.createElement('div');
|
||||
option.className = 'work-option';
|
||||
const safeCompany = escapeHtml(v.visitor_company || '-');
|
||||
const safePurpose = escapeHtml(v.purpose_name || '방문');
|
||||
const visitorCount = parseInt(v.visitor_count) || 0;
|
||||
option.innerHTML = `
|
||||
<div class="work-option-title">출입: ${v.visitor_company}</div>
|
||||
<div class="work-option-desc">${v.purpose_name || '방문'} - ${v.visitor_count || 0}명</div>
|
||||
<div class="work-option-title">출입: ${safeCompany}</div>
|
||||
<div class="work-option-desc">${safePurpose} - ${visitorCount}명</div>
|
||||
`;
|
||||
option.onclick = () => {
|
||||
selectedVisitRequestId = v.request_id;
|
||||
@@ -540,20 +546,20 @@ function updateLocationInfo() {
|
||||
|
||||
if (useCustom && customLocation) {
|
||||
infoBox.classList.remove('empty');
|
||||
infoBox.innerHTML = `<strong>선택된 위치:</strong> ${customLocation}`;
|
||||
infoBox.innerHTML = `<strong>선택된 위치:</strong> ${escapeHtml(customLocation)}`;
|
||||
} else if (selectedWorkplaceName) {
|
||||
infoBox.classList.remove('empty');
|
||||
let html = `<strong>선택된 위치:</strong> ${selectedWorkplaceName}`;
|
||||
let html = `<strong>선택된 위치:</strong> ${escapeHtml(selectedWorkplaceName)}`;
|
||||
|
||||
if (selectedTbmSessionId) {
|
||||
const worker = todayWorkers.find(w => w.session_id === selectedTbmSessionId);
|
||||
if (worker) {
|
||||
html += `<br><span style="color: var(--primary-600);">연결 작업: ${worker.task_name} (TBM)</span>`;
|
||||
html += `<br><span style="color: var(--primary-600);">연결 작업: ${escapeHtml(worker.task_name || '-')} (TBM)</span>`;
|
||||
}
|
||||
} else if (selectedVisitRequestId) {
|
||||
const visitor = todayVisitors.find(v => v.request_id === selectedVisitRequestId);
|
||||
if (visitor) {
|
||||
html += `<br><span style="color: var(--primary-600);">연결 작업: ${visitor.visitor_company} (출입)</span>`;
|
||||
html += `<br><span style="color: var(--primary-600);">연결 작업: ${escapeHtml(visitor.visitor_company || '-')} (출입)</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -121,17 +121,18 @@ function renderIssues(issues) {
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
// 위치 정보
|
||||
let location = issue.custom_location || '';
|
||||
// 위치 정보 (escaped)
|
||||
let location = escapeHtml(issue.custom_location || '');
|
||||
if (issue.factory_name) {
|
||||
location = issue.factory_name;
|
||||
location = escapeHtml(issue.factory_name);
|
||||
if (issue.workplace_name) {
|
||||
location += ` - ${issue.workplace_name}`;
|
||||
location += ` - ${escapeHtml(issue.workplace_name)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 신고 제목 (항목명 또는 카테고리명)
|
||||
const title = issue.issue_item_name || issue.issue_category_name || '부적합 신고';
|
||||
const title = escapeHtml(issue.issue_item_name || issue.issue_category_name || '부적합 신고');
|
||||
const categoryName = escapeHtml(issue.issue_category_name || '부적합');
|
||||
|
||||
// 사진 목록
|
||||
const photos = [
|
||||
@@ -142,15 +143,22 @@ function renderIssues(issues) {
|
||||
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(${issue.report_id})">
|
||||
<div class="issue-card" onclick="viewIssue(${safeReportId})">
|
||||
<div class="issue-header">
|
||||
<span class="issue-id">#${issue.report_id}</span>
|
||||
<span class="issue-status ${issue.status}">${STATUS_LABELS[issue.status] || issue.status}</span>
|
||||
<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">${issue.issue_category_name || '부적합'}</span>
|
||||
<span class="issue-category-badge">${categoryName}</span>
|
||||
${title}
|
||||
</div>
|
||||
|
||||
@@ -160,7 +168,7 @@ function renderIssues(issues) {
|
||||
<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>
|
||||
${issue.reporter_full_name || issue.reporter_name}
|
||||
${reporterName}
|
||||
</span>
|
||||
<span class="issue-meta-item">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -180,7 +188,7 @@ function renderIssues(issues) {
|
||||
${location}
|
||||
</span>
|
||||
` : ''}
|
||||
${issue.assigned_full_name ? `
|
||||
${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"/>
|
||||
@@ -188,7 +196,7 @@ function renderIssues(issues) {
|
||||
<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>
|
||||
담당: ${issue.assigned_full_name}
|
||||
담당: ${assignedName}
|
||||
</span>
|
||||
` : ''}
|
||||
</div>
|
||||
@@ -196,7 +204,7 @@ function renderIssues(issues) {
|
||||
${photos.length > 0 ? `
|
||||
<div class="issue-photos">
|
||||
${photos.slice(0, 3).map(p => `
|
||||
<img src="${baseUrl}${p}" alt="신고 사진" loading="lazy">
|
||||
<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>
|
||||
|
||||
@@ -165,26 +165,35 @@ function renderProjects() {
|
||||
'completed': { icon: '✅', text: '완료', color: '#3b82f6' },
|
||||
'cancelled': { icon: '❌', text: '취소', color: '#ef4444' }
|
||||
};
|
||||
|
||||
const status = statusMap[project.project_status] || statusMap['active'];
|
||||
|
||||
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 || '-');
|
||||
|
||||
console.log('🎨 카드 렌더링:', {
|
||||
project_id: project.project_id,
|
||||
project_name: project.project_name,
|
||||
is_active_raw: project.is_active,
|
||||
isInactive: isInactive
|
||||
});
|
||||
|
||||
|
||||
return `
|
||||
<div class="project-card ${isInactive ? 'inactive' : ''}" onclick="editProject(${project.project_id})">
|
||||
<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">${project.job_no || 'Job No. 없음'}</div>
|
||||
<div class="project-job-no">${safeJobNo}</div>
|
||||
<h3 class="project-name">
|
||||
${project.project_name}
|
||||
${safeProjectName}
|
||||
${isInactive ? '<span class="inactive-label">(비활성)</span>' : ''}
|
||||
</h3>
|
||||
<div class="project-meta">
|
||||
@@ -202,20 +211,20 @@ function renderProjects() {
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span class="meta-label">PM</span>
|
||||
<span class="meta-value">${project.pm || '-'}</span>
|
||||
<span class="meta-value">${safePm}</span>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span class="meta-label">현장</span>
|
||||
<span class="meta-value">${project.site || '-'}</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(${project.project_id})" title="수정">
|
||||
<button class="btn-edit" onclick="event.stopPropagation(); editProject(${safeProjectId})" title="수정">
|
||||
✏️ 수정
|
||||
</button>
|
||||
<button class="btn-delete" onclick="event.stopPropagation(); confirmDeleteProject(${project.project_id})" title="삭제">
|
||||
<button class="btn-delete" onclick="event.stopPropagation(); confirmDeleteProject(${safeProjectId})" title="삭제">
|
||||
🗑️ 삭제
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -121,17 +121,18 @@ function renderIssues(issues) {
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
// 위치 정보
|
||||
let location = issue.custom_location || '';
|
||||
// 위치 정보 (escaped)
|
||||
let location = escapeHtml(issue.custom_location || '');
|
||||
if (issue.factory_name) {
|
||||
location = issue.factory_name;
|
||||
location = escapeHtml(issue.factory_name);
|
||||
if (issue.workplace_name) {
|
||||
location += ` - ${issue.workplace_name}`;
|
||||
location += ` - ${escapeHtml(issue.workplace_name)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 신고 제목 (항목명 또는 카테고리명)
|
||||
const title = issue.issue_item_name || issue.issue_category_name || '안전 신고';
|
||||
const title = escapeHtml(issue.issue_item_name || issue.issue_category_name || '안전 신고');
|
||||
const categoryName = escapeHtml(issue.issue_category_name || '안전');
|
||||
|
||||
// 사진 목록
|
||||
const photos = [
|
||||
@@ -142,15 +143,22 @@ function renderIssues(issues) {
|
||||
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(${issue.report_id})">
|
||||
<div class="issue-card" onclick="viewIssue(${safeReportId})">
|
||||
<div class="issue-header">
|
||||
<span class="issue-id">#${issue.report_id}</span>
|
||||
<span class="issue-status ${issue.status}">${STATUS_LABELS[issue.status] || issue.status}</span>
|
||||
<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">${issue.issue_category_name || '안전'}</span>
|
||||
<span class="issue-category-badge">${categoryName}</span>
|
||||
${title}
|
||||
</div>
|
||||
|
||||
@@ -160,7 +168,7 @@ function renderIssues(issues) {
|
||||
<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>
|
||||
${issue.reporter_full_name || issue.reporter_name}
|
||||
${reporterName}
|
||||
</span>
|
||||
<span class="issue-meta-item">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -180,7 +188,7 @@ function renderIssues(issues) {
|
||||
${location}
|
||||
</span>
|
||||
` : ''}
|
||||
${issue.assigned_full_name ? `
|
||||
${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"/>
|
||||
@@ -188,7 +196,7 @@ function renderIssues(issues) {
|
||||
<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>
|
||||
담당: ${issue.assigned_full_name}
|
||||
담당: ${assignedName}
|
||||
</span>
|
||||
` : ''}
|
||||
</div>
|
||||
@@ -196,7 +204,7 @@ function renderIssues(issues) {
|
||||
${photos.length > 0 ? `
|
||||
<div class="issue-photos">
|
||||
${photos.slice(0, 3).map(p => `
|
||||
<img src="${baseUrl}${p}" alt="신고 사진" loading="lazy">
|
||||
<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>
|
||||
|
||||
@@ -100,14 +100,15 @@ function renderWorkTypeTabs() {
|
||||
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="${workType.id}"
|
||||
onclick="switchWorkType(${workType.id})"
|
||||
data-work-type="${safeId}"
|
||||
onclick="switchWorkType(${safeId})"
|
||||
style="position: relative; padding-right: 3rem;">
|
||||
<span class="tab-icon">🔧</span>
|
||||
${workType.name} (${count})
|
||||
<span onclick="event.stopPropagation(); editWorkType(${workType.id});"
|
||||
${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="공정 수정">
|
||||
✏️
|
||||
@@ -157,35 +158,36 @@ 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(${task.task_id})">
|
||||
<div class="code-card" onclick="editTask(${safeTaskId})">
|
||||
<div class="code-card-header">
|
||||
<h3 class="code-name">${task.task_name}</h3>
|
||||
<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">${task.work_type_name || '-'}</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">${task.category}</span>
|
||||
<span class="info-value">${escapeHtml(task.category)}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
${task.description ? `
|
||||
<div class="code-description">
|
||||
${task.description}
|
||||
${escapeHtml(task.description)}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="code-meta">
|
||||
<span>등록: ${formatDate(task.created_at)}</span>
|
||||
<span>등록: ${escapeHtml(formatDate(task.created_at))}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -275,7 +277,7 @@ function populateWorkTypeSelect() {
|
||||
|
||||
select.innerHTML = '<option value="">공정 선택...</option>' +
|
||||
workTypes.map(wt => `
|
||||
<option value="${wt.id}">${wt.name}${wt.category ? ' (' + wt.category + ')' : ''}</option>
|
||||
<option value="${escapeHtml(String(wt.id))}">${escapeHtml(wt.name)}${wt.category ? ' (' + escapeHtml(wt.category) + ')' : ''}</option>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
|
||||
@@ -493,13 +493,14 @@ function createSessionCard(session) {
|
||||
}[session.status] || '';
|
||||
|
||||
// 작업 책임자 표시 (leader_name이 있으면 표시, 없으면 created_by_name 표시)
|
||||
const leaderName = session.leader_name || session.created_by_name || '작업 책임자';
|
||||
const leaderRole = session.leader_name
|
||||
const leaderName = escapeHtml(session.leader_name || session.created_by_name || '작업 책임자');
|
||||
const leaderRole = escapeHtml(session.leader_name
|
||||
? (session.leader_job_type || '작업자')
|
||||
: '관리자';
|
||||
: '관리자');
|
||||
const safeSessionId = parseInt(session.session_id) || 0;
|
||||
|
||||
return `
|
||||
<div class="tbm-session-card" onclick="viewTbmSession(${session.session_id})">
|
||||
<div class="tbm-session-card" onclick="viewTbmSession(${safeSessionId})">
|
||||
<div class="tbm-card-header">
|
||||
<div class="tbm-card-header-top">
|
||||
<div>
|
||||
@@ -512,7 +513,7 @@ function createSessionCard(session) {
|
||||
</div>
|
||||
<div class="tbm-card-date">
|
||||
<span>📅</span>
|
||||
${formatDate(session.session_date)} ${session.start_time ? '| ' + session.start_time : ''}
|
||||
${escapeHtml(formatDate(session.session_date))} ${session.start_time ? '| ' + escapeHtml(session.start_time) : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -520,29 +521,29 @@ function createSessionCard(session) {
|
||||
<div class="tbm-card-info-grid">
|
||||
<div class="tbm-card-info-item">
|
||||
<span class="tbm-card-info-label">프로젝트</span>
|
||||
<span class="tbm-card-info-value">${session.project_name || '-'}</span>
|
||||
<span class="tbm-card-info-value">${escapeHtml(session.project_name || '-')}</span>
|
||||
</div>
|
||||
<div class="tbm-card-info-item">
|
||||
<span class="tbm-card-info-label">공정</span>
|
||||
<span class="tbm-card-info-value">${session.work_type_name || '-'}</span>
|
||||
<span class="tbm-card-info-value">${escapeHtml(session.work_type_name || '-')}</span>
|
||||
</div>
|
||||
<div class="tbm-card-info-item">
|
||||
<span class="tbm-card-info-label">작업장</span>
|
||||
<span class="tbm-card-info-value">${session.work_location || '-'}</span>
|
||||
<span class="tbm-card-info-value">${escapeHtml(session.work_location || '-')}</span>
|
||||
</div>
|
||||
<div class="tbm-card-info-item">
|
||||
<span class="tbm-card-info-label">팀원</span>
|
||||
<span class="tbm-card-info-value">${session.team_member_count || 0}명</span>
|
||||
<span class="tbm-card-info-value">${parseInt(session.team_member_count) || 0}명</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${session.status === 'draft' ? `
|
||||
<div class="tbm-card-footer">
|
||||
<button class="tbm-btn tbm-btn-primary tbm-btn-sm" onclick="event.stopPropagation(); openTeamCompositionModal(${session.session_id})">
|
||||
<button class="tbm-btn tbm-btn-primary tbm-btn-sm" onclick="event.stopPropagation(); openTeamCompositionModal(${safeSessionId})">
|
||||
👥 팀 구성
|
||||
</button>
|
||||
<button class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="event.stopPropagation(); openSafetyCheckModal(${session.session_id})">
|
||||
<button class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="event.stopPropagation(); openSafetyCheckModal(${safeSessionId})">
|
||||
✓ 안전 체크
|
||||
</button>
|
||||
</div>
|
||||
@@ -604,8 +605,8 @@ function populateLeaderSelect() {
|
||||
// 작업자와 연결된 경우: 자동으로 선택하고 비활성화
|
||||
const worker = allWorkers.find(w => w.worker_id === currentUser.worker_id);
|
||||
if (worker) {
|
||||
const jobTypeText = worker.job_type ? ` (${worker.job_type})` : '';
|
||||
leaderSelect.innerHTML = `<option value="${worker.worker_id}" selected>${worker.worker_name}${jobTypeText}</option>`;
|
||||
const jobTypeText = worker.job_type ? ` (${escapeHtml(worker.job_type)})` : '';
|
||||
leaderSelect.innerHTML = `<option value="${escapeHtml(worker.worker_id)}" selected>${escapeHtml(worker.worker_name)}${jobTypeText}</option>`;
|
||||
leaderSelect.disabled = true;
|
||||
console.log('✅ 입력자 자동 설정:', worker.worker_name);
|
||||
} else {
|
||||
@@ -621,8 +622,8 @@ function populateLeaderSelect() {
|
||||
|
||||
leaderSelect.innerHTML = '<option value="">입력자 선택...</option>' +
|
||||
leaders.map(w => {
|
||||
const jobTypeText = w.job_type ? ` (${w.job_type})` : '';
|
||||
return `<option value="${w.worker_id}">${w.worker_name}${jobTypeText}</option>`;
|
||||
const jobTypeText = w.job_type ? ` (${escapeHtml(w.job_type)})` : '';
|
||||
return `<option value="${escapeHtml(w.worker_id)}">${escapeHtml(w.worker_name)}${jobTypeText}</option>`;
|
||||
}).join('');
|
||||
leaderSelect.disabled = false;
|
||||
console.log('✅ 관리자: 입력자 선택 가능');
|
||||
@@ -636,7 +637,7 @@ function populateProjectSelect() {
|
||||
|
||||
projectSelect.innerHTML = '<option value="">프로젝트 선택...</option>' +
|
||||
allProjects.map(p => `
|
||||
<option value="${p.project_id}">${p.project_name} (${p.job_no})</option>
|
||||
<option value="${escapeHtml(p.project_id)}">${escapeHtml(p.project_name)} (${escapeHtml(p.job_no)})</option>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
@@ -647,7 +648,7 @@ function populateWorkTypeSelect() {
|
||||
|
||||
workTypeSelect.innerHTML = '<option value="">공정 선택...</option>' +
|
||||
allWorkTypes.map(wt => `
|
||||
<option value="${wt.id}">${wt.name}${wt.category ? ' (' + wt.category + ')' : ''}</option>
|
||||
<option value="${escapeHtml(wt.id)}">${escapeHtml(wt.name)}${wt.category ? ' (' + escapeHtml(wt.category) + ')' : ''}</option>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
@@ -658,7 +659,7 @@ function populateWorkplaceSelect() {
|
||||
|
||||
workLocationSelect.innerHTML = '<option value="">작업장 선택...</option>' +
|
||||
allWorkplaces.map(wp => `
|
||||
<option value="${wp.workplace_name}">${wp.workplace_name}${wp.location ? ' - ' + wp.location : ''}</option>
|
||||
<option value="${escapeHtml(wp.workplace_name)}">${escapeHtml(wp.workplace_name)}${wp.location ? ' - ' + escapeHtml(wp.location) : ''}</option>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
@@ -683,7 +684,7 @@ function loadTasksByWorkType() {
|
||||
taskSelect.disabled = false;
|
||||
taskSelect.innerHTML = '<option value="">작업 선택...</option>' +
|
||||
filteredTasks.map(task => `
|
||||
<option value="${task.task_id}">${task.task_name}</option>
|
||||
<option value="${escapeHtml(String(task.task_id))}">${escapeHtml(task.task_name)}</option>
|
||||
`).join('');
|
||||
|
||||
if (filteredTasks.length === 0) {
|
||||
@@ -872,12 +873,12 @@ function renderWorkerTaskList() {
|
||||
<!-- 작업자 헤더 -->
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; padding-bottom: 0.75rem; border-bottom: 1px solid #e5e7eb;">
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<span style="font-weight: 600; font-size: 1.1rem; color: #1f2937;">👤 ${workerData.worker_name}</span>
|
||||
<span style="font-weight: 600; font-size: 1.1rem; color: #1f2937;">👤 ${escapeHtml(workerData.worker_name)}</span>
|
||||
<span style="padding: 0.125rem 0.5rem; background: #dbeafe; color: #1e40af; border-radius: 0.25rem; font-size: 0.75rem;">
|
||||
${workerData.job_type || '작업자'}
|
||||
${escapeHtml(workerData.job_type || '작업자')}
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" onclick="removeWorkerFromList(${workerIndex})" class="btn btn-sm btn-danger">
|
||||
<button type="button" onclick="removeWorkerFromList(${parseInt(workerIndex) || 0})" class="btn btn-sm btn-danger">
|
||||
<span style="font-size: 1rem;">✕ 작업자 제거</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -887,7 +888,7 @@ function renderWorkerTaskList() {
|
||||
|
||||
<!-- 작업 추가 버튼 -->
|
||||
<div style="margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px dashed #d1d5db;">
|
||||
<button type="button" onclick="addTaskLineToWorker(${workerIndex})" class="btn btn-sm btn-secondary" style="width: 100%;">
|
||||
<button type="button" onclick="addTaskLineToWorker(${parseInt(workerIndex) || 0})" class="btn btn-sm btn-secondary" style="width: 100%;">
|
||||
<span style="font-size: 1.2rem; margin-right: 0.25rem;">+</span>
|
||||
이 작업자의 추가 작업 등록
|
||||
</button>
|
||||
@@ -902,12 +903,14 @@ function renderTaskLine(workerData, workerIndex, taskLine, taskIndex) {
|
||||
const project = allProjects.find(p => p.project_id === taskLine.project_id);
|
||||
const workType = allWorkTypes.find(wt => wt.id === taskLine.work_type_id);
|
||||
const task = allTasks.find(t => t.task_id === taskLine.task_id);
|
||||
const safeWorkerIndex = parseInt(workerIndex) || 0;
|
||||
const safeTaskIndex = parseInt(taskIndex) || 0;
|
||||
|
||||
const projectText = project ? project.project_name : '프로젝트 선택';
|
||||
const workTypeText = workType ? workType.name : '공정 선택 *';
|
||||
const taskText = task ? task.task_name : '작업 선택 *';
|
||||
const projectText = escapeHtml(project ? project.project_name : '프로젝트 선택');
|
||||
const workTypeText = escapeHtml(workType ? workType.name : '공정 선택 *');
|
||||
const taskText = escapeHtml(task ? task.task_name : '작업 선택 *');
|
||||
const workplaceText = taskLine.workplace_name
|
||||
? `${taskLine.workplace_category_name || ''} → ${taskLine.workplace_name}`
|
||||
? escapeHtml(`${taskLine.workplace_category_name || ''} → ${taskLine.workplace_name}`)
|
||||
: '작업장 선택 *';
|
||||
|
||||
return `
|
||||
@@ -915,7 +918,7 @@ function renderTaskLine(workerData, workerIndex, taskLine, taskIndex) {
|
||||
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.5rem; margin-bottom: 0.5rem;">
|
||||
<!-- 프로젝트 선택 -->
|
||||
<button type="button"
|
||||
onclick="openItemSelect('project', ${workerIndex}, ${taskIndex})"
|
||||
onclick="openItemSelect('project', ${safeWorkerIndex}, ${safeTaskIndex})"
|
||||
class="btn btn-sm ${project ? 'btn-primary' : 'btn-secondary'}"
|
||||
style="text-align: left; justify-content: flex-start; font-size: 0.875rem;">
|
||||
📁 ${projectText}
|
||||
@@ -923,7 +926,7 @@ function renderTaskLine(workerData, workerIndex, taskLine, taskIndex) {
|
||||
|
||||
<!-- 작업장 선택 -->
|
||||
<button type="button"
|
||||
onclick="openWorkplaceSelect(${workerIndex}, ${taskIndex})"
|
||||
onclick="openWorkplaceSelect(${safeWorkerIndex}, ${safeTaskIndex})"
|
||||
class="btn btn-sm ${taskLine.workplace_id ? 'btn-primary' : 'btn-secondary'}"
|
||||
style="text-align: left; justify-content: flex-start; font-size: 0.875rem;">
|
||||
📍 ${workplaceText}
|
||||
@@ -931,7 +934,7 @@ function renderTaskLine(workerData, workerIndex, taskLine, taskIndex) {
|
||||
|
||||
<!-- 공정 선택 -->
|
||||
<button type="button"
|
||||
onclick="openItemSelect('workType', ${workerIndex}, ${taskIndex})"
|
||||
onclick="openItemSelect('workType', ${safeWorkerIndex}, ${safeTaskIndex})"
|
||||
class="btn btn-sm ${workType ? 'btn-primary' : 'btn-secondary'}"
|
||||
style="text-align: left; justify-content: flex-start; font-size: 0.875rem;">
|
||||
⚙️ ${workTypeText}
|
||||
@@ -939,7 +942,7 @@ function renderTaskLine(workerData, workerIndex, taskLine, taskIndex) {
|
||||
|
||||
<!-- 작업 선택 -->
|
||||
<button type="button"
|
||||
onclick="openItemSelect('task', ${workerIndex}, ${taskIndex})"
|
||||
onclick="openItemSelect('task', ${safeWorkerIndex}, ${safeTaskIndex})"
|
||||
class="btn btn-sm ${task ? 'btn-primary' : 'btn-secondary'}"
|
||||
style="text-align: left; justify-content: flex-start; font-size: 0.875rem;"
|
||||
${!taskLine.work_type_id ? 'disabled' : ''}>
|
||||
@@ -949,7 +952,7 @@ function renderTaskLine(workerData, workerIndex, taskLine, taskIndex) {
|
||||
|
||||
<!-- 작업 라인 제거 버튼 -->
|
||||
${workerData.tasks.length > 1 ? `
|
||||
<button type="button" onclick="removeTaskLine(${workerIndex}, ${taskIndex})"
|
||||
<button type="button" onclick="removeTaskLine(${safeWorkerIndex}, ${safeTaskIndex})"
|
||||
class="btn btn-sm btn-danger" style="width: 100%; font-size: 0.8rem;">
|
||||
<span style="margin-right: 0.25rem;">−</span> 이 작업 라인 제거
|
||||
</button>
|
||||
@@ -971,16 +974,17 @@ function openWorkerSelectionModal() {
|
||||
|
||||
workerCardGrid.innerHTML = allWorkers.map(worker => {
|
||||
const isAdded = addedWorkerIds.has(worker.worker_id);
|
||||
const safeWorkerId = parseInt(worker.worker_id) || 0;
|
||||
return `
|
||||
<div id="worker-card-${worker.worker_id}"
|
||||
onclick="toggleWorkerSelection(${worker.worker_id})"
|
||||
<div id="worker-card-${safeWorkerId}"
|
||||
onclick="toggleWorkerSelection(${safeWorkerId})"
|
||||
style="padding: 1rem; border: 2px solid ${isAdded ? '#d1d5db' : '#e5e7eb'}; border-radius: 0.5rem; cursor: ${isAdded ? 'not-allowed' : 'pointer'}; background: ${isAdded ? '#f3f4f6' : 'white'}; opacity: ${isAdded ? '0.5' : '1'}; transition: all 0.2s;">
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;">
|
||||
${isAdded ? '✓' : '☐'}
|
||||
<span style="font-weight: 600; font-size: 0.95rem;">${worker.worker_name}</span>
|
||||
<span style="font-weight: 600; font-size: 0.95rem;">${escapeHtml(worker.worker_name)}</span>
|
||||
</div>
|
||||
<div style="font-size: 0.8rem; color: #6b7280;">
|
||||
${worker.job_type || '작업자'}${worker.department ? ' · ' + worker.department : ''}
|
||||
${escapeHtml(worker.job_type || '작업자')}${worker.department ? ' · ' + escapeHtml(worker.department) : ''}
|
||||
</div>
|
||||
${isAdded ? '<div style="font-size: 0.75rem; color: #9ca3af; margin-top: 0.25rem;">이미 추가됨</div>' : ''}
|
||||
</div>
|
||||
|
||||
@@ -83,25 +83,31 @@ function renderVacationRequests(requests, containerId, showActions = false, acti
|
||||
</thead>
|
||||
<tbody>
|
||||
${requests.map(request => {
|
||||
const statusClass = request.status === 'pending' ? 'status-pending' :
|
||||
request.status === 'approved' ? 'status-approved' : 'status-rejected';
|
||||
const statusText = request.status === 'pending' ? '대기' :
|
||||
request.status === 'approved' ? '승인' : '거부';
|
||||
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>${request.worker_name || '알 수 없음'}</strong></td>
|
||||
<td>${request.vacation_type_name || request.type_name || '알 수 없음'}</td>
|
||||
<td>${request.start_date}</td>
|
||||
<td>${request.end_date}</td>
|
||||
<td>${request.days_used}일</td>
|
||||
<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="${request.reason || '-'}">
|
||||
${request.reason || '-'}
|
||||
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${reasonText}">
|
||||
${reasonText}
|
||||
</td>
|
||||
${showActions ? renderActionButtons(request, actionType) : ''}
|
||||
</tr>
|
||||
@@ -118,14 +124,15 @@ function renderVacationRequests(requests, containerId, showActions = false, acti
|
||||
* 액션 버튼 렌더링
|
||||
*/
|
||||
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(${request.request_id})" title="승인">
|
||||
<button class="btn-small btn-success" onclick="approveVacationRequest(${safeRequestId})" title="승인">
|
||||
✓
|
||||
</button>
|
||||
<button class="btn-small btn-danger" onclick="rejectVacationRequest(${request.request_id})" title="거부">
|
||||
<button class="btn-small btn-danger" onclick="rejectVacationRequest(${safeRequestId})" title="거부">
|
||||
✗
|
||||
</button>
|
||||
</div>
|
||||
@@ -134,7 +141,7 @@ function renderActionButtons(request, actionType) {
|
||||
} else if (actionType === 'delete' && request.status === 'pending') {
|
||||
return `
|
||||
<td>
|
||||
<button class="btn-small btn-danger" onclick="deleteVacationRequest(${request.request_id})" title="삭제">
|
||||
<button class="btn-small btn-danger" onclick="deleteVacationRequest(${safeRequestId})" title="삭제">
|
||||
삭제
|
||||
</button>
|
||||
</td>
|
||||
|
||||
@@ -69,29 +69,34 @@ function renderDepartmentList() {
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = departments.map(dept => `
|
||||
<div class="department-item ${currentDepartmentId === 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>
|
||||
container.innerHTML = departments.map(dept => {
|
||||
const safeDeptId = parseInt(dept.department_id) || 0;
|
||||
const safeDeptName = escapeHtml(dept.department_name || '-');
|
||||
const workerCount = parseInt(dept.worker_count) || 0;
|
||||
return `
|
||||
<div class="department-item ${currentDepartmentId === dept.department_id ? 'active' : ''}"
|
||||
onclick="selectDepartment(${safeDeptId})">
|
||||
<div class="department-info">
|
||||
<span class="department-name">${safeDeptName}</span>
|
||||
<span class="department-count">${workerCount}명</span>
|
||||
</div>
|
||||
<div class="department-actions" onclick="event.stopPropagation()">
|
||||
<button class="btn-icon" onclick="editDepartment(${safeDeptId})" title="수정">
|
||||
<svg width="14" height="14" 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="confirmDeleteDepartment(${safeDeptId})" title="삭제">
|
||||
<svg width="14" height="14" 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>
|
||||
<div class="department-actions" onclick="event.stopPropagation()">
|
||||
<button class="btn-icon" onclick="editDepartment(${dept.department_id})" title="수정">
|
||||
<svg width="14" height="14" 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="confirmDeleteDepartment(${dept.department_id})" title="삭제">
|
||||
<svg width="14" height="14" 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('');
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// 부서 선택
|
||||
@@ -117,7 +122,10 @@ function updateParentDepartmentSelect() {
|
||||
select.innerHTML = '<option value="">없음 (최상위 부서)</option>' +
|
||||
departments
|
||||
.filter(d => d.department_id !== parseInt(currentId))
|
||||
.map(d => `<option value="${d.department_id}">${d.department_name}</option>`)
|
||||
.map(d => {
|
||||
const safeDeptId = parseInt(d.department_id) || 0;
|
||||
return `<option value="${safeDeptId}">${escapeHtml(d.department_name || '-')}</option>`;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
@@ -316,7 +324,8 @@ function renderWorkerList() {
|
||||
'leader': '그룹장',
|
||||
'admin': '관리자'
|
||||
};
|
||||
const jobType = jobTypeMap[worker.job_type] || worker.job_type || '-';
|
||||
const safeJobType = ['worker', 'leader', 'admin'].includes(worker.job_type) ? worker.job_type : '';
|
||||
const jobType = jobTypeMap[safeJobType] || escapeHtml(worker.job_type || '-');
|
||||
|
||||
const isInactive = worker.status === 'inactive';
|
||||
const isResigned = worker.employment_status === 'resigned';
|
||||
@@ -332,12 +341,16 @@ function renderWorkerList() {
|
||||
statusText = '사무직';
|
||||
}
|
||||
|
||||
const safeWorkerId = parseInt(worker.worker_id) || 0;
|
||||
const safeWorkerName = escapeHtml(worker.worker_name || '');
|
||||
const firstChar = safeWorkerName ? safeWorkerName.charAt(0) : '?';
|
||||
|
||||
return `
|
||||
<tr style="${isResigned ? 'opacity: 0.6;' : ''}">
|
||||
<td>
|
||||
<div class="worker-name-cell">
|
||||
<div class="worker-avatar">${worker.worker_name.charAt(0)}</div>
|
||||
<span style="font-weight: 500;">${worker.worker_name}</span>
|
||||
<div class="worker-avatar">${firstChar}</div>
|
||||
<span style="font-weight: 500;">${safeWorkerName}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>${jobType}</td>
|
||||
@@ -350,8 +363,8 @@ function renderWorkerList() {
|
||||
</td>
|
||||
<td>
|
||||
<div style="display: flex; gap: 0.25rem; justify-content: center;">
|
||||
<button class="btn-icon" onclick="editWorker(${worker.worker_id})" title="수정">✏️</button>
|
||||
<button class="btn-icon danger" onclick="confirmDeleteWorker(${worker.worker_id})" title="삭제">🗑️</button>
|
||||
<button class="btn-icon" onclick="editWorker(${safeWorkerId})" title="수정">✏️</button>
|
||||
<button class="btn-icon danger" onclick="confirmDeleteWorker(${safeWorkerId})" title="삭제">🗑️</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,21 +1,38 @@
|
||||
// 작업장 관리 페이지 JavaScript
|
||||
//
|
||||
// 참고: 이 파일은 점진적 마이그레이션 중입니다.
|
||||
// 새로운 모듈 시스템: /js/workplace-management/
|
||||
// - state.js: 전역 상태 관리
|
||||
// - utils.js: 유틸리티 함수
|
||||
// - api.js: API 클라이언트
|
||||
// - index.js: 메인 컨트롤러
|
||||
|
||||
// 전역 변수
|
||||
let categories = [];
|
||||
let workplaces = [];
|
||||
let currentCategoryId = '';
|
||||
// 전역 변수 (모듈 시스템이 없을 때만 사용)
|
||||
let categories = window.WorkplaceState?.categories || [];
|
||||
let workplaces = window.WorkplaceState?.workplaces || [];
|
||||
let currentCategoryId = window.WorkplaceState?.currentCategoryId || '';
|
||||
let currentEditingCategory = null;
|
||||
let currentEditingWorkplace = null;
|
||||
|
||||
// 페이지 초기화
|
||||
// 페이지 초기화 (모듈 시스템이 없을 때만)
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('🏗️ 작업장 관리 페이지 초기화 시작');
|
||||
// 모듈 시스템이 이미 로드되어 있으면 초기화 건너뜀
|
||||
if (window.WorkplaceController) {
|
||||
console.log('[workplace-management.js] 모듈 시스템 감지 - 기존 초기화 건너뜀');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🏗️ 작업장 관리 페이지 초기화 시작 (레거시)');
|
||||
loadAllData();
|
||||
});
|
||||
|
||||
// 모든 데이터 로드
|
||||
async function loadAllData() {
|
||||
// 모듈 시스템이 있으면 위임
|
||||
if (window.WorkplaceController) {
|
||||
return window.WorkplaceController.loadAllData();
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
loadCategories(),
|
||||
@@ -35,6 +52,13 @@ async function loadAllData() {
|
||||
|
||||
// 카테고리 목록 로드
|
||||
async function loadCategories() {
|
||||
// 모듈 시스템이 있으면 위임
|
||||
if (window.WorkplaceAPI) {
|
||||
const result = await window.WorkplaceAPI.loadCategories();
|
||||
categories = window.WorkplaceState?.categories || result;
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await window.apiCall('/workplaces/categories', 'GET');
|
||||
|
||||
@@ -934,27 +958,30 @@ function showToast(message, type = 'info') {
|
||||
}
|
||||
|
||||
// 전역 함수 및 변수로 노출 (다른 모듈에서 접근 가능하도록)
|
||||
// getter/setter를 사용하여 항상 최신 값을 반환
|
||||
Object.defineProperty(window, 'categories', {
|
||||
get: function() {
|
||||
return categories;
|
||||
}
|
||||
});
|
||||
// 모듈 시스템이 이미 정의했으면 건너뜀
|
||||
if (!window.WorkplaceState) {
|
||||
// getter/setter를 사용하여 항상 최신 값을 반환
|
||||
Object.defineProperty(window, 'categories', {
|
||||
get: function() {
|
||||
return categories;
|
||||
}
|
||||
});
|
||||
|
||||
Object.defineProperty(window, 'workplaces', {
|
||||
get: function() {
|
||||
return workplaces;
|
||||
}
|
||||
});
|
||||
Object.defineProperty(window, 'workplaces', {
|
||||
get: function() {
|
||||
return workplaces;
|
||||
}
|
||||
});
|
||||
|
||||
Object.defineProperty(window, 'currentCategoryId', {
|
||||
get: function() {
|
||||
return currentCategoryId;
|
||||
},
|
||||
set: function(value) {
|
||||
currentCategoryId = value;
|
||||
}
|
||||
});
|
||||
Object.defineProperty(window, 'currentCategoryId', {
|
||||
get: function() {
|
||||
return currentCategoryId;
|
||||
},
|
||||
set: function(value) {
|
||||
currentCategoryId = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 작업장 지도 관리 ====================
|
||||
|
||||
|
||||
@@ -442,15 +442,15 @@ function renderCurrentTasks(workers) {
|
||||
html += `
|
||||
<div class="current-task-item">
|
||||
<div class="task-info">
|
||||
<p class="task-name">${worker.task_name}</p>
|
||||
<p class="task-name">${escapeHtml(worker.task_name)}</p>
|
||||
<p class="task-detail">
|
||||
${worker.work_location ? `📍 ${worker.work_location}` : ''}
|
||||
${worker.project_name ? ` • 📁 ${worker.project_name}` : ''}
|
||||
${worker.work_location ? `📍 ${escapeHtml(worker.work_location)}` : ''}
|
||||
${worker.project_name ? ` • 📁 ${escapeHtml(worker.project_name)}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div class="task-badge">
|
||||
<span>👷</span>
|
||||
<span>${worker.member_count}명</span>
|
||||
<span>${parseInt(worker.member_count) || 0}명</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -481,7 +481,7 @@ async function loadEquipmentStatus(workplaceId) {
|
||||
<div class="equipment-item">
|
||||
<span class="equipment-icon">⚙️</span>
|
||||
<div class="equipment-info">
|
||||
<p class="equipment-name">${eq.equipment_name}</p>
|
||||
<p class="equipment-name">${escapeHtml(eq.equipment_name)}</p>
|
||||
<p class="equipment-status ${statusClass}">${statusText}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -516,11 +516,11 @@ function renderWorkersTab(workers) {
|
||||
html += `
|
||||
<div class="worker-item">
|
||||
<div class="worker-item-header">
|
||||
<p class="worker-item-title">${worker.task_name}</p>
|
||||
<span class="worker-item-badge">${worker.member_count}명</span>
|
||||
<p class="worker-item-title">${escapeHtml(worker.task_name)}</p>
|
||||
<span class="worker-item-badge">${parseInt(worker.member_count) || 0}명</span>
|
||||
</div>
|
||||
${worker.work_location ? `<p class="worker-item-detail">📍 ${worker.work_location}</p>` : ''}
|
||||
${worker.project_name ? `<p class="worker-item-detail">📁 ${worker.project_name}</p>` : ''}
|
||||
${worker.work_location ? `<p class="worker-item-detail">📍 ${escapeHtml(worker.work_location)}</p>` : ''}
|
||||
${worker.project_name ? `<p class="worker-item-detail">📁 ${escapeHtml(worker.project_name)}</p>` : ''}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
@@ -544,11 +544,11 @@ function renderVisitorsTab(visitors) {
|
||||
html += `
|
||||
<div class="visitor-item">
|
||||
<div class="visitor-item-header">
|
||||
<p class="visitor-item-title">${visitor.visitor_company}</p>
|
||||
<span class="visitor-item-badge">${visitor.visitor_count}명 • ${statusText}</span>
|
||||
<p class="visitor-item-title">${escapeHtml(visitor.visitor_company)}</p>
|
||||
<span class="visitor-item-badge">${parseInt(visitor.visitor_count) || 0}명 • ${statusText}</span>
|
||||
</div>
|
||||
<p class="visitor-item-detail">⏰ ${visitor.visit_time}</p>
|
||||
<p class="visitor-item-detail">📋 ${visitor.purpose_name}</p>
|
||||
<p class="visitor-item-detail">⏰ ${escapeHtml(visitor.visit_time)}</p>
|
||||
<p class="visitor-item-detail">📋 ${escapeHtml(visitor.purpose_name)}</p>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
<section>
|
||||
<h2>📄 작업 보고서</h2>
|
||||
<ul>
|
||||
<li><a href="/pages/work-reports/create.html">작업보고서 입력</a></li>
|
||||
<li><a href="/pages/work-reports/manage.html">작업보고서 수정/삭제</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>📊 출근/공수 관리</h2>
|
||||
<ul>
|
||||
<li><a href="/pages/common/attendance.html">출근부</a></li>
|
||||
<li><a href="/pages/work-reports/project-labor-summary.html">프로젝트별 공수 계산</a></li>
|
||||
<li><a href="/pages/work-reports/monthly-labor-report.html">월간 공수 보고서</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>🔧 관리콘솔</h2>
|
||||
<ul>
|
||||
<li><a href="/pages/admin/manage-user.html">👤 사용자 관리</a></li>
|
||||
<li><a href="/pages/admin/manage-project.html">📁 프로젝트 관리</a></li>
|
||||
<li><a href="/pages/admin/manage-worker.html">👷 작업자 관리</a></li>
|
||||
<li><a href="/pages/admin/manage-task.html">📋 작업 유형 관리</a></li>
|
||||
<li><a href="/pages/admin/manage-issue.html">🚨 이슈 유형 관리</a></li>
|
||||
<li><a href="/pages/admin/manage-pipespec.html">🔧 배관 스펙 관리</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>🏭 공장 정보</h2>
|
||||
<ul>
|
||||
<li><a href="/pages/common/factory-upload.html">공장 정보 등록</a></li>
|
||||
<li><a href="/pages/common/factory-list.html">공장 목록 보기</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>📊 이슈 리포트</h2>
|
||||
<ul>
|
||||
<li><a href="/pages/issue-reports/daily-issue.html">일일 이슈 보고</a></li>
|
||||
<li><a href="/pages/issue-reports/issue-summary.html">이슈 현황 요약</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
@@ -1,35 +0,0 @@
|
||||
<section>
|
||||
<h2>📄 작업 보고서</h2>
|
||||
<ul>
|
||||
<li><a href="/pages/work/work-report-create.html">작업보고서 입력</a></li>
|
||||
<li><a href="/pages/work/work-report-manage.html">작업보고서 수정/삭제</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>📊 출근/공수 관리</h2>
|
||||
<ul>
|
||||
<li><a href="/pages/attendance/attendance.html">출근부</a></li>
|
||||
<li><a href="/pages/attendance/project-labor-summary.html">프로젝트별 공수 계산</a></li>
|
||||
<li><a href="/pages/attendance/monthly-labor-report.html">월간 공수 보고서</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>관리콘솔</h2>
|
||||
<ul>
|
||||
<li><a href="/pages/admin/manage-all.html">📋 기본정보 관리</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>🏭 공장 정보</h2>
|
||||
<ul>
|
||||
<li><a href="/pages/factory/factory-map-upload.html">공장 지도 업로드</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>🗂 기타 관리</h2>
|
||||
<p>프로젝트 및 작업자 관련 기능은 추후 확장 예정</p>
|
||||
</section>
|
||||
@@ -1,667 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Calendar, Clock, Users, AlertTriangle, CheckCircle, Edit3, Filter } from 'lucide-react';
|
||||
|
||||
const AttendanceValidationPage = () => {
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const [selectedDate, setSelectedDate] = useState(null);
|
||||
const [attendanceData, setAttendanceData] = useState({});
|
||||
const [selectedDateWorkers, setSelectedDateWorkers] = useState([]);
|
||||
const [filter, setFilter] = useState('all'); // all, needsReview, normal, missing
|
||||
const [monthlyData, setMonthlyData] = useState({ workReports: [], dailyReports: [] });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isAuthorized, setIsAuthorized] = useState(true); // TODO: 실제 권한 체크
|
||||
|
||||
// 월이 변경될 때마다 해당 월의 전체 데이터 로드
|
||||
useEffect(() => {
|
||||
loadMonthlyData();
|
||||
}, [currentDate]);
|
||||
|
||||
const loadMonthlyData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const year = currentDate.getFullYear();
|
||||
const month = currentDate.getMonth() + 1;
|
||||
|
||||
console.log(`${year}년 ${month}월 데이터 로딩 중...`);
|
||||
const data = await fetchMonthlyData(year, month);
|
||||
setMonthlyData(data);
|
||||
|
||||
} catch (error) {
|
||||
console.error('월간 데이터 로딩 실패:', error);
|
||||
// 실패 시 빈 데이터로 설정
|
||||
setMonthlyData({ workReports: [], dailyReports: [] });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 실제 API 호출 함수들
|
||||
const fetchWorkReports = async (date) => {
|
||||
try {
|
||||
const response = await fetch(`/api/workreports/date/${date}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('WorkReports API 호출 오류:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDailyWorkReports = async (date) => {
|
||||
try {
|
||||
const response = await fetch(`/api/daily-work-reports/date/${date}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('DailyWorkReports API 호출 오류:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// 월간 데이터 가져오기 (캘린더용)
|
||||
const fetchMonthlyData = async (year, month) => {
|
||||
const start = `${year}-${month.toString().padStart(2, '0')}-01`;
|
||||
const end = `${year}-${month.toString().padStart(2, '0')}-31`;
|
||||
|
||||
try {
|
||||
const [workReports, dailyReports] = await Promise.all([
|
||||
fetch(`/api/workreports?start=${start}&end=${end}`, {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
}).then(res => res.json()),
|
||||
fetch(`/api/daily-work-reports/search?start_date=${start}&end_date=${end}`, {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
}).then(res => res.json())
|
||||
]);
|
||||
|
||||
return { workReports, dailyReports: dailyReports.reports || [] };
|
||||
} catch (error) {
|
||||
console.error('월간 데이터 가져오기 오류:', error);
|
||||
return { workReports: [], dailyReports: [] };
|
||||
}
|
||||
};
|
||||
|
||||
// 목업 데이터 (개발/테스트용)
|
||||
const mockWorkReports = {
|
||||
'2025-06-16': [
|
||||
{ worker_id: 1, worker_name: '김철수', overtime_hours: 1, status: 'normal' },
|
||||
{ worker_id: 2, worker_name: '이영희', overtime_hours: 0, status: 'half_day' },
|
||||
{ worker_id: 3, worker_name: '박민수', overtime_hours: 0, status: 'vacation' },
|
||||
],
|
||||
'2025-06-17': [
|
||||
{ worker_id: 1, worker_name: '김철수', overtime_hours: 2, status: 'normal' },
|
||||
{ worker_id: 2, worker_name: '이영희', overtime_hours: 0, status: 'normal' },
|
||||
{ worker_id: 4, worker_name: '정수현', overtime_hours: 0, status: 'early_leave' },
|
||||
],
|
||||
'2025-06-19': [
|
||||
{ worker_id: 1, worker_name: '김철수', overtime_hours: 1, status: 'normal' },
|
||||
{ worker_id: 2, worker_name: '이영희', overtime_hours: 0, status: 'half_day' },
|
||||
{ worker_id: 3, worker_name: '박민수', overtime_hours: 0, status: 'vacation' },
|
||||
{ worker_id: 4, worker_name: '정수현', overtime_hours: 0, status: 'normal' },
|
||||
]
|
||||
};
|
||||
|
||||
const mockDailyReports = {
|
||||
'2025-06-16': [
|
||||
{ worker_id: 1, worker_name: '김철수', work_hours: 9 },
|
||||
{ worker_id: 2, worker_name: '이영희', work_hours: 4 },
|
||||
{ worker_id: 3, worker_name: '박민수', work_hours: 0 },
|
||||
],
|
||||
'2025-06-17': [
|
||||
{ worker_id: 1, worker_name: '김철수', work_hours: 10 },
|
||||
{ worker_id: 2, worker_name: '이영희', work_hours: 8 },
|
||||
{ worker_id: 4, worker_name: '정수현', work_hours: 6 },
|
||||
],
|
||||
'2025-06-19': [
|
||||
{ worker_id: 1, worker_name: '김철수', work_hours: 9 },
|
||||
{ worker_id: 2, worker_name: '이영희', work_hours: 4 },
|
||||
{ worker_id: 3, worker_name: '박민수', work_hours: 0 },
|
||||
// 정수현 데이터 누락 - 미입력 상태
|
||||
]
|
||||
};
|
||||
|
||||
// 시간 계산 함수
|
||||
const calculateExpectedHours = (status, overtime_hours = 0) => {
|
||||
const baseHours = {
|
||||
'normal': 8,
|
||||
'half_day': 4,
|
||||
'early_leave': 6,
|
||||
'quarter_day': 2,
|
||||
'vacation': 0,
|
||||
'sick_leave': 0
|
||||
};
|
||||
return (baseHours[status] || 8) + (overtime_hours || 0);
|
||||
};
|
||||
|
||||
// 날짜별 상태 계산 (월간 데이터 기반)
|
||||
const calculateDateStatus = (dateStr) => {
|
||||
const workReports = monthlyData.workReports.filter(wr => wr.date === dateStr);
|
||||
const dailyReports = monthlyData.dailyReports.filter(dr => dr.report_date === dateStr);
|
||||
|
||||
if (workReports.length === 0 && dailyReports.length === 0) {
|
||||
return 'no-data';
|
||||
}
|
||||
|
||||
if (workReports.length === 0 || dailyReports.length === 0) {
|
||||
return 'missing';
|
||||
}
|
||||
|
||||
// 작업자별 시간 집계
|
||||
const dailyGrouped = dailyReports.reduce((acc, dr) => {
|
||||
if (!acc[dr.worker_id]) {
|
||||
acc[dr.worker_id] = 0;
|
||||
}
|
||||
acc[dr.worker_id] += parseFloat(dr.work_hours || 0);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// 불일치 검사
|
||||
const hasDiscrepancy = workReports.some(wr => {
|
||||
const reportedHours = dailyGrouped[wr.worker_id] || 0;
|
||||
const expectedHours = calculateExpectedHours('normal', wr.overtime_hours || 0);
|
||||
return Math.abs(reportedHours - expectedHours) > 0;
|
||||
});
|
||||
|
||||
return hasDiscrepancy ? 'needs-review' : 'normal';
|
||||
};
|
||||
|
||||
// 권한 체크 (실제 구현 시)
|
||||
const checkPermission = () => {
|
||||
// TODO: 실제 권한 체크 로직
|
||||
const userRole = localStorage.getItem('userRole') || 'user';
|
||||
return userRole === 'admin' || userRole === 'manager';
|
||||
};
|
||||
|
||||
// 권한 없음 UI
|
||||
if (!isAuthorized) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto p-6 bg-gray-50 min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-6xl mb-4">🔒</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">접근 권한이 없습니다</h1>
|
||||
<p className="text-gray-600">이 페이지는 관리자(Admin) 이상만 접근 가능합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 로딩 중 UI
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto p-6 bg-gray-50 min-h-screen">
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
<span className="ml-3 text-gray-600">데이터를 불러오는 중...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 캘린더 생성
|
||||
const generateCalendar = () => {
|
||||
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 calendar = [];
|
||||
const current = new Date(startDate);
|
||||
|
||||
for (let week = 0; week < 6; week++) {
|
||||
const weekDays = [];
|
||||
for (let day = 0; day < 7; day++) {
|
||||
const dateStr = current.toISOString().split('T')[0];
|
||||
const isCurrentMonth = current.getMonth() === month;
|
||||
const status = isCurrentMonth ? calculateDateStatus(dateStr) : 'no-data';
|
||||
|
||||
weekDays.push({
|
||||
date: new Date(current),
|
||||
dateStr,
|
||||
isCurrentMonth,
|
||||
status
|
||||
});
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
calendar.push(weekDays);
|
||||
}
|
||||
return calendar;
|
||||
};
|
||||
|
||||
// 실제 API를 사용한 작업자 데이터 조합
|
||||
const getWorkersForDate = async (dateStr) => {
|
||||
try {
|
||||
// 실제 API 호출
|
||||
const [workReports, dailyReports] = await Promise.all([
|
||||
fetchWorkReports(dateStr),
|
||||
fetchDailyWorkReports(dateStr)
|
||||
]);
|
||||
|
||||
console.log('API 응답:', { workReports, dailyReports });
|
||||
|
||||
const workerMap = new Map();
|
||||
|
||||
// WorkReports 데이터 추가 (생산지원팀 입력)
|
||||
workReports.forEach(wr => {
|
||||
workerMap.set(wr.worker_id, {
|
||||
worker_id: wr.worker_id,
|
||||
worker_name: wr.worker_name,
|
||||
overtime_hours: wr.overtime_hours || 0,
|
||||
status: 'normal', // 실제 테이블 구조에 맞게 수정 필요
|
||||
expected_hours: calculateExpectedHours('normal', wr.overtime_hours),
|
||||
reported_hours: null,
|
||||
hasWorkReport: true,
|
||||
hasDailyReport: false
|
||||
});
|
||||
});
|
||||
|
||||
// DailyReports 데이터 추가 (그룹장 입력) - 작업자별 총 시간 집계
|
||||
const dailyGrouped = dailyReports.reduce((acc, dr) => {
|
||||
if (!acc[dr.worker_id]) {
|
||||
acc[dr.worker_id] = {
|
||||
worker_id: dr.worker_id,
|
||||
worker_name: dr.worker_name,
|
||||
total_work_hours: 0
|
||||
};
|
||||
}
|
||||
acc[dr.worker_id].total_work_hours += parseFloat(dr.work_hours || 0);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
Object.values(dailyGrouped).forEach(dr => {
|
||||
if (workerMap.has(dr.worker_id)) {
|
||||
const worker = workerMap.get(dr.worker_id);
|
||||
worker.reported_hours = dr.total_work_hours;
|
||||
worker.hasDailyReport = true;
|
||||
} else {
|
||||
workerMap.set(dr.worker_id, {
|
||||
worker_id: dr.worker_id,
|
||||
worker_name: dr.worker_name,
|
||||
overtime_hours: 0,
|
||||
status: 'normal',
|
||||
expected_hours: 8,
|
||||
reported_hours: dr.total_work_hours,
|
||||
hasWorkReport: false,
|
||||
hasDailyReport: true
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(workerMap.values()).map(worker => ({
|
||||
...worker,
|
||||
difference: worker.reported_hours !== null ? worker.reported_hours - worker.expected_hours : -worker.expected_hours,
|
||||
validationStatus: getValidationStatus(worker)
|
||||
}));
|
||||
|
||||
} catch (error) {
|
||||
console.error('데이터 조합 오류:', error);
|
||||
// 오류 시 목업 데이터 사용
|
||||
return getWorkersForDateMock(dateStr);
|
||||
}
|
||||
};
|
||||
|
||||
// 목업 데이터용 함수 (개발/테스트)
|
||||
const getWorkersForDateMock = (dateStr) => {
|
||||
const workReports = mockWorkReports[dateStr] || [];
|
||||
const dailyReports = mockDailyReports[dateStr] || [];
|
||||
|
||||
const workerMap = new Map();
|
||||
|
||||
// WorkReports 데이터 추가
|
||||
workReports.forEach(wr => {
|
||||
workerMap.set(wr.worker_id, {
|
||||
worker_id: wr.worker_id,
|
||||
worker_name: wr.worker_name,
|
||||
overtime_hours: wr.overtime_hours,
|
||||
status: wr.status,
|
||||
expected_hours: calculateExpectedHours(wr.status, wr.overtime_hours),
|
||||
reported_hours: null,
|
||||
hasWorkReport: true,
|
||||
hasDailyReport: false
|
||||
});
|
||||
});
|
||||
|
||||
// DailyReports 데이터 추가
|
||||
dailyReports.forEach(dr => {
|
||||
if (workerMap.has(dr.worker_id)) {
|
||||
const worker = workerMap.get(dr.worker_id);
|
||||
worker.reported_hours = dr.work_hours;
|
||||
worker.hasDailyReport = true;
|
||||
} else {
|
||||
workerMap.set(dr.worker_id, {
|
||||
worker_id: dr.worker_id,
|
||||
worker_name: dr.worker_name,
|
||||
overtime_hours: 0,
|
||||
status: 'normal',
|
||||
expected_hours: 8,
|
||||
reported_hours: dr.work_hours,
|
||||
hasWorkReport: false,
|
||||
hasDailyReport: true
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(workerMap.values()).map(worker => ({
|
||||
...worker,
|
||||
difference: worker.reported_hours !== null ? worker.reported_hours - worker.expected_hours : -worker.expected_hours,
|
||||
validationStatus: getValidationStatus(worker)
|
||||
}));
|
||||
};
|
||||
|
||||
const getValidationStatus = (worker) => {
|
||||
if (!worker.hasWorkReport || !worker.hasDailyReport) return 'missing';
|
||||
if (Math.abs(worker.difference) > 0) return 'needs-review';
|
||||
return 'normal';
|
||||
};
|
||||
|
||||
// 작업자 수정 핸들러
|
||||
const handleEditWorker = (worker) => {
|
||||
// TODO: 수정 모달 또는 인라인 편집 구현
|
||||
const newHours = prompt(
|
||||
`${worker.worker_name}의 근무시간을 수정하세요.\n현재: ${worker.reported_hours || 0}시간`,
|
||||
worker.reported_hours || 0
|
||||
);
|
||||
|
||||
if (newHours !== null && !isNaN(newHours)) {
|
||||
updateWorkerHours(worker.worker_id, parseFloat(newHours));
|
||||
}
|
||||
};
|
||||
|
||||
// 작업자 시간 업데이트 (실제 API 호출)
|
||||
const updateWorkerHours = async (workerId, newHours) => {
|
||||
try {
|
||||
// TODO: 실제 수정 API 호출
|
||||
console.log(`작업자 ${workerId}의 시간을 ${newHours}시간으로 수정`);
|
||||
|
||||
// 임시: 로컬 상태 업데이트
|
||||
setSelectedDateWorkers(prev =>
|
||||
prev.map(worker =>
|
||||
worker.worker_id === workerId
|
||||
? {
|
||||
...worker,
|
||||
reported_hours: newHours,
|
||||
difference: newHours - worker.expected_hours,
|
||||
validationStatus: Math.abs(newHours - worker.expected_hours) > 0 ? 'needs-review' : 'normal'
|
||||
}
|
||||
: worker
|
||||
)
|
||||
);
|
||||
|
||||
alert('수정이 완료되었습니다.');
|
||||
|
||||
} catch (error) {
|
||||
console.error('수정 실패:', error);
|
||||
alert('수정 중 오류가 발생했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 날짜 클릭 핸들러 (실제 API 호출)
|
||||
const handleDateClick = async (dateInfo) => {
|
||||
if (!dateInfo.isCurrentMonth) return;
|
||||
|
||||
setSelectedDate(dateInfo.dateStr);
|
||||
|
||||
try {
|
||||
// 로딩 상태 표시
|
||||
setSelectedDateWorkers([]);
|
||||
|
||||
// 실제 API에서 데이터 가져오기
|
||||
const workers = await getWorkersForDate(dateInfo.dateStr);
|
||||
setSelectedDateWorkers(workers);
|
||||
|
||||
} catch (error) {
|
||||
console.error('날짜별 데이터 로딩 오류:', error);
|
||||
// 오류 시 목업 데이터 사용
|
||||
const workers = getWorkersForDateMock(dateInfo.dateStr);
|
||||
setSelectedDateWorkers(workers);
|
||||
}
|
||||
};
|
||||
|
||||
// 필터링된 작업자 목록
|
||||
const filteredWorkers = selectedDateWorkers.filter(worker => {
|
||||
if (filter === 'all') return true;
|
||||
if (filter === 'needsReview') return worker.validationStatus === 'needs-review';
|
||||
if (filter === 'normal') return worker.validationStatus === 'normal';
|
||||
if (filter === 'missing') return worker.validationStatus === 'missing';
|
||||
return true;
|
||||
});
|
||||
|
||||
// 상태별 아이콘 및 색상
|
||||
const getStatusIcon = (status) => {
|
||||
switch (status) {
|
||||
case 'normal': return <CheckCircle className="w-4 h-4 text-green-500" />;
|
||||
case 'needs-review': return <AlertTriangle className="w-4 h-4 text-yellow-500" />;
|
||||
case 'missing': return <Clock className="w-4 h-4 text-red-500" />;
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'normal': return 'bg-green-100 text-green-800';
|
||||
case 'needs-review': return 'bg-yellow-100 text-yellow-800';
|
||||
case 'missing': return 'bg-red-100 text-red-800';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const calendar = generateCalendar();
|
||||
const monthNames = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'];
|
||||
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto p-6 bg-gray-50 min-h-screen">
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Users className="w-6 h-6 text-blue-600" />
|
||||
<h1 className="text-2xl font-bold text-gray-900">근태 검증 관리</h1>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
Admin 전용 페이지
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* 캘린더 섹션 */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
{/* 캘린더 헤더 */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
const newDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - 1);
|
||||
setCurrentDate(newDate);
|
||||
}}
|
||||
className="p-2 hover:bg-gray-100 rounded-md"
|
||||
disabled={loading}
|
||||
>
|
||||
◀
|
||||
</button>
|
||||
<h2 className="text-xl font-semibold">
|
||||
{currentDate.getFullYear()}년 {monthNames[currentDate.getMonth()]}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
const newDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1);
|
||||
setCurrentDate(newDate);
|
||||
}}
|
||||
className="p-2 hover:bg-gray-100 rounded-md"
|
||||
disabled={loading}
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 월간 요약 정보 */}
|
||||
<div className="mb-4 p-3 bg-gray-50 rounded-lg">
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div className="text-center">
|
||||
<div className="text-green-600 font-semibold">
|
||||
{calendar.flat().filter(d => d.isCurrentMonth && d.status === 'normal').length}
|
||||
</div>
|
||||
<div className="text-gray-600">정상</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-yellow-600 font-semibold">
|
||||
{calendar.flat().filter(d => d.isCurrentMonth && d.status === 'needs-review').length}
|
||||
</div>
|
||||
<div className="text-gray-600">검토필요</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-red-600 font-semibold">
|
||||
{calendar.flat().filter(d => d.isCurrentMonth && d.status === 'missing').length}
|
||||
</div>
|
||||
<div className="text-gray-600">미입력</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 요일 헤더 */}
|
||||
<div className="grid grid-cols-7 gap-1 mb-2">
|
||||
{dayNames.map(day => (
|
||||
<div key={day} className="p-2 text-center text-sm font-medium text-gray-500">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 캘린더 본체 */}
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{calendar.flat().map((dateInfo, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleDateClick(dateInfo)}
|
||||
className={`
|
||||
p-2 text-sm rounded-md h-12 relative transition-colors
|
||||
${dateInfo.isCurrentMonth ? 'text-gray-900' : 'text-gray-400'}
|
||||
${selectedDate === dateInfo.dateStr ? 'bg-blue-100 border-2 border-blue-500' : 'hover:bg-gray-50'}
|
||||
${dateInfo.status === 'needs-review' ? 'bg-yellow-50 border border-yellow-200' : ''}
|
||||
${dateInfo.status === 'missing' ? 'bg-red-50 border border-red-200' : ''}
|
||||
${dateInfo.status === 'normal' ? 'bg-green-50 border border-green-200' : ''}
|
||||
`}
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<span>{dateInfo.date.getDate()}</span>
|
||||
{dateInfo.isCurrentMonth && dateInfo.status !== 'no-data' && (
|
||||
<div className="absolute top-1 right-1">
|
||||
{dateInfo.status === 'needs-review' && <div className="w-2 h-2 bg-yellow-500 rounded-full"></div>}
|
||||
{dateInfo.status === 'missing' && <div className="w-2 h-2 bg-red-500 rounded-full"></div>}
|
||||
{dateInfo.status === 'normal' && <div className="w-2 h-2 bg-green-500 rounded-full"></div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 범례 */}
|
||||
<div className="flex items-center justify-center space-x-4 mt-4 text-xs">
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
|
||||
<span>정상</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="w-3 h-3 bg-yellow-500 rounded-full"></div>
|
||||
<span>검토필요</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="w-3 h-3 bg-red-500 rounded-full"></div>
|
||||
<span>미입력</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 작업자 리스트 섹션 */}
|
||||
<div className="lg:col-span-1">
|
||||
{selectedDate ? (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">
|
||||
📅 {selectedDate}
|
||||
</h3>
|
||||
<select
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-md px-2 py-1"
|
||||
>
|
||||
<option value="all">전체</option>
|
||||
<option value="needsReview">검토필요</option>
|
||||
<option value="normal">정상</option>
|
||||
<option value="missing">미입력</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{filteredWorkers.map(worker => (
|
||||
<div key={worker.worker_id} className={`p-3 rounded-lg border ${getStatusColor(worker.validationStatus)}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium">{worker.worker_name}</span>
|
||||
{getStatusIcon(worker.validationStatus)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span>그룹장 입력:</span>
|
||||
<span className="font-mono">
|
||||
{worker.reported_hours !== null ? `${worker.reported_hours}시간` : '미입력'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>시스템 계산:</span>
|
||||
<span className="font-mono">{worker.expected_hours}시간</span>
|
||||
</div>
|
||||
{worker.difference !== 0 && (
|
||||
<div className="flex justify-between font-semibold">
|
||||
<span>차이:</span>
|
||||
<span className={`font-mono ${worker.difference > 0 ? 'text-red-600' : 'text-blue-600'}`}>
|
||||
{worker.difference > 0 ? '+' : ''}{worker.difference}시간
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{worker.validationStatus === 'needs-review' && (
|
||||
<button
|
||||
onClick={() => handleEditWorker(worker)}
|
||||
className="mt-2 w-full text-xs bg-blue-600 text-white px-2 py-1 rounded-md hover:bg-blue-700 flex items-center justify-center space-x-1"
|
||||
>
|
||||
<Edit3 className="w-3 h-3" />
|
||||
<span>수정</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredWorkers.length === 0 && (
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
해당 조건의 작업자가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4 text-center text-gray-500">
|
||||
<Calendar className="w-12 h-12 mx-auto mb-3 text-gray-300" />
|
||||
<p>날짜를 선택하면</p>
|
||||
<p>작업자 검증 내역을 확인할 수 있습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttendanceValidationPage;
|
||||
@@ -1,62 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>이슈 유형 관리 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/main-layout.css">
|
||||
<link rel="stylesheet" href="/css/admin.css">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-layout">
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<div id="content-container">
|
||||
<div class="page-header">
|
||||
<h1>⚙️ 이슈 유형 관리</h1>
|
||||
<p class="subtitle">프로젝트에서 발생하는 이슈 유형을 관리합니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>새 이슈 유형 등록</h3>
|
||||
<form id="issueTypeForm" class="form-horizontal">
|
||||
<div class="form-row">
|
||||
<input type="text" id="category" placeholder="카테고리" required>
|
||||
<input type="text" id="subcategory" placeholder="서브카테고리" required>
|
||||
<button type="submit" class="btn btn-primary">등록</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>등록된 이슈 유형</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>카테고리</th>
|
||||
<th>서브카테고리</th>
|
||||
<th>작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="issueTypeTableBody">
|
||||
<tr><td colspan="4" class="text-center">불러오는 중...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module" src="/js/manage-issue.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,76 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>프로젝트 관리 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/main-layout.css">
|
||||
<link rel="stylesheet" href="/css/admin.css">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-layout">
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<div id="content-container">
|
||||
<div class="page-header">
|
||||
<h1>🏗 프로젝트 관리</h1>
|
||||
<p class="subtitle">진행 중인 프로젝트를 등록하고 관리합니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>새 프로젝트 등록</h3>
|
||||
<form id="projectForm" class="form-vertical">
|
||||
<div class="form-row">
|
||||
<input type="text" id="job_no" placeholder="공사번호" required>
|
||||
<input type="text" id="project_name" placeholder="프로젝트명" required>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<input type="date" id="contract_date" placeholder="계약일">
|
||||
<input type="date" id="due_date" placeholder="납기일">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<input type="text" id="delivery_method" placeholder="납품방식">
|
||||
<input type="text" id="site" placeholder="현장명">
|
||||
<input type="text" id="pm" placeholder="담당 PM">
|
||||
<button type="submit" class="btn btn-primary">등록</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>등록된 프로젝트</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>공사번호</th>
|
||||
<th>프로젝트명</th>
|
||||
<th>계약일</th>
|
||||
<th>납기일</th>
|
||||
<th>납품방식</th>
|
||||
<th>현장</th>
|
||||
<th>PM</th>
|
||||
<th>작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="projectTableBody">
|
||||
<tr><td colspan="9" class="text-center">불러오는 중...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module" src="/js/manage-project.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,68 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>작업 항목 관리 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/main-layout.css">
|
||||
<link rel="stylesheet" href="/css/admin.css">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-layout">
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<div id="content-container">
|
||||
<div class="page-header">
|
||||
<h1>🛠 작업 항목 관리</h1>
|
||||
<p class="subtitle">프로젝트에서 수행되는 작업 항목을 관리합니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>새 작업 항목 등록</h3>
|
||||
<form id="taskForm" class="form-vertical">
|
||||
<div class="form-row">
|
||||
<input type="text" id="category" placeholder="카테고리" required>
|
||||
<input type="text" id="subcategory" placeholder="서브카테고리" required>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<input type="text" id="task_name" placeholder="작업명" required>
|
||||
<input type="text" id="description" placeholder="설명">
|
||||
<button type="submit" class="btn btn-primary">등록</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>등록된 작업 항목</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>카테고리</th>
|
||||
<th>서브카테고리</th>
|
||||
<th>작업명</th>
|
||||
<th>설명</th>
|
||||
<th>작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="taskTableBody">
|
||||
<tr><td colspan="6" class="text-center">불러오는 중...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module" src="/js/manage-task.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,287 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>👤 사용자 관리 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/main-layout.css">
|
||||
<link rel="stylesheet" href="/css/admin.css">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-layout">
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<div id="content-container">
|
||||
<div class="page-header">
|
||||
<h1>👤 사용자 관리</h1>
|
||||
<p class="subtitle">시스템 사용자를 등록하고 관리합니다.</p>
|
||||
</div>
|
||||
|
||||
<!-- 내 비밀번호 변경 -->
|
||||
<div class="card">
|
||||
<h3>🔐 내 비밀번호 변경</h3>
|
||||
<form id="myPasswordForm" class="form-horizontal">
|
||||
<div class="form-row">
|
||||
<input type="password" id="currentPassword" placeholder="현재 비밀번호" required />
|
||||
<input type="password" id="newPassword" placeholder="새 비밀번호" required />
|
||||
<input type="password" id="confirmPassword" placeholder="새 비밀번호 확인" required />
|
||||
<button type="submit" class="btn btn-warning">내 비밀번호 변경</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 등록 폼 -->
|
||||
<div class="card">
|
||||
<h3>새 사용자 등록</h3>
|
||||
<form id="userForm" class="form-horizontal">
|
||||
<div class="form-row">
|
||||
<input type="text" id="username" placeholder="아이디" required />
|
||||
<input type="password" id="password" placeholder="비밀번호" required />
|
||||
<input type="text" id="name" placeholder="이름" required />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<select id="access_level" required>
|
||||
<option value="">권한 선택</option>
|
||||
<option value="worker">작업자</option>
|
||||
<option value="group_leader">그룹장</option>
|
||||
<option value="support_team">지원팀</option>
|
||||
<option value="admin">관리자</option>
|
||||
<option value="system">시스템</option>
|
||||
</select>
|
||||
<select id="worker_id">
|
||||
<option value="">작업자 연결 (선택)</option>
|
||||
</select>
|
||||
<button type="submit" class="btn btn-primary">등록</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 사용자 비밀번호 변경 (시스템 권한자만) -->
|
||||
<div class="card" id="systemPasswordChangeCard" style="display: none;">
|
||||
<h3>🔑 사용자 비밀번호 변경 (시스템 권한자 전용)</h3>
|
||||
<form id="userPasswordForm" class="form-horizontal">
|
||||
<div class="form-row">
|
||||
<select id="targetUserId" required>
|
||||
<option value="">사용자 선택</option>
|
||||
</select>
|
||||
<input type="password" id="targetNewPassword" placeholder="새 비밀번호" required />
|
||||
<button type="submit" class="btn btn-danger">비밀번호 변경</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 사용자 목록 -->
|
||||
<div class="card">
|
||||
<h3>등록된 사용자</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>아이디</th>
|
||||
<th>이름</th>
|
||||
<th>권한</th>
|
||||
<th>연결 작업자</th>
|
||||
<th>작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="userTableBody">
|
||||
<tr><td colspan="6" class="text-center">불러오는 중...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 페이지 권한 관리 모달 -->
|
||||
<div id="pageAccessModal" class="modal" style="display: none;">
|
||||
<div class="modal-content large">
|
||||
<div class="modal-header">
|
||||
<h2>🔐 페이지 접근 권한 관리</h2>
|
||||
<span class="close">×</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="user-info-section">
|
||||
<h3 id="modalUserInfo">사용자 정보</h3>
|
||||
<p id="modalUserRole" class="text-muted"></p>
|
||||
</div>
|
||||
|
||||
<div class="page-access-grid">
|
||||
<div class="category-section" id="dashboardPages">
|
||||
<h4>📊 대시보드</h4>
|
||||
<div class="page-list" id="dashboardPageList"></div>
|
||||
</div>
|
||||
|
||||
<div class="category-section" id="managementPages">
|
||||
<h4>⚙️ 관리</h4>
|
||||
<div class="page-list" id="managementPageList"></div>
|
||||
</div>
|
||||
|
||||
<div class="category-section" id="commonPages">
|
||||
<h4>📝 공통</h4>
|
||||
<div class="page-list" id="commonPageList"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closePageAccessModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" onclick="savePageAccessChanges()">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* 모달 스타일 */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
background-color: rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: #fefefe;
|
||||
margin: 5% auto;
|
||||
padding: 0;
|
||||
border: 1px solid #888;
|
||||
width: 90%;
|
||||
max-width: 800px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.modal-content.large {
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 20px;
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
border-radius: 8px 8px 0 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 30px;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 15px 20px;
|
||||
background-color: #f1f1f1;
|
||||
border-radius: 0 0 8px 8px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.close {
|
||||
color: white;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.close:hover,
|
||||
.close:focus {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.user-info-section {
|
||||
margin-bottom: 30px;
|
||||
padding: 15px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.user-info-section h3 {
|
||||
margin: 0 0 5px 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.page-access-grid {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.category-section h4 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #4CAF50;
|
||||
font-size: 1.1rem;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid #4CAF50;
|
||||
}
|
||||
|
||||
.page-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.page-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.page-item:hover {
|
||||
background-color: #f8f9fa;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.page-item input[type="checkbox"] {
|
||||
margin-right: 15px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.page-item label {
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-item .page-path {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #6c757d;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module" src="/js/manage-user.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,62 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>작업자 관리 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/main-layout.css">
|
||||
<link rel="stylesheet" href="/css/admin.css">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-layout">
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<div id="content-container">
|
||||
<div class="page-header">
|
||||
<h1>👷 작업자 관리</h1>
|
||||
<p class="subtitle">프로젝트에 참여하는 작업자를 관리합니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>새 작업자 등록</h3>
|
||||
<form id="workerForm" class="form-horizontal">
|
||||
<div class="form-row">
|
||||
<input type="text" id="workerName" placeholder="작업자명" required>
|
||||
<input type="text" id="position" placeholder="직책">
|
||||
<button type="submit" class="btn btn-primary">등록</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>등록된 작업자</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>이름</th>
|
||||
<th>직책</th>
|
||||
<th>작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="workerTableBody">
|
||||
<tr><td colspan="4" class="text-center">불러오는 중...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module" src="/js/manage-worker.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,363 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>작업 분석 | (주)테크니컬코리아</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/css/work-analysis.css?v=42">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=1" defer></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="analysis-container">
|
||||
<!-- 페이지 헤더 -->
|
||||
<header class="page-header fade-in">
|
||||
<h1 class="page-title">
|
||||
<span class="icon">📊</span>
|
||||
작업 분석
|
||||
</h1>
|
||||
<p class="page-subtitle">기간별/프로젝트별 작업 현황을 분석하고 통계를 확인합니다</p>
|
||||
</header>
|
||||
|
||||
<!-- 분석 모드 탭 -->
|
||||
<nav class="analysis-tabs fade-in">
|
||||
<button class="tab-button active" data-mode="period">
|
||||
📅 기간별 분석
|
||||
</button>
|
||||
<button class="tab-button" data-mode="project">
|
||||
🏗️ 프로젝트별 분석
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- 분석 조건 설정 -->
|
||||
<section class="analysis-controls fade-in">
|
||||
<div class="controls-grid">
|
||||
<!-- 기간 설정 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="startDate">
|
||||
<span class="icon">📅</span>
|
||||
시작일
|
||||
</label>
|
||||
<input type="date" id="startDate" class="form-input" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="endDate">
|
||||
<span class="icon">📅</span>
|
||||
종료일
|
||||
</label>
|
||||
<input type="date" id="endDate" class="form-input" required>
|
||||
</div>
|
||||
|
||||
<!-- 기간 확정 버튼 -->
|
||||
<div class="form-group">
|
||||
<button class="confirm-period-button" id="confirmPeriodBtn">
|
||||
<span class="icon">✅</span>
|
||||
기간 확정
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 기간 상태 표시 -->
|
||||
<div class="form-group" id="periodStatusGroup" style="display: none;">
|
||||
<div class="period-status">
|
||||
<span class="icon">✅</span>
|
||||
<div>
|
||||
<div style="font-size: 0.8rem; opacity: 0.8; margin-bottom: 2px;">분석 기간</div>
|
||||
<div id="periodStatus">기간이 설정되지 않았습니다</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 분석 결과 영역 -->
|
||||
<main id="analysisResults" class="fade-in">
|
||||
<!-- 로딩 상태 -->
|
||||
<div id="loadingState" class="loading-container" style="display: none;">
|
||||
<div class="loading-spinner"></div>
|
||||
<p class="loading-text">분석 중입니다...</p>
|
||||
</div>
|
||||
|
||||
<!-- 분석 탭 네비게이션 -->
|
||||
<div id="analysisTabNavigation" class="tab-navigation" style="display: none;">
|
||||
<div class="tab-buttons">
|
||||
<button class="tab-button active" data-tab="work-status">
|
||||
<span class="icon">📈</span>
|
||||
기간별 작업 현황
|
||||
</button>
|
||||
<button class="tab-button" data-tab="project-distribution">
|
||||
<span class="icon">🥧</span>
|
||||
프로젝트별 분포
|
||||
</button>
|
||||
<button class="tab-button" data-tab="worker-performance">
|
||||
<span class="icon">👤</span>
|
||||
작업자별 성과
|
||||
</button>
|
||||
<button class="tab-button" data-tab="error-analysis">
|
||||
<span class="icon">⚠️</span>
|
||||
오류 분석
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 결과 카드 그리드 -->
|
||||
<div id="resultsGrid" class="results-grid" style="display: none;">
|
||||
|
||||
<!-- 통계 카드들 -->
|
||||
<div class="stats-cards">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">⏰</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">총 작업시간</div>
|
||||
<div class="stat-value" id="totalHours">0</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">✅</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">정상 시간</div>
|
||||
<div class="stat-value" id="normalHours">0</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">⚠️</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">오류 시간</div>
|
||||
<div class="stat-value" id="errorHours">0</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">👥</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">참여 작업자</div>
|
||||
<div class="stat-value" id="workerCount">0</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">📊</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">오류율</div>
|
||||
<div class="stat-value" id="errorRate">0%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 분석 탭 컨텐츠 -->
|
||||
<div class="tab-contents">
|
||||
|
||||
<!-- 기간별 작업 현황 -->
|
||||
<div id="work-status-tab" class="tab-content active">
|
||||
<div class="chart-container table-type">
|
||||
<div class="chart-header">
|
||||
<h3 class="chart-title">
|
||||
<span class="icon">📈</span>
|
||||
기간별 작업 현황
|
||||
</h3>
|
||||
<button class="chart-analyze-btn" onclick="analyzeWorkStatus()" disabled>
|
||||
<span class="icon">🔍</span>
|
||||
분석 실행
|
||||
</button>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<!-- 테이블이 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 프로젝트별 분포 -->
|
||||
<div id="project-distribution-tab" class="tab-content">
|
||||
<div class="chart-container table-type">
|
||||
<div class="chart-header">
|
||||
<h3 class="chart-title">
|
||||
<span class="icon">🥧</span>
|
||||
프로젝트별 분포
|
||||
</h3>
|
||||
<button class="chart-analyze-btn" onclick="analyzeProjectDistribution()" disabled>
|
||||
<span class="icon">🔍</span>
|
||||
분석 실행
|
||||
</button>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="production-report-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="job-no-header">Job No.</th>
|
||||
<th class="work-content-header">작업내용</th>
|
||||
<th class="man-days-header">공수</th>
|
||||
<th class="load-rate-header">전체 부하율</th>
|
||||
<th class="labor-cost-header">인건비</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="projectDistributionTableBody">
|
||||
<tr>
|
||||
<td colspan="5" style="text-align: center; padding: 2rem; color: #666;">
|
||||
분석을 실행해주세요
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot id="projectDistributionTableFooter" style="display: none;">
|
||||
<tr class="total-row">
|
||||
<td colspan="2"><strong>총계</strong></td>
|
||||
<td><strong id="totalManDays">0</strong></td>
|
||||
<td><strong>100%</strong></td>
|
||||
<td><strong id="totalLaborCost">₩0</strong></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업자별 성과 -->
|
||||
<div id="worker-performance-tab" class="tab-content">
|
||||
<div class="chart-container chart-type">
|
||||
<div class="chart-header">
|
||||
<h3 class="chart-title">
|
||||
<span class="icon">👤</span>
|
||||
작업자별 성과
|
||||
</h3>
|
||||
<button class="chart-analyze-btn" onclick="analyzeWorkerPerformance()" disabled>
|
||||
<span class="icon">🔍</span>
|
||||
분석 실행
|
||||
</button>
|
||||
</div>
|
||||
<canvas id="workerPerformanceChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 오류 분석 -->
|
||||
<div id="error-analysis-tab" class="tab-content">
|
||||
<div class="chart-container table-type">
|
||||
<div class="chart-header">
|
||||
<h3 class="chart-title">
|
||||
<span class="icon">⚠️</span>
|
||||
오류 분석
|
||||
</h3>
|
||||
<button class="chart-analyze-btn" onclick="analyzeErrorAnalysis()" disabled>
|
||||
<span class="icon">🔍</span>
|
||||
분석 실행
|
||||
</button>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="error-analysis-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Job No.</th>
|
||||
<th>작업내용</th>
|
||||
<th>총 시간</th>
|
||||
<th>세부시간</th>
|
||||
<th>작업 타입</th>
|
||||
<th>오류율</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="errorAnalysisTableBody">
|
||||
<tr>
|
||||
<td colspan="6" style="text-align: center; padding: 2rem; color: #666;">
|
||||
분석을 실행해주세요
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot id="errorAnalysisTableFooter" style="display: none;">
|
||||
<tr class="total-row">
|
||||
<td colspan="2"><strong>총계</strong></td>
|
||||
<td><strong id="totalErrorHours">0h</strong></td>
|
||||
<td><strong>-</strong></td>
|
||||
<td><strong>-</strong></td>
|
||||
<td><strong>0.0%</strong></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 모듈화된 JavaScript 로딩 -->
|
||||
<script src="/js/work-analysis/module-loader.js?v=1" defer></script>
|
||||
|
||||
<script>
|
||||
// 서울 표준시(KST) 기준 날짜 함수들 (하위 호환성 유지)
|
||||
function getKSTDate() {
|
||||
const now = new Date();
|
||||
// UTC 시간에 9시간 추가 (KST = UTC+9)
|
||||
const kstOffset = 9 * 60; // 9시간을 분으로 변환
|
||||
const utc = now.getTime() + (now.getTimezoneOffset() * 60000);
|
||||
const kst = new Date(utc + (kstOffset * 60000));
|
||||
return kst;
|
||||
}
|
||||
|
||||
function formatDateToString(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 formatSimpleDate(dateStr) {
|
||||
if (!dateStr) return '날짜 없음';
|
||||
if (typeof dateStr === 'string' && dateStr.includes('T')) {
|
||||
return dateStr.split('T')[0]; // 2025-11-01T00:00:00.000Z → 2025-11-01
|
||||
}
|
||||
return dateStr;
|
||||
}
|
||||
|
||||
// 현재 시간 업데이트 (하위 호환성 유지)
|
||||
function updateTime() {
|
||||
const now = new Date();
|
||||
const timeString = now.toLocaleTimeString('ko-KR', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
|
||||
// 시간 표시 요소가 있다면 업데이트
|
||||
const timeElement = document.querySelector('.time-value');
|
||||
if (timeElement) {
|
||||
timeElement.textContent = timeString;
|
||||
}
|
||||
}
|
||||
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('📦 작업 분석 모듈 로딩 시작...');
|
||||
|
||||
// 서울 표준시(KST) 기준 날짜 설정
|
||||
const today = getKSTDate();
|
||||
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1); // 이번 달 1일
|
||||
const monthEnd = new Date(today.getFullYear(), today.getMonth() + 1, 0); // 이번 달 마지막 날
|
||||
|
||||
document.getElementById('startDate').value = formatDateToString(monthStart);
|
||||
document.getElementById('endDate').value = formatDateToString(monthEnd);
|
||||
|
||||
// 시간 업데이트 시작
|
||||
updateTime();
|
||||
setInterval(updateTime, 1000);
|
||||
});
|
||||
|
||||
// 모듈 로딩 완료 후 초기화
|
||||
window.addEventListener('workAnalysisModulesLoaded', function(event) {
|
||||
console.log('🎉 작업 분석 모듈 로딩 완료:', event.detail.modules);
|
||||
|
||||
// 모듈 로딩 완료 후 추가 초기화 작업이 있다면 여기에 추가
|
||||
});
|
||||
|
||||
// 초기 모드 설정 (하위 호환성 유지)
|
||||
window.currentAnalysisMode = 'period';
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,890 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>작업 현황 분석</title>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/3.9.1/chart.min.js"></script>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: #f5f7fa;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 28px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.date-selector {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.date-selector input {
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: rgba(255,255,255,0.2);
|
||||
color: white;
|
||||
border: 1px solid rgba(255,255,255,0.3);
|
||||
padding: 8px 16px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: rgba(255,255,255,0.3);
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
padding: 25px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
text-align: center;
|
||||
transition: transform 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 5px 20px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.stat-change {
|
||||
font-size: 12px;
|
||||
margin-top: 5px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 15px;
|
||||
}
|
||||
|
||||
.stat-change.positive {
|
||||
background: #e8f5e8;
|
||||
color: #2e7d2e;
|
||||
}
|
||||
|
||||
.stat-change.negative {
|
||||
background: #ffeaea;
|
||||
color: #c53030;
|
||||
}
|
||||
|
||||
.charts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 15px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.chart-canvas {
|
||||
position: relative;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
padding: 15px 20px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 12px 15px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.table th {
|
||||
background: #f8f9fa;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.table tr:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-completed {
|
||||
background: #e8f5e8;
|
||||
color: #2e7d2e;
|
||||
}
|
||||
|
||||
.status-progress {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin: 10px 0;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.debug-info {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
margin: 10px 0;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.charts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.date-selector {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>📊 일일 작업 현황 분석</h1>
|
||||
<p>실시간 작업 현황과 주요 지표를 확인하세요</p>
|
||||
<div class="date-selector">
|
||||
<label>조회 기간:</label>
|
||||
<input type="date" id="startDate">
|
||||
<span>~</span>
|
||||
<input type="date" id="endDate">
|
||||
<button class="btn" onclick="loadData()">조회</button>
|
||||
<button class="btn" onclick="setToday()">오늘</button>
|
||||
<button class="btn" onclick="setThisWeek()">이번주</button>
|
||||
<button class="btn" onclick="setThisMonth()">이번달</button>
|
||||
<button class="btn" onclick="toggleDebug()">디버그</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="errorContainer"></div>
|
||||
<div id="debugInfo" class="debug-info"></div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-number" id="totalHours">0</div>
|
||||
<div class="stat-label">총 작업시간</div>
|
||||
<div class="stat-change positive" id="hoursChange">+0%</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-number" id="totalReports">0</div>
|
||||
<div class="stat-label">보고서 건수</div>
|
||||
<div class="stat-change positive" id="reportsChange">+0%</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-number" id="activeProjects">0</div>
|
||||
<div class="stat-label">진행 프로젝트</div>
|
||||
<div class="stat-change" id="projectsChange">+0%</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-number" id="errorRate">0%</div>
|
||||
<div class="stat-label">에러율</div>
|
||||
<div class="stat-change negative" id="errorChange">+0%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="charts-grid">
|
||||
<div class="chart-container">
|
||||
<div class="chart-title">📈 일별 작업시간 추이</div>
|
||||
<div class="chart-canvas">
|
||||
<canvas id="dailyHoursChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<div class="chart-title">👥 작업자별 작업량</div>
|
||||
<div class="chart-canvas">
|
||||
<canvas id="workerChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="charts-grid">
|
||||
<div class="chart-container">
|
||||
<div class="chart-title">🏗️ 프로젝트별 투입시간</div>
|
||||
<div class="chart-canvas">
|
||||
<canvas id="projectChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<div class="chart-title">⚙️ 작업 유형별 분포</div>
|
||||
<div class="chart-canvas">
|
||||
<canvas id="workTypeChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<div class="table-header">🔍 최근 작업 현황</div>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>날짜</th>
|
||||
<th>작업자</th>
|
||||
<th>프로젝트</th>
|
||||
<th>작업유형</th>
|
||||
<th>작업시간</th>
|
||||
<th>상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="recentWorkTable">
|
||||
<tr>
|
||||
<td colspan="6" class="loading">데이터를 불러오는 중...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 전역 변수
|
||||
let API_URL = 'http://192.168.0.3:3005';
|
||||
let dailyChart, workerChart, projectChart, workTypeChart;
|
||||
let debugMode = false;
|
||||
|
||||
// 디버그 함수
|
||||
function log(message, data = null) {
|
||||
console.log(message, data);
|
||||
if (debugMode) {
|
||||
const debugDiv = document.getElementById('debugInfo');
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
debugDiv.innerHTML += `[${timestamp}] ${message}${data ? ': ' + JSON.stringify(data, null, 2) : ''}<br>`;
|
||||
debugDiv.scrollTop = debugDiv.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDebug() {
|
||||
debugMode = !debugMode;
|
||||
const debugDiv = document.getElementById('debugInfo');
|
||||
debugDiv.style.display = debugMode ? 'block' : 'none';
|
||||
if (debugMode) {
|
||||
debugDiv.innerHTML = '=== 디버그 모드 활성화 ===<br>';
|
||||
}
|
||||
}
|
||||
|
||||
function showError(message, details = null) {
|
||||
const errorContainer = document.getElementById('errorContainer');
|
||||
errorContainer.innerHTML = `
|
||||
<div class="error-message">
|
||||
<strong>오류:</strong> ${message}
|
||||
${details ? `<br><small>${details}</small>` : ''}
|
||||
</div>
|
||||
`;
|
||||
log('ERROR: ' + message, details);
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
document.getElementById('errorContainer').innerHTML = '';
|
||||
}
|
||||
|
||||
// API 설정 초기화
|
||||
async function initializeAPI() {
|
||||
log('API 초기화 시작');
|
||||
|
||||
// 기존 API 설정 파일 시도
|
||||
try {
|
||||
const module = await import('/js/api-config.js');
|
||||
if (module.API && module.API.BASE_URL) {
|
||||
API_URL = module.API.BASE_URL;
|
||||
log('API config 파일에서 설정 로드', API_URL);
|
||||
} else {
|
||||
log('API config 파일에서 BASE_URL을 찾을 수 없음');
|
||||
}
|
||||
} catch (error) {
|
||||
log('API config 파일 로드 실패, 기본값 사용', error.message);
|
||||
}
|
||||
|
||||
// API 연결 테스트
|
||||
await testAPIConnection();
|
||||
}
|
||||
|
||||
// API 연결 테스트 함수
|
||||
async function testAPIConnection() {
|
||||
const testUrls = [
|
||||
{ url: 'http://192.168.0.3:3005/api', hasApi: false }, // 직접 연결 우선
|
||||
{ url: 'http://192.168.0.3:3001', hasApi: true } // nginx 프록시
|
||||
];
|
||||
|
||||
for (const testConfig of testUrls) {
|
||||
try {
|
||||
const healthUrl = testConfig.hasApi
|
||||
? `${testConfig.url}/health`
|
||||
: `${testConfig.url}/health`;
|
||||
|
||||
log(`API 연결 테스트: ${healthUrl}`);
|
||||
const response = await fetch(healthUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.text();
|
||||
// HTML 응답이면 실패로 간주
|
||||
if (data.includes('<!DOCTYPE html>')) {
|
||||
log(`❌ HTML 응답 (로그인 페이지): ${healthUrl}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
API_URL = testConfig.hasApi ? testConfig.url : testConfig.url;
|
||||
log(`✅ API 연결 성공: ${API_URL}`);
|
||||
return;
|
||||
} else {
|
||||
log(`❌ API 연결 실패 (${response.status}): ${healthUrl}`);
|
||||
}
|
||||
} catch (error) {
|
||||
log(`❌ API 연결 오류: ${testConfig.url}`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 모든 URL 실패 시 직접 연결 사용
|
||||
API_URL = 'http://192.168.0.3:3005/api';
|
||||
log(`⚠️ 모든 연결 실패, 직접 연결 사용: ${API_URL}`);
|
||||
}
|
||||
|
||||
// 차트 초기화
|
||||
function initializeCharts() {
|
||||
log('차트 초기화 시작');
|
||||
|
||||
try {
|
||||
// 일별 작업시간 차트
|
||||
const dailyCtx = document.getElementById('dailyHoursChart').getContext('2d');
|
||||
dailyChart = new Chart(dailyCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
label: '작업시간',
|
||||
data: [],
|
||||
borderColor: '#667eea',
|
||||
backgroundColor: 'rgba(102, 126, 234, 0.1)',
|
||||
tension: 0.4,
|
||||
fill: true
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: '시간'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 작업자별 차트
|
||||
const workerCtx = document.getElementById('workerChart').getContext('2d');
|
||||
workerChart = new Chart(workerCtx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
data: [],
|
||||
backgroundColor: [
|
||||
'#667eea',
|
||||
'#764ba2',
|
||||
'#f093fb',
|
||||
'#f5576c',
|
||||
'#4facfe',
|
||||
'#43e97b'
|
||||
]
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 프로젝트별 차트
|
||||
const projectCtx = document.getElementById('projectChart').getContext('2d');
|
||||
projectChart = new Chart(projectCtx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
label: '투입시간',
|
||||
data: [],
|
||||
backgroundColor: '#667eea'
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: '시간'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 작업유형별 차트
|
||||
const workTypeCtx = document.getElementById('workTypeChart').getContext('2d');
|
||||
workTypeChart = new Chart(workTypeCtx, {
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
data: [],
|
||||
backgroundColor: [
|
||||
'#667eea',
|
||||
'#764ba2',
|
||||
'#f093fb',
|
||||
'#f5576c',
|
||||
'#4facfe',
|
||||
'#43e97b',
|
||||
'#f6d55c',
|
||||
'#ed4a7b'
|
||||
]
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
log('차트 초기화 완료');
|
||||
} catch (error) {
|
||||
log('차트 초기화 실패', error);
|
||||
showError('차트 초기화에 실패했습니다.', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 날짜 설정 함수들
|
||||
function setToday() {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
document.getElementById('startDate').value = today;
|
||||
document.getElementById('endDate').value = today;
|
||||
log('오늘 날짜 설정', today);
|
||||
loadData();
|
||||
}
|
||||
|
||||
function setThisWeek() {
|
||||
const today = new Date();
|
||||
const monday = new Date(today.setDate(today.getDate() - today.getDay() + 1));
|
||||
const sunday = new Date(today.setDate(today.getDate() - today.getDay() + 7));
|
||||
|
||||
const mondayStr = monday.toISOString().split('T')[0];
|
||||
const sundayStr = sunday.toISOString().split('T')[0];
|
||||
|
||||
document.getElementById('startDate').value = mondayStr;
|
||||
document.getElementById('endDate').value = sundayStr;
|
||||
log('이번주 날짜 설정', { start: mondayStr, end: sundayStr });
|
||||
loadData();
|
||||
}
|
||||
|
||||
function setThisMonth() {
|
||||
const today = new Date();
|
||||
const firstDay = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
const lastDay = new Date(today.getFullYear(), today.getMonth() + 1, 0);
|
||||
|
||||
const firstDayStr = firstDay.toISOString().split('T')[0];
|
||||
const lastDayStr = lastDay.toISOString().split('T')[0];
|
||||
|
||||
document.getElementById('startDate').value = firstDayStr;
|
||||
document.getElementById('endDate').value = lastDayStr;
|
||||
log('이번달 날짜 설정', { start: firstDayStr, end: lastDayStr });
|
||||
loadData();
|
||||
}
|
||||
|
||||
// 토큰 확인 함수
|
||||
function checkToken() {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
showError('로그인이 필요합니다.');
|
||||
setTimeout(() => {
|
||||
location.href = '/login';
|
||||
}, 2000);
|
||||
return null;
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
// API 호출 헬퍼 함수
|
||||
async function makeAPICall(url, options = {}) {
|
||||
log('API 호출', url);
|
||||
|
||||
const token = checkToken();
|
||||
if (!token) return null;
|
||||
|
||||
const defaultOptions = {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
};
|
||||
|
||||
const finalOptions = { ...defaultOptions, ...options };
|
||||
log('요청 옵션', finalOptions);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, finalOptions);
|
||||
log('응답 상태', { status: response.status, statusText: response.statusText });
|
||||
|
||||
const responseText = await response.text();
|
||||
log('응답 내용 (텍스트)', responseText);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${responseText}`);
|
||||
}
|
||||
|
||||
const data = JSON.parse(responseText);
|
||||
log('파싱된 데이터', data);
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
log('API 호출 실패', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 데이터 로딩
|
||||
async function loadData() {
|
||||
clearError();
|
||||
log('데이터 로딩 시작');
|
||||
|
||||
const startDate = document.getElementById('startDate').value;
|
||||
const endDate = document.getElementById('endDate').value;
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
showError('조회 기간을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
log('조회 기간', { startDate, endDate });
|
||||
|
||||
try {
|
||||
// 기본 통계 조회
|
||||
log('기본 통계 조회 시작');
|
||||
const statsUrl = `${API_URL}/work-analysis/stats?start=${startDate}&end=${endDate}`;
|
||||
const statsData = await makeAPICall(statsUrl);
|
||||
|
||||
if (statsData) {
|
||||
updateStats(statsData.data || statsData);
|
||||
}
|
||||
|
||||
// 일별 추이 데이터
|
||||
log('일별 추이 조회 시작');
|
||||
const dailyUrl = `${API_URL}/work-analysis/daily-trend?start=${startDate}&end=${endDate}`;
|
||||
const dailyData = await makeAPICall(dailyUrl);
|
||||
|
||||
if (dailyData) {
|
||||
updateDailyChart(dailyData.data || dailyData);
|
||||
}
|
||||
|
||||
// 작업자별 데이터
|
||||
log('작업자별 통계 조회 시작');
|
||||
const workerUrl = `${API_URL}/work-analysis/worker-stats?start=${startDate}&end=${endDate}`;
|
||||
const workerData = await makeAPICall(workerUrl);
|
||||
|
||||
if (workerData) {
|
||||
updateWorkerChart(workerData.data || workerData);
|
||||
}
|
||||
|
||||
// 프로젝트별 데이터
|
||||
log('프로젝트별 통계 조회 시작');
|
||||
const projectUrl = `${API_URL}/work-analysis/project-stats?start=${startDate}&end=${endDate}`;
|
||||
const projectData = await makeAPICall(projectUrl);
|
||||
|
||||
if (projectData) {
|
||||
updateProjectChart(projectData.data || projectData);
|
||||
}
|
||||
|
||||
// 작업유형별 데이터
|
||||
log('작업유형별 통계 조회 시작');
|
||||
const workTypeUrl = `${API_URL}/work-analysis/worktype-stats?start=${startDate}&end=${endDate}`;
|
||||
const workTypeData = await makeAPICall(workTypeUrl);
|
||||
|
||||
if (workTypeData) {
|
||||
updateWorkTypeChart(workTypeData.data || workTypeData);
|
||||
}
|
||||
|
||||
// 최근 작업 현황
|
||||
log('최근 작업 현황 조회 시작');
|
||||
const recentUrl = `${API_URL}/work-analysis/recent-work?start=${startDate}&end=${endDate}&limit=10`;
|
||||
const recentData = await makeAPICall(recentUrl);
|
||||
|
||||
if (recentData) {
|
||||
updateRecentWorkTable(recentData.data || recentData);
|
||||
}
|
||||
|
||||
log('모든 데이터 로딩 완료');
|
||||
|
||||
} catch (error) {
|
||||
log('데이터 로딩 오류', error);
|
||||
|
||||
if (error.message.includes('403')) {
|
||||
showError('접근 권한이 없습니다. 관리자에게 문의하세요.');
|
||||
} else if (error.message.includes('401')) {
|
||||
showError('로그인이 만료되었습니다. 다시 로그인해주세요.');
|
||||
setTimeout(() => {
|
||||
location.href = '/login';
|
||||
}, 2000);
|
||||
} else {
|
||||
showError('데이터를 불러오는 중 오류가 발생했습니다.', error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 통계 업데이트
|
||||
function updateStats(stats) {
|
||||
log('통계 업데이트', stats);
|
||||
|
||||
document.getElementById('totalHours').textContent = (stats.totalHours || 0).toFixed(1);
|
||||
document.getElementById('totalReports').textContent = stats.totalReports || 0;
|
||||
document.getElementById('activeProjects').textContent = stats.activeProjects || 0;
|
||||
document.getElementById('errorRate').textContent = (stats.errorRate || 0).toFixed(1) + '%';
|
||||
|
||||
// 변화율 표시 (임시 데이터)
|
||||
document.getElementById('hoursChange').textContent = '+12.5%';
|
||||
document.getElementById('reportsChange').textContent = '+8.3%';
|
||||
document.getElementById('projectsChange').textContent = '+2';
|
||||
document.getElementById('errorChange').textContent = '-0.5%';
|
||||
}
|
||||
|
||||
// 차트 업데이트 함수들
|
||||
function updateDailyChart(data) {
|
||||
log('일별 차트 업데이트', data);
|
||||
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
log('일별 차트 데이터 없음');
|
||||
return;
|
||||
}
|
||||
|
||||
dailyChart.data.labels = data.map(item => item.date);
|
||||
dailyChart.data.datasets[0].data = data.map(item => parseFloat(item.hours) || 0);
|
||||
dailyChart.update();
|
||||
}
|
||||
|
||||
function updateWorkerChart(data) {
|
||||
log('작업자 차트 업데이트', data);
|
||||
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
log('작업자 차트 데이터 없음');
|
||||
return;
|
||||
}
|
||||
|
||||
workerChart.data.labels = data.map(item => item.workerName || `작업자 ${item.worker_id}`);
|
||||
workerChart.data.datasets[0].data = data.map(item => parseFloat(item.totalHours) || 0);
|
||||
workerChart.update();
|
||||
}
|
||||
|
||||
function updateProjectChart(data) {
|
||||
log('프로젝트 차트 업데이트', data);
|
||||
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
log('프로젝트 차트 데이터 없음');
|
||||
return;
|
||||
}
|
||||
|
||||
projectChart.data.labels = data.map(item => item.projectName || `프로젝트 ${item.project_id}`);
|
||||
projectChart.data.datasets[0].data = data.map(item => parseFloat(item.totalHours) || 0);
|
||||
projectChart.update();
|
||||
}
|
||||
|
||||
function updateWorkTypeChart(data) {
|
||||
log('작업유형 차트 업데이트', data);
|
||||
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
log('작업유형 차트 데이터 없음');
|
||||
return;
|
||||
}
|
||||
|
||||
workTypeChart.data.labels = data.map(item => item.workTypeName || `작업유형 ${item.work_type_id}`);
|
||||
workTypeChart.data.datasets[0].data = data.map(item => parseFloat(item.totalHours) || 0);
|
||||
workTypeChart.update();
|
||||
}
|
||||
|
||||
// 테이블 업데이트
|
||||
function updateRecentWorkTable(data) {
|
||||
log('테이블 업데이트', data);
|
||||
|
||||
const tbody = document.getElementById('recentWorkTable');
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="text-align: center; color: #666;">조회된 데이터가 없습니다.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = data.map(item => `
|
||||
<tr>
|
||||
<td>${item.report_date}</td>
|
||||
<td>작업자 ${item.worker_id}</td>
|
||||
<td>프로젝트 ${item.project_id}</td>
|
||||
<td>작업유형 ${item.work_type_id}</td>
|
||||
<td>${item.work_hours}시간</td>
|
||||
<td><span class="status-badge ${getStatusClass(item.work_status_id)}">${getStatusText(item.work_status_id)}</span></td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function getStatusClass(statusId) {
|
||||
switch(statusId) {
|
||||
case 1: return 'status-completed';
|
||||
case 2: return 'status-progress';
|
||||
default: return 'status-pending';
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusText(statusId) {
|
||||
switch(statusId) {
|
||||
case 1: return '완료';
|
||||
case 2: return '진행중';
|
||||
default: return '대기';
|
||||
}
|
||||
}
|
||||
|
||||
// 페이지 초기화
|
||||
async function initializePage() {
|
||||
log('페이지 초기화 시작');
|
||||
|
||||
try {
|
||||
await initializeAPI();
|
||||
initializeCharts();
|
||||
setToday(); // 오늘 날짜로 초기 설정 및 데이터 로드
|
||||
|
||||
log('페이지 초기화 완료');
|
||||
} catch (error) {
|
||||
log('페이지 초기화 실패', error);
|
||||
showError('페이지 초기화에 실패했습니다.', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', initializePage);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,300 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>시스템 관리자 대시보드 - TK Portal</title>
|
||||
<link rel="stylesheet" href="/css/main-layout.css">
|
||||
<link rel="stylesheet" href="/css/admin.css">
|
||||
<link rel="stylesheet" href="/css/system-dashboard.css">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script type="module" src="/js/auth-check.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-layout">
|
||||
<!-- 기존 네비게이션 바 사용 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<!-- 시스템 관리자 배너 -->
|
||||
<div class="system-banner">
|
||||
<div class="banner-content">
|
||||
<div class="banner-left">
|
||||
<div class="system-icon">🔧</div>
|
||||
<div class="banner-text">
|
||||
<h1>시스템 관리자</h1>
|
||||
<p>시스템 전반의 설정, 모니터링 및 관리를 담당합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="banner-right">
|
||||
<span class="system-badge">SYSTEM</span>
|
||||
<div class="system-status">
|
||||
<span class="status-dot online"></span>
|
||||
<span>시스템 정상</span>
|
||||
</div>
|
||||
<div class="quick-actions">
|
||||
<button class="quick-btn" onclick="window.location.href='/pages/admin/manage-user.html'" title="사용자 관리">
|
||||
👤
|
||||
</button>
|
||||
<button class="quick-btn" onclick="window.location.href='/pages/analysis/work-report-analytics.html'" title="분석 대시보드">
|
||||
📊
|
||||
</button>
|
||||
<button class="quick-btn" onclick="window.location.href='/pages/analysis/project-worktype-analysis.html'" title="프로젝트별 작업 시간 분석">
|
||||
🏗️
|
||||
</button>
|
||||
<button class="quick-btn" onclick="refreshSystemStatus()" title="시스템 새로고침">
|
||||
🔄
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="main-content">
|
||||
<!-- 시스템 상태 개요 -->
|
||||
<section class="system-overview">
|
||||
<h2><i class="fas fa-tachometer-alt"></i> 시스템 상태</h2>
|
||||
<div class="status-grid">
|
||||
<div class="status-card">
|
||||
<div class="status-info">
|
||||
<h3>서버 상태</h3>
|
||||
<p class="status-value online">온라인</p>
|
||||
<small>마지막 확인: <span id="server-check-time">--</span></small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<div class="status-info">
|
||||
<h3>데이터베이스</h3>
|
||||
<p class="status-value online">정상</p>
|
||||
<small>연결 수: <span id="db-connections">--</span></small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<div class="status-info">
|
||||
<h3>활성 사용자</h3>
|
||||
<p class="status-value" id="active-users">--</p>
|
||||
<small>총 사용자: <span id="total-users">--</span></small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<div class="status-info">
|
||||
<h3>시스템 알림</h3>
|
||||
<p class="status-value warning" id="system-alerts">--</p>
|
||||
<small>미처리 알림</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 주요 관리 기능 -->
|
||||
<section class="management-section">
|
||||
<h2><i class="fas fa-tools"></i> 시스템 관리</h2>
|
||||
<div class="management-grid">
|
||||
<!-- 계정 관리 -->
|
||||
<div class="management-card primary">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-user-cog"></i>
|
||||
<h3>계정 관리</h3>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<p>사용자 계정 생성, 수정, 삭제 및 권한 관리</p>
|
||||
<div class="card-actions">
|
||||
<button class="btn btn-primary" data-action="account-management">
|
||||
<i class="fas fa-users"></i> 계정 관리
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 시스템 로그 -->
|
||||
<div class="management-card">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-file-alt"></i>
|
||||
<h3>시스템 로그</h3>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<p>로그인 이력, 시스템 활동 및 오류 로그 조회</p>
|
||||
<div class="card-actions">
|
||||
<button class="btn btn-secondary" data-action="system-logs">
|
||||
<i class="fas fa-search"></i> 로그 조회
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 데이터베이스 관리 -->
|
||||
<div class="management-card">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-database"></i>
|
||||
<h3>데이터베이스</h3>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<p>데이터베이스 백업, 복원 및 최적화</p>
|
||||
<div class="card-actions">
|
||||
<button class="btn btn-secondary" data-action="database-management">
|
||||
<i class="fas fa-cog"></i> DB 관리
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 시스템 설정 -->
|
||||
<div class="management-card">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-sliders-h"></i>
|
||||
<h3>시스템 설정</h3>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<p>전역 설정, 보안 정책 및 시스템 매개변수</p>
|
||||
<div class="card-actions">
|
||||
<button class="btn btn-secondary" data-action="system-settings">
|
||||
<i class="fas fa-wrench"></i> 설정
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 백업 관리 -->
|
||||
<div class="management-card">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-shield-alt"></i>
|
||||
<h3>백업 관리</h3>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<p>자동 백업 설정 및 복원 관리</p>
|
||||
<div class="card-actions">
|
||||
<button class="btn btn-secondary" data-action="backup-management">
|
||||
<i class="fas fa-download"></i> 백업
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 프로젝트별 작업 시간 분석 -->
|
||||
<div class="management-card primary">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-project-diagram"></i>
|
||||
<h3>프로젝트 작업 분석</h3>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<p>프로젝트별-작업별 시간 분석 및 에러율 모니터링</p>
|
||||
<div class="card-actions">
|
||||
<button class="btn btn-primary" onclick="window.location.href='/pages/analysis/project-worktype-analysis.html'">
|
||||
<i class="fas fa-chart-bar"></i> 분석 보기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 모니터링 -->
|
||||
<div class="management-card">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-chart-line"></i>
|
||||
<h3>시스템 모니터링</h3>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<p>성능 지표, 리소스 사용량 및 트래픽 분석</p>
|
||||
<div class="card-actions">
|
||||
<button class="btn btn-secondary" data-action="monitoring">
|
||||
<i class="fas fa-eye"></i> 모니터링
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 최근 활동 -->
|
||||
<section class="recent-activity">
|
||||
<h2><i class="fas fa-history"></i> 최근 시스템 활동</h2>
|
||||
<div class="activity-container">
|
||||
<div class="activity-list" id="recent-activities">
|
||||
<!-- 동적으로 로드됨 -->
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- 계정 관리 모달 -->
|
||||
<div id="account-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3><i class="fas fa-user-cog"></i> 계정 관리</h3>
|
||||
<button class="close-btn" data-action="close-modal">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="account-management-content">
|
||||
<!-- 계정 관리 내용이 여기에 로드됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 시스템 관리자 스크립트 -->
|
||||
<script>
|
||||
console.log('🔧 시스템 관리자 대시보드 로드됨');
|
||||
|
||||
// 시스템 상태 새로고침 함수
|
||||
function refreshSystemStatus() {
|
||||
console.log('🔄 시스템 상태 새로고침 중...');
|
||||
|
||||
// 시각적 피드백
|
||||
const statusDot = document.querySelector('.status-dot');
|
||||
const refreshBtn = document.querySelector('.quick-btn[title="시스템 새로고침"]');
|
||||
|
||||
if (refreshBtn) {
|
||||
refreshBtn.style.transform = 'rotate(360deg)';
|
||||
setTimeout(() => {
|
||||
refreshBtn.style.transform = '';
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// 실제 상태 업데이트 (시뮬레이션)
|
||||
setTimeout(() => {
|
||||
updateSystemTime();
|
||||
console.log('✅ 시스템 상태 업데이트 완료');
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// 시스템 시간 업데이트
|
||||
function updateSystemTime() {
|
||||
const timeElement = document.getElementById('server-check-time');
|
||||
if (timeElement) {
|
||||
timeElement.textContent = new Date().toLocaleTimeString('ko-KR');
|
||||
}
|
||||
}
|
||||
|
||||
// 간단한 테스트 함수
|
||||
function testClick() {
|
||||
console.log('🎯 버튼 클릭 테스트 성공!');
|
||||
alert('버튼이 정상적으로 작동합니다!');
|
||||
}
|
||||
|
||||
// DOM 로드 후 초기화
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('📄 시스템 대시보드 DOM 로드 완료');
|
||||
|
||||
// 초기 시간 설정
|
||||
updateSystemTime();
|
||||
|
||||
// 주기적 시간 업데이트 (30초마다)
|
||||
setInterval(updateSystemTime, 30000);
|
||||
|
||||
// 계정 관리 버튼 이벤트
|
||||
const accountBtn = document.querySelector('[data-action="account-management"]');
|
||||
if (accountBtn) {
|
||||
accountBtn.addEventListener('click', testClick);
|
||||
console.log('✅ 계정 관리 버튼 이벤트 설정 완료');
|
||||
}
|
||||
|
||||
console.log('🚀 시스템 관리자 대시보드 초기화 완료');
|
||||
});
|
||||
</script>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/system-dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,70 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>개인 페이지 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/main-layout.css">
|
||||
<link rel="stylesheet" href="/css/user.css">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<!-- ✅ auth-check를 가장 먼저 로딩 -->
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-layout">
|
||||
<!-- ✅ ID는 이미 올바름: navbar-container -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<div id="content-container">
|
||||
<header class="user-header">
|
||||
<h1>👷 내 작업 정보</h1>
|
||||
<p id="welcome-message">환영합니다. 개인 작업 포털입니다.</p>
|
||||
</header>
|
||||
|
||||
<main id="user-sections">
|
||||
<section class="card">
|
||||
<h2>📅 오늘의 작업 일정</h2>
|
||||
<div id="today-schedule">
|
||||
<p>작업 일정을 불러오는 중...</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>🔧 빠른 메뉴</h2>
|
||||
<div class="quick-menu">
|
||||
<a href="/pages/work-reports/create.html" class="menu-item">
|
||||
<span class="icon">📝</span>
|
||||
<span>작업 일보 작성</span>
|
||||
</a>
|
||||
<a href="/pages/issue-reports/daily-issue.html" class="menu-item">
|
||||
<span class="icon">📊</span>
|
||||
<span>일일 이슈 보고</span>
|
||||
</a>
|
||||
<a href="/pages/common/my-attendance.html" class="menu-item">
|
||||
<span class="icon">📋</span>
|
||||
<span>출근부 확인</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>📈 내 작업 현황</h2>
|
||||
<div id="work-stats">
|
||||
<p>통계를 불러오는 중...</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ✅ 스크립트 로딩 순서 최적화 -->
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module" src="/js/load-sections.js"></script>
|
||||
<script type="module" src="/js/user-dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,215 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>관리자 대시보드 - 일일 작업 입력 현황 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/main-layout.css">
|
||||
<link rel="stylesheet" href="/css/management-dashboard.css">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script type="module" src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-layout-with-navbar">
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<div class="dashboard-container">
|
||||
<!-- 뒤로가기 버튼 -->
|
||||
<a href="javascript:history.back()" class="back-btn">
|
||||
← 뒤로가기
|
||||
</a>
|
||||
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="page-header">
|
||||
<h1>📊 관리자 대시보드</h1>
|
||||
<p class="subtitle">팀 전체의 일일 작업 입력 현황을 한눈에 확인하세요</p>
|
||||
</div>
|
||||
|
||||
<!-- 권한 체크 메시지 -->
|
||||
<div id="permission-check-message" class="message warning" style="display: none;">
|
||||
⚠️ 권한을 확인하는 중입니다...
|
||||
</div>
|
||||
|
||||
<!-- 메시지 영역 -->
|
||||
<div id="message-container"></div>
|
||||
|
||||
<!-- 날짜 선택 섹션 -->
|
||||
<div class="date-selection-card">
|
||||
<div class="date-selection-header">
|
||||
<h3>📅 조회 날짜 선택</h3>
|
||||
<button class="refresh-btn" id="refreshBtn">
|
||||
🔄 새로고침
|
||||
</button>
|
||||
</div>
|
||||
<div class="date-selection-body">
|
||||
<input type="date" id="selectedDate" class="date-input">
|
||||
<button class="btn btn-primary" id="loadDataBtn">📊 현황 조회</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 요약 대시보드 -->
|
||||
<div id="summarySection" class="summary-section" style="display: none;">
|
||||
<h3>📈 전체 현황 요약</h3>
|
||||
<div class="summary-grid">
|
||||
<div class="summary-card total-workers">
|
||||
<div class="summary-icon">👥</div>
|
||||
<div class="summary-content">
|
||||
<div class="summary-number" id="totalWorkers">0</div>
|
||||
<div class="summary-label">전체 작업자</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-card completed-workers">
|
||||
<div class="summary-icon">✅</div>
|
||||
<div class="summary-content">
|
||||
<div class="summary-number" id="completedWorkers">0</div>
|
||||
<div class="summary-label">입력 완료</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-card missing-workers">
|
||||
<div class="summary-icon">❌</div>
|
||||
<div class="summary-content">
|
||||
<div class="summary-number" id="missingWorkers">0</div>
|
||||
<div class="summary-label">입력 미완료</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-card total-hours">
|
||||
<div class="summary-icon">⏰</div>
|
||||
<div class="summary-content">
|
||||
<div class="summary-number" id="totalHours">0</div>
|
||||
<div class="summary-label">총 작업시간</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-card total-entries">
|
||||
<div class="summary-icon">📝</div>
|
||||
<div class="summary-content">
|
||||
<div class="summary-number" id="totalEntries">0</div>
|
||||
<div class="summary-label">총 작업항목</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-card error-count">
|
||||
<div class="summary-icon">⚠️</div>
|
||||
<div class="summary-content">
|
||||
<div class="summary-number" id="errorCount">0</div>
|
||||
<div class="summary-label">에러 발생</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 필터 및 액션 바 -->
|
||||
<div id="actionBar" class="action-bar" style="display: none;">
|
||||
<div class="filter-section">
|
||||
<label class="filter-checkbox">
|
||||
<input type="checkbox" id="showOnlyMissing">
|
||||
<span class="checkmark"></span>
|
||||
미입력자만 보기
|
||||
</label>
|
||||
</div>
|
||||
<div class="action-section">
|
||||
<button class="btn btn-secondary" id="exportBtn">
|
||||
📥 엑셀 다운로드
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업자 현황 테이블 -->
|
||||
<div id="workersSection" class="workers-section" style="display: none;">
|
||||
<div class="section-header">
|
||||
<h3>👥 작업자별 입력 현황</h3>
|
||||
<div class="legend">
|
||||
<span class="legend-item completed">✅ 입력완료</span>
|
||||
<span class="legend-item missing">❌ 미입력</span>
|
||||
<span class="legend-item partial">⚠️ 부분입력</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table class="workers-table" id="workersTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>작업자</th>
|
||||
<th>상태</th>
|
||||
<th>총시간</th>
|
||||
<th>항목수</th>
|
||||
<th>작업유형</th>
|
||||
<th>프로젝트</th>
|
||||
<th>기여자</th>
|
||||
<th>최근업데이트</th>
|
||||
<th>상세</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="workersTableBody">
|
||||
<!-- 작업자 데이터가 여기에 동적으로 추가됩니다 -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 로딩 스피너 -->
|
||||
<div id="loadingSpinner" class="loading-spinner" style="display: none;">
|
||||
<div class="spinner"></div>
|
||||
<p>데이터를 불러오는 중...</p>
|
||||
</div>
|
||||
|
||||
<!-- 데이터 없음 메시지 -->
|
||||
<div id="noDataMessage" class="no-data-message" style="display: none;">
|
||||
<div class="no-data-icon">📭</div>
|
||||
<h3>표시할 데이터가 없습니다</h3>
|
||||
<p>선택한 날짜에 입력된 작업 데이터가 없거나<br>조회 권한이 없습니다.</p>
|
||||
</div>
|
||||
|
||||
<!-- 사용법 안내 -->
|
||||
<div class="guide-section">
|
||||
<h3>📖 사용 가이드</h3>
|
||||
<div class="guide-grid">
|
||||
<div class="guide-item">
|
||||
<div class="guide-icon">📅</div>
|
||||
<strong>날짜 선택</strong><br>
|
||||
확인하고 싶은 날짜를 선택하세요
|
||||
</div>
|
||||
<div class="guide-item">
|
||||
<div class="guide-icon">📊</div>
|
||||
<strong>현황 확인</strong><br>
|
||||
팀 전체의 입력 현황을 확인하세요
|
||||
</div>
|
||||
<div class="guide-item">
|
||||
<div class="guide-icon">🔍</div>
|
||||
<strong>필터링</strong><br>
|
||||
미입력자만 따로 확인할 수 있습니다
|
||||
</div>
|
||||
<div class="guide-item">
|
||||
<div class="guide-icon">📥</div>
|
||||
<strong>내보내기</strong><br>
|
||||
엑셀로 데이터를 다운로드하세요
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업자 상세 모달 -->
|
||||
<div id="workerDetailModal" class="worker-detail-modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="modalWorkerName">작업자 상세</h3>
|
||||
<button class="close-modal-btn" onclick="closeWorkerDetailModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body" id="modalWorkerDetails">
|
||||
<!-- 작업자 상세 정보가 여기에 표시됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 스크립트 -->
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/management-dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,151 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>나의 출근 현황 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/main-layout.css">
|
||||
<link rel="stylesheet" href="/css/attendance.css">
|
||||
<link rel="stylesheet" href="/css/my-attendance.css">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-layout">
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<div id="content-container">
|
||||
<!-- 페이지 헤더 -->
|
||||
<header class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">
|
||||
<span class="title-icon">📊</span>
|
||||
나의 출근 현황
|
||||
</h1>
|
||||
<p class="page-description">나의 출근 기록과 근태 현황을 확인할 수 있습니다</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 필터 섹션 -->
|
||||
<div class="controls">
|
||||
<label for="yearSelect">연도:</label>
|
||||
<select id="yearSelect"></select>
|
||||
|
||||
<label for="monthSelect">월:</label>
|
||||
<select id="monthSelect"></select>
|
||||
|
||||
<button id="loadAttendance" class="btn-primary">조회</button>
|
||||
</div>
|
||||
|
||||
<!-- 통계 카드 섹션 -->
|
||||
<section class="stats-section">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">⏱️</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value" id="totalHours">-</div>
|
||||
<div class="stat-label">총 근무시간</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">📅</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value" id="totalDays">-</div>
|
||||
<div class="stat-label">근무일수</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">🌴</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value" id="remainingLeave">-</div>
|
||||
<div class="stat-label">잔여 연차</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 탭 섹션 -->
|
||||
<div class="tab-container">
|
||||
<button class="tab-btn active" data-tab="list">
|
||||
<span class="tab-icon">📋</span> 리스트 보기
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="calendar">
|
||||
<span class="tab-icon">📅</span> 달력 보기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 리스트 뷰 -->
|
||||
<div id="listView" class="tab-content active">
|
||||
<div id="attendanceTableContainer">
|
||||
<table id="attendanceTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>날짜</th>
|
||||
<th>요일</th>
|
||||
<th>출근시간</th>
|
||||
<th>퇴근시간</th>
|
||||
<th>근무시간</th>
|
||||
<th>상태</th>
|
||||
<th>비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="attendanceTableBody">
|
||||
<tr>
|
||||
<td colspan="7" class="loading-cell">데이터를 불러오는 중...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 달력 뷰 -->
|
||||
<div id="calendarView" class="tab-content">
|
||||
<div id="calendarContainer">
|
||||
<div class="calendar-header">
|
||||
<button id="prevMonth" class="calendar-nav-btn">◀</button>
|
||||
<h3 id="calendarTitle">2026년 1월</h3>
|
||||
<button id="nextMonth" class="calendar-nav-btn">▶</button>
|
||||
</div>
|
||||
<div id="calendarGrid" class="calendar-grid">
|
||||
<!-- 달력이 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
<div class="calendar-legend">
|
||||
<span class="legend-item"><span class="legend-dot normal"></span> 정상</span>
|
||||
<span class="legend-item"><span class="legend-dot late"></span> 지각</span>
|
||||
<span class="legend-item"><span class="legend-dot early"></span> 조퇴</span>
|
||||
<span class="legend-item"><span class="legend-dot absent"></span> 결근</span>
|
||||
<span class="legend-item"><span class="legend-dot vacation"></span> 휴가</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 일별 상세 모달 -->
|
||||
<div id="detailModal" class="modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 id="modalTitle">출근 상세 정보</h2>
|
||||
<button class="modal-close-btn" onclick="closeDetailModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body" id="modalBody">
|
||||
<!-- 상세 정보가 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" onclick="closeDetailModal()">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 스크립트 로딩 -->
|
||||
<script type="module" src="/js/api-config.js"></script>
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script src="/js/my-attendance.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,111 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>나의 대시보드 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/common.css?v=2">
|
||||
<link rel="stylesheet" href="/css/my-dashboard.css?v=1">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<main class="dashboard-container">
|
||||
<a href="javascript:history.back()" class="back-button">
|
||||
← 뒤로가기
|
||||
</a>
|
||||
|
||||
<header class="page-header">
|
||||
<h1>📊 나의 대시보드</h1>
|
||||
<p>안녕하세요, <span id="userName"></span>님!</p>
|
||||
</header>
|
||||
|
||||
<!-- 사용자 정보 카드 -->
|
||||
<section class="user-info-card">
|
||||
<div class="info-row">
|
||||
<div class="info-item">
|
||||
<span class="label">부서:</span>
|
||||
<span id="department">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">직책:</span>
|
||||
<span id="jobType">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">입사일:</span>
|
||||
<span id="hireDate">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 연차 정보 위젯 -->
|
||||
<section class="vacation-widget">
|
||||
<h2>💼 연차 정보</h2>
|
||||
<div class="vacation-summary">
|
||||
<div class="stat">
|
||||
<span class="label">총 연차</span>
|
||||
<span class="value" id="totalLeave">15</span>일
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="label">사용</span>
|
||||
<span class="value used" id="usedLeave">0</span>일
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="label">잔여</span>
|
||||
<span class="value remaining" id="remainingLeave">15</span>일
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-bar">
|
||||
<div class="progress" id="vacationProgress" style="width: 0%"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 월별 출근 캘린더 -->
|
||||
<section class="calendar-section">
|
||||
<h2>📅 이번 달 출근 현황</h2>
|
||||
<div class="calendar-controls">
|
||||
<button onclick="previousMonth()">◀</button>
|
||||
<span id="currentMonth">2026년 1월</span>
|
||||
<button onclick="nextMonth()">▶</button>
|
||||
</div>
|
||||
<div id="calendar" class="calendar-grid">
|
||||
<!-- 동적 생성 -->
|
||||
</div>
|
||||
<div class="calendar-legend">
|
||||
<span><span class="dot normal"></span> 정상</span>
|
||||
<span><span class="dot late"></span> 지각</span>
|
||||
<span><span class="dot vacation"></span> 휴가</span>
|
||||
<span><span class="dot absent"></span> 결근</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 근무 시간 통계 -->
|
||||
<section class="work-hours-stats">
|
||||
<h2>⏱️ 근무 시간 통계</h2>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<span class="label">이번 달</span>
|
||||
<span class="value" id="monthHours">0</span>시간
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="label">근무 일수</span>
|
||||
<span class="value" id="workDays">0</span>일
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 최근 작업 보고서 -->
|
||||
<section class="recent-reports">
|
||||
<h2>📝 최근 작업 보고서</h2>
|
||||
<div id="recentReportsList">
|
||||
<p class="empty-message">최근 7일간의 작업 보고서가 없습니다.</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js?v=5"></script>
|
||||
<script type="module" src="/js/my-dashboard.js?v=1"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,304 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>프로젝트 투입 분석 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/main-layout.css">
|
||||
<link rel="stylesheet" href="/css/admin.css">
|
||||
<link rel="stylesheet" href="/css/work-report.css">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
<style>
|
||||
.period-selector {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.period-selector label {
|
||||
font-weight: bold;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.period-selector input[type="date"] {
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.analysis-tabs {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
background: #f5f5f5;
|
||||
cursor: pointer;
|
||||
border-radius: 4px 4px 0 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.analysis-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.analysis-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.summary-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
border-left: 4px solid #007bff;
|
||||
}
|
||||
|
||||
.summary-card h4 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.summary-card .value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #007bff;
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
background: #f8f9fa;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.data-table .project-col {
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.data-table .worker-col {
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.data-table .task-col {
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.data-table .hours-col {
|
||||
text-align: right;
|
||||
font-weight: bold;
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
background: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-row select {
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
min-width: 120px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-layout">
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<div id="content-container">
|
||||
<div class="page-header">
|
||||
<h1>📊 프로젝트 투입 분석</h1>
|
||||
<p class="subtitle">기간별 프로젝트/작업자/작업 투입 현황을 분석합니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>📅 분석 기간 설정</h3>
|
||||
<div class="period-selector">
|
||||
<label for="startDate">시작일:</label>
|
||||
<input type="date" id="startDate">
|
||||
|
||||
<label for="endDate">종료일:</label>
|
||||
<input type="date" id="endDate">
|
||||
|
||||
<button id="analyzeBtn" class="btn btn-primary">분석 실행</button>
|
||||
<button id="quickMonth" class="btn btn-secondary">이번 달</button>
|
||||
<button id="quickLastMonth" class="btn btn-secondary">지난 달</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="analysisCard" style="display: none;">
|
||||
<div class="summary-cards" id="summaryCards">
|
||||
<!-- 요약 정보가 여기에 동적으로 추가됩니다 -->
|
||||
</div>
|
||||
|
||||
<div class="filter-section">
|
||||
<h4>🔍 필터 옵션</h4>
|
||||
<div class="filter-row">
|
||||
<label>프로젝트:</label>
|
||||
<select id="projectFilter">
|
||||
<option value="">전체</option>
|
||||
</select>
|
||||
|
||||
<label>작업자:</label>
|
||||
<select id="workerFilter">
|
||||
<option value="">전체</option>
|
||||
</select>
|
||||
|
||||
<label>작업 분류:</label>
|
||||
<select id="taskFilter">
|
||||
<option value="">전체</option>
|
||||
</select>
|
||||
|
||||
<button id="applyFilter" class="btn btn-primary">필터 적용</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="analysis-tabs">
|
||||
<button class="tab-button active" data-tab="project">프로젝트별</button>
|
||||
<button class="tab-button" data-tab="worker">작업자별</button>
|
||||
<button class="tab-button" data-tab="task">작업별</button>
|
||||
<button class="tab-button" data-tab="detail">상세내역</button>
|
||||
</div>
|
||||
|
||||
<div id="projectTab" class="analysis-content active">
|
||||
<h4>📋 프로젝트별 투입 현황</h4>
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="50">순번</th>
|
||||
<th>프로젝트명</th>
|
||||
<th width="100">투입 시간</th>
|
||||
<th width="80">비율</th>
|
||||
<th width="100">참여 인원</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="projectTableBody">
|
||||
<tr><td colspan="5" class="no-data">분석을 실행해주세요</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="workerTab" class="analysis-content">
|
||||
<h4>👥 작업자별 투입 현황</h4>
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="50">순번</th>
|
||||
<th>작업자명</th>
|
||||
<th width="100">투입 시간</th>
|
||||
<th width="80">비율</th>
|
||||
<th width="100">참여 프로젝트</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="workerTableBody">
|
||||
<tr><td colspan="5" class="no-data">분석을 실행해주세요</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="taskTab" class="analysis-content">
|
||||
<h4>⚙️ 작업별 투입 현황</h4>
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="50">순번</th>
|
||||
<th>작업 분류</th>
|
||||
<th width="100">투입 시간</th>
|
||||
<th width="80">비율</th>
|
||||
<th width="100">참여 인원</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="taskTableBody">
|
||||
<tr><td colspan="5" class="no-data">분석을 실행해주세요</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="detailTab" class="analysis-content">
|
||||
<h4>📄 상세 내역</h4>
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="50">순번</th>
|
||||
<th width="100">날짜</th>
|
||||
<th>프로젝트</th>
|
||||
<th>작업자</th>
|
||||
<th>작업 분류</th>
|
||||
<th width="80">시간</th>
|
||||
<th>메모</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="detailTableBody">
|
||||
<tr><td colspan="7" class="no-data">분석을 실행해주세요</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module" src="/js/project-analysis.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,672 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>프로젝트별 작업 시간 분석</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
.project-card {
|
||||
transition: all 0.3s ease;
|
||||
border-left: 4px solid #3B82F6;
|
||||
}
|
||||
.project-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
|
||||
}
|
||||
.work-type-row {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
.work-type-row:hover {
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
.error-high { border-left-color: #EF4444; }
|
||||
.error-medium { border-left-color: #F59E0B; }
|
||||
.error-low { border-left-color: #10B981; }
|
||||
|
||||
.progress-bar {
|
||||
transition: width 0.8s ease-in-out;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
border: 3px solid #f3f3f3;
|
||||
border-top: 3px solid #3498db;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stat-card.error {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
}
|
||||
|
||||
.stat-card.regular {
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
}
|
||||
|
||||
.stat-card.total {
|
||||
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
<!-- 헤더 -->
|
||||
<header class="bg-white shadow-lg border-b">
|
||||
<div class="container mx-auto px-6 py-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-800">🏗️ 프로젝트별 작업 시간 분석</h1>
|
||||
<p class="text-gray-600 mt-1">총시간 · 정규시간 · 에러시간 상세 분석</p>
|
||||
<div class="mt-2">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
|
||||
🔧 시스템 관리자 전용
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="text-sm text-gray-500">
|
||||
<span id="last-updated">마지막 업데이트: -</span>
|
||||
</div>
|
||||
<button id="refresh-btn" class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-lg transition-colors">
|
||||
🔄 새로고침
|
||||
</button>
|
||||
<button onclick="history.back()" class="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded-lg transition-colors">
|
||||
← 뒤로가기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 날짜 선택 -->
|
||||
<div class="container mx-auto px-6 py-6">
|
||||
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<div class="flex items-center space-x-2">
|
||||
<label for="start-date" class="text-sm font-medium text-gray-700">시작일:</label>
|
||||
<input type="date" id="start-date" class="border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<label for="end-date" class="text-sm font-medium text-gray-700">종료일:</label>
|
||||
<input type="date" id="end-date" class="border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
</div>
|
||||
<button id="analyze-btn" class="bg-green-500 hover:bg-green-600 text-white px-6 py-2 rounded-md text-sm font-medium transition-colors">
|
||||
📊 분석 실행
|
||||
</button>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button id="preset-week" class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-3 py-1 rounded text-xs">최근 1주일</button>
|
||||
<button id="preset-month" class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-3 py-1 rounded text-xs">최근 1개월</button>
|
||||
<button id="preset-august" class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-3 py-1 rounded text-xs">8월 전체</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 로딩 화면 -->
|
||||
<div id="loading" class="hidden fixed inset-0 bg-white bg-opacity-90 flex items-center justify-center z-50">
|
||||
<div class="text-center">
|
||||
<div class="loading-spinner mx-auto mb-4"></div>
|
||||
<p class="text-gray-600">데이터를 분석하는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 메인 컨테이너 -->
|
||||
<div class="container mx-auto px-6 pb-8" id="main-content">
|
||||
|
||||
<!-- 전체 요약 통계 -->
|
||||
<div id="summary-stats" class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8 hidden">
|
||||
<div class="stat-card total rounded-lg shadow-md p-6 text-center">
|
||||
<div class="text-3xl font-bold" id="total-hours">-</div>
|
||||
<div class="text-sm opacity-90">총 작업시간</div>
|
||||
</div>
|
||||
<div class="stat-card regular rounded-lg shadow-md p-6 text-center">
|
||||
<div class="text-3xl font-bold" id="regular-hours">-</div>
|
||||
<div class="text-sm opacity-90">정규 시간</div>
|
||||
</div>
|
||||
<div class="stat-card error rounded-lg shadow-md p-6 text-center">
|
||||
<div class="text-3xl font-bold" id="error-hours">-</div>
|
||||
<div class="text-sm opacity-90">에러 시간</div>
|
||||
</div>
|
||||
<div class="stat-card rounded-lg shadow-md p-6 text-center">
|
||||
<div class="text-3xl font-bold" id="error-rate">-</div>
|
||||
<div class="text-sm opacity-90">전체 에러율</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 차트 섹션 -->
|
||||
<div id="charts-section" class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8 hidden">
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
<h3 class="text-lg font-semibold mb-4">프로젝트별 시간 분포</h3>
|
||||
<canvas id="project-chart"></canvas>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
<h3 class="text-lg font-semibold mb-4">에러율 분석</h3>
|
||||
<canvas id="error-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 프로젝트별 상세 데이터 -->
|
||||
<div id="projects-container" class="space-y-6">
|
||||
<!-- 프로젝트 카드들이 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
|
||||
<!-- 데이터 없음 메시지 -->
|
||||
<div id="no-data" class="hidden text-center py-12">
|
||||
<div class="text-gray-400 text-6xl mb-4">📊</div>
|
||||
<h3 class="text-xl font-semibold text-gray-600 mb-2">분석할 데이터가 없습니다</h3>
|
||||
<p class="text-gray-500">날짜 범위를 선택하고 분석을 실행해주세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 기존 인증 시스템 사용 -->
|
||||
<script>
|
||||
// 전역 변수
|
||||
let analysisData = null;
|
||||
let charts = {};
|
||||
|
||||
// API 호출 함수 (토큰 포함)
|
||||
async function apiCall(endpoint, options = {}) {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
throw new Error('인증 토큰이 없습니다.');
|
||||
}
|
||||
|
||||
const defaultHeaders = {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
const response = await fetch(`http://localhost:20005/api${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
...defaultHeaders,
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// DOM 요소들
|
||||
const elements = {
|
||||
startDate: document.getElementById('start-date'),
|
||||
endDate: document.getElementById('end-date'),
|
||||
analyzeBtn: document.getElementById('analyze-btn'),
|
||||
refreshBtn: document.getElementById('refresh-btn'),
|
||||
loading: document.getElementById('loading'),
|
||||
mainContent: document.getElementById('main-content'),
|
||||
summaryStats: document.getElementById('summary-stats'),
|
||||
chartsSection: document.getElementById('charts-section'),
|
||||
projectsContainer: document.getElementById('projects-container'),
|
||||
noData: document.getElementById('no-data'),
|
||||
lastUpdated: document.getElementById('last-updated'),
|
||||
presetWeek: document.getElementById('preset-week'),
|
||||
presetMonth: document.getElementById('preset-month'),
|
||||
presetAugust: document.getElementById('preset-august')
|
||||
};
|
||||
|
||||
// 초기화
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 로그인 확인
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
alert('로그인이 필요합니다.');
|
||||
window.location.href = '/index.html';
|
||||
return;
|
||||
}
|
||||
|
||||
// 사용자 정보 및 권한 확인
|
||||
const userStr = localStorage.getItem('user');
|
||||
if (!userStr) {
|
||||
alert('사용자 정보를 찾을 수 없습니다.');
|
||||
window.location.href = '/index.html';
|
||||
return;
|
||||
}
|
||||
|
||||
const user = JSON.parse(userStr);
|
||||
|
||||
// 시스템 권한 확인 (system 역할만 접근 가능)
|
||||
if (user.role !== 'system') {
|
||||
alert('시스템 관리자 권한이 필요합니다.');
|
||||
window.location.href = '/pages/dashboard/user.html'; // 일반 사용자 대시보드로 리디렉션
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('시스템 관리자 인증 완료:', user.name || user.username);
|
||||
|
||||
initializeDateInputs();
|
||||
bindEventListeners();
|
||||
|
||||
// 8월 전체를 기본값으로 설정
|
||||
setDatePreset('august');
|
||||
});
|
||||
|
||||
// 날짜 입력 초기화
|
||||
function initializeDateInputs() {
|
||||
const today = new Date();
|
||||
const oneMonthAgo = new Date(today.getFullYear(), today.getMonth() - 1, today.getDate());
|
||||
|
||||
elements.endDate.value = today.toISOString().split('T')[0];
|
||||
elements.startDate.value = oneMonthAgo.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
// 이벤트 리스너 바인딩
|
||||
function bindEventListeners() {
|
||||
elements.analyzeBtn.addEventListener('click', performAnalysis);
|
||||
elements.refreshBtn.addEventListener('click', performAnalysis);
|
||||
|
||||
// 날짜 프리셋 버튼들
|
||||
elements.presetWeek.addEventListener('click', () => setDatePreset('week'));
|
||||
elements.presetMonth.addEventListener('click', () => setDatePreset('month'));
|
||||
elements.presetAugust.addEventListener('click', () => setDatePreset('august'));
|
||||
|
||||
// Enter 키로 분석 실행
|
||||
elements.startDate.addEventListener('keypress', handleEnterKey);
|
||||
elements.endDate.addEventListener('keypress', handleEnterKey);
|
||||
}
|
||||
|
||||
// Enter 키 처리
|
||||
function handleEnterKey(event) {
|
||||
if (event.key === 'Enter') {
|
||||
performAnalysis();
|
||||
}
|
||||
}
|
||||
|
||||
// 날짜 프리셋 설정
|
||||
function setDatePreset(preset) {
|
||||
const today = new Date();
|
||||
let startDate, endDate;
|
||||
|
||||
switch (preset) {
|
||||
case 'week':
|
||||
startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 7);
|
||||
endDate = today;
|
||||
break;
|
||||
case 'month':
|
||||
startDate = new Date(today.getFullYear(), today.getMonth() - 1, today.getDate());
|
||||
endDate = today;
|
||||
break;
|
||||
case 'august':
|
||||
startDate = new Date(2025, 7, 1); // 2025년 8월 1일
|
||||
endDate = new Date(2025, 7, 31); // 2025년 8월 31일
|
||||
break;
|
||||
}
|
||||
|
||||
elements.startDate.value = startDate.toISOString().split('T')[0];
|
||||
elements.endDate.value = endDate.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
// 분석 실행
|
||||
async function performAnalysis() {
|
||||
const startDate = elements.startDate.value;
|
||||
const endDate = elements.endDate.value;
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
alert('시작일과 종료일을 모두 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (new Date(startDate) > new Date(endDate)) {
|
||||
alert('시작일이 종료일보다 늦을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 로그인 확인
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
alert('로그인이 필요합니다.');
|
||||
window.location.href = '/index.html';
|
||||
return;
|
||||
}
|
||||
|
||||
showLoading(true);
|
||||
|
||||
try {
|
||||
const response = await apiCall(`/work-analysis/project-worktype-analysis?start=${startDate}&end=${endDate}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
analysisData = result.data;
|
||||
renderAnalysisResults();
|
||||
updateLastUpdated();
|
||||
} else {
|
||||
throw new Error(result.error || '데이터 조회에 실패했습니다.');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('분석 실패:', error);
|
||||
showError(`분석 실패: ${error.message}`);
|
||||
} finally {
|
||||
showLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 로딩 표시/숨김
|
||||
function showLoading(show) {
|
||||
elements.loading.classList.toggle('hidden', !show);
|
||||
}
|
||||
|
||||
// 에러 표시
|
||||
function showError(message) {
|
||||
alert(message);
|
||||
}
|
||||
|
||||
// 분석 결과 렌더링
|
||||
function renderAnalysisResults() {
|
||||
if (!analysisData || !analysisData.projects || analysisData.projects.length === 0) {
|
||||
showNoData();
|
||||
return;
|
||||
}
|
||||
|
||||
hideNoData();
|
||||
renderSummaryStats();
|
||||
renderCharts();
|
||||
renderProjectCards();
|
||||
}
|
||||
|
||||
// 데이터 없음 표시
|
||||
function showNoData() {
|
||||
elements.summaryStats.classList.add('hidden');
|
||||
elements.chartsSection.classList.add('hidden');
|
||||
elements.projectsContainer.innerHTML = '';
|
||||
elements.noData.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// 데이터 없음 숨김
|
||||
function hideNoData() {
|
||||
elements.noData.classList.add('hidden');
|
||||
elements.summaryStats.classList.remove('hidden');
|
||||
elements.chartsSection.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// 요약 통계 렌더링
|
||||
function renderSummaryStats() {
|
||||
const summary = analysisData.summary;
|
||||
|
||||
document.getElementById('total-hours').textContent = `${(summary.grand_total_hours || 0).toFixed(1)}h`;
|
||||
document.getElementById('regular-hours').textContent = `${(summary.grand_regular_hours || 0).toFixed(1)}h`;
|
||||
document.getElementById('error-hours').textContent = `${(summary.grand_error_hours || 0).toFixed(1)}h`;
|
||||
document.getElementById('error-rate').textContent = `${summary.grand_error_rate || 0}%`;
|
||||
}
|
||||
|
||||
// 차트 렌더링
|
||||
function renderCharts() {
|
||||
renderProjectChart();
|
||||
renderErrorChart();
|
||||
}
|
||||
|
||||
// 프로젝트별 시간 분포 차트
|
||||
function renderProjectChart() {
|
||||
const ctx = document.getElementById('project-chart').getContext('2d');
|
||||
|
||||
// 기존 차트 삭제
|
||||
if (charts.project) {
|
||||
charts.project.destroy();
|
||||
}
|
||||
|
||||
const projects = analysisData.projects;
|
||||
const labels = projects.map(p => p.project_name || 'Unknown Project');
|
||||
const totalHours = projects.map(p => p.total_project_hours || 0);
|
||||
const regularHours = projects.map(p => p.total_regular_hours || 0);
|
||||
const errorHours = projects.map(p => p.total_error_hours || 0);
|
||||
|
||||
charts.project = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{
|
||||
label: '정규 시간',
|
||||
data: regularHours,
|
||||
backgroundColor: '#10B981',
|
||||
borderColor: '#059669',
|
||||
borderWidth: 1
|
||||
},
|
||||
{
|
||||
label: '에러 시간',
|
||||
data: errorHours,
|
||||
backgroundColor: '#EF4444',
|
||||
borderColor: '#DC2626',
|
||||
borderWidth: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true,
|
||||
ticks: {
|
||||
maxRotation: 45
|
||||
}
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: '시간 (h)'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top'
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
footer: function(tooltipItems) {
|
||||
const index = tooltipItems[0].dataIndex;
|
||||
const total = totalHours[index];
|
||||
return `총 시간: ${total.toFixed(1)}h`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 에러율 분석 차트
|
||||
function renderErrorChart() {
|
||||
const ctx = document.getElementById('error-chart').getContext('2d');
|
||||
|
||||
// 기존 차트 삭제
|
||||
if (charts.error) {
|
||||
charts.error.destroy();
|
||||
}
|
||||
|
||||
const projects = analysisData.projects;
|
||||
const labels = projects.map(p => p.project_name || 'Unknown Project');
|
||||
const errorRates = projects.map(p => p.project_error_rate || 0);
|
||||
|
||||
// 에러율에 따른 색상 결정
|
||||
const colors = errorRates.map(rate => {
|
||||
if (rate >= 10) return '#EF4444'; // 높음 (빨강)
|
||||
if (rate >= 5) return '#F59E0B'; // 중간 (주황)
|
||||
return '#10B981'; // 낮음 (초록)
|
||||
});
|
||||
|
||||
charts.error = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
data: errorRates,
|
||||
backgroundColor: colors,
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 2
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom'
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
return `${context.label}: ${context.parsed}%`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 프로젝트 카드 렌더링
|
||||
function renderProjectCards() {
|
||||
const container = elements.projectsContainer;
|
||||
container.innerHTML = '';
|
||||
|
||||
analysisData.projects.forEach(project => {
|
||||
const card = createProjectCard(project);
|
||||
container.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
// 프로젝트 카드 생성
|
||||
function createProjectCard(project) {
|
||||
const card = document.createElement('div');
|
||||
|
||||
// 에러율에 따른 카드 스타일 결정
|
||||
let errorClass = 'error-low';
|
||||
const errorRate = project.project_error_rate || 0;
|
||||
if (errorRate >= 10) errorClass = 'error-high';
|
||||
else if (errorRate >= 5) errorClass = 'error-medium';
|
||||
|
||||
card.className = `project-card ${errorClass} bg-white rounded-lg shadow-md p-6`;
|
||||
|
||||
// 프로젝트 헤더
|
||||
const header = `
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 class="text-xl font-bold text-gray-800">${project.project_name || 'Unknown Project'}</h3>
|
||||
<p class="text-sm text-gray-600">Job No: ${project.job_no || 'N/A'}</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-2xl font-bold text-blue-600">${(project.total_project_hours || 0).toFixed(1)}h</div>
|
||||
<div class="text-sm text-gray-500">총 시간</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 프로젝트 요약 통계
|
||||
const summary = `
|
||||
<div class="grid grid-cols-3 gap-4 mb-6 p-4 bg-gray-50 rounded-lg">
|
||||
<div class="text-center">
|
||||
<div class="text-lg font-semibold text-green-600">${(project.total_regular_hours || 0).toFixed(1)}h</div>
|
||||
<div class="text-xs text-gray-600">정규 시간</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-lg font-semibold text-red-600">${(project.total_error_hours || 0).toFixed(1)}h</div>
|
||||
<div class="text-xs text-gray-600">에러 시간</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-lg font-semibold text-purple-600">${errorRate}%</div>
|
||||
<div class="text-xs text-gray-600">에러율</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 작업 유형별 테이블
|
||||
let workTypesTable = `
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full">
|
||||
<thead class="bg-gray-100">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">작업 유형</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">총 시간</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">정규 시간</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">에러 시간</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">에러율</th>
|
||||
<th class="px-4 py-2 text-center text-xs font-medium text-gray-500 uppercase">진행률</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
`;
|
||||
|
||||
if (project.work_types && project.work_types.length > 0) {
|
||||
project.work_types.forEach(workType => {
|
||||
const totalHours = workType.total_hours || 0;
|
||||
const regularHours = workType.regular_hours || 0;
|
||||
const errorHours = workType.error_hours || 0;
|
||||
const errorRatePercent = workType.error_rate_percent || 0;
|
||||
|
||||
const regularPercent = totalHours > 0 ? (regularHours / totalHours) * 100 : 0;
|
||||
const errorPercent = totalHours > 0 ? (errorHours / totalHours) * 100 : 0;
|
||||
|
||||
workTypesTable += `
|
||||
<tr class="work-type-row">
|
||||
<td class="px-4 py-3 text-sm font-medium text-gray-900">${workType.work_type_name || 'Unknown'}</td>
|
||||
<td class="px-4 py-3 text-sm text-right font-semibold">${totalHours.toFixed(1)}h</td>
|
||||
<td class="px-4 py-3 text-sm text-right text-green-600">${regularHours.toFixed(1)}h</td>
|
||||
<td class="px-4 py-3 text-sm text-right text-red-600">${errorHours.toFixed(1)}h</td>
|
||||
<td class="px-4 py-3 text-sm text-right font-medium ${errorRatePercent >= 10 ? 'text-red-600' : errorRatePercent >= 5 ? 'text-yellow-600' : 'text-green-600'}">${errorRatePercent}%</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex-1 bg-gray-200 rounded-full h-2">
|
||||
<div class="bg-green-500 h-2 rounded-full progress-bar" style="width: ${regularPercent}%"></div>
|
||||
</div>
|
||||
<div class="flex-1 bg-gray-200 rounded-full h-2">
|
||||
<div class="bg-red-500 h-2 rounded-full progress-bar" style="width: ${errorPercent}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
} else {
|
||||
workTypesTable += `
|
||||
<tr>
|
||||
<td colspan="6" class="px-4 py-3 text-center text-gray-500">작업 유형 데이터가 없습니다.</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
workTypesTable += `
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
|
||||
card.innerHTML = header + summary + workTypesTable;
|
||||
return card;
|
||||
}
|
||||
|
||||
// 마지막 업데이트 시간 갱신
|
||||
function updateLastUpdated() {
|
||||
const now = new Date();
|
||||
const timeString = now.toLocaleString('ko-KR');
|
||||
elements.lastUpdated.textContent = `마지막 업데이트: ${timeString}`;
|
||||
}
|
||||
|
||||
// 유틸리티 함수들
|
||||
function formatNumber(num) {
|
||||
return new Intl.NumberFormat('ko-KR').format(num);
|
||||
}
|
||||
|
||||
function formatHours(hours) {
|
||||
return `${hours.toFixed(1)}시간`;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,723 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>작업보고서 검토 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/main-layout.css">
|
||||
<link rel="stylesheet" href="/css/admin.css">
|
||||
<link rel="stylesheet" href="/css/work-report.css">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
<style>
|
||||
/* 검토 페이지 전용 스타일 */
|
||||
.review-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 350px;
|
||||
gap: 24px;
|
||||
min-height: calc(100vh - 200px);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* 상단 대시보드 */
|
||||
.dashboard-section {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dashboard-card {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e1e5e9;
|
||||
text-align: center;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.dashboard-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.dashboard-number {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.dashboard-label {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dashboard-card.total .dashboard-number { color: #007bff; }
|
||||
.dashboard-card.error .dashboard-number { color: #dc3545; }
|
||||
.dashboard-card.warning .dashboard-number { color: #ffc107; }
|
||||
.dashboard-card.missing .dashboard-number { color: #6c757d; }
|
||||
|
||||
/* 필터 섹션 */
|
||||
.filter-section {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e1e5e9;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.filter-input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 2px solid #e1e5e9;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.filter-input:focus {
|
||||
outline: none;
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 12px 24px;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
|
||||
/* 알림 영역 */
|
||||
.alerts-section {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e1e5e9;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.alerts-header {
|
||||
background: #f8f9fa;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid #e1e5e9;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.alert-item {
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.alert-item:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.alert-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.alert-type {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.alert-type.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.alert-type.warning {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.alert-type.missing {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
}
|
||||
|
||||
.alert-type.pending {
|
||||
background: #e2e3e5;
|
||||
color: #383d41;
|
||||
}
|
||||
|
||||
.alert-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.alert-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.alert-time {
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 메인 테이블 */
|
||||
.table-section {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e1e5e9;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
background: #f8f9fa;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid #e1e5e9;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.table-title {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.table-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #dee2e6;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
border-color: #007bff;
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
background: #f8f9fa;
|
||||
padding: 16px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
border-bottom: 2px solid #e1e5e9;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.data-table tr {
|
||||
transition: background 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.data-table tr:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.data-table tr.selected {
|
||||
background: #e7f3ff;
|
||||
border-left: 4px solid #007bff;
|
||||
}
|
||||
|
||||
/* 상태 표시 */
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-badge.normal {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.status-badge.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.row-normal { background: #fff; }
|
||||
.row-warning { background: #fffbf0; border-left: 4px solid #ffc107; }
|
||||
.row-error { background: #fef5f5; border-left: 4px solid #dc3545; }
|
||||
.row-missing { background: #f0f8ff; border-left: 4px solid #6c757d; }
|
||||
.row-reviewed { background: #f0f9ff; border-left: 4px solid #28a745; }
|
||||
|
||||
/* 새로운 배지 스타일 */
|
||||
.attendance-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.attendance-badge.NORMAL {
|
||||
background: #e3f2fd;
|
||||
color: #1565c0;
|
||||
}
|
||||
|
||||
.attendance-badge.HALF_DAY {
|
||||
background: #fff3e0;
|
||||
color: #ef6c00;
|
||||
}
|
||||
|
||||
.attendance-badge.HALF_HALF_DAY {
|
||||
background: #f3e5f5;
|
||||
color: #7b1fa2;
|
||||
}
|
||||
|
||||
.attendance-badge.EARLY_LEAVE {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.hours-status-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hours-status-badge.NORMAL {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.hours-status-badge.UNDER {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.hours-status-badge.OVER {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.review-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.review-badge.reviewed {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.review-badge.pending {
|
||||
background: #ffeaa7;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.review-complete-btn {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.review-complete-btn:hover {
|
||||
background: #1e7e34;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* 우측 수정 패널 */
|
||||
.edit-panel {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e1e5e9;
|
||||
position: sticky;
|
||||
top: 24px;
|
||||
height: fit-content;
|
||||
max-height: calc(100vh - 48px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
background: #f8f9fa;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #e1e5e9;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.panel-subtitle {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.panel-empty {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 60px 20px;
|
||||
}
|
||||
|
||||
.panel-empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 2px solid #e1e5e9;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
padding: 20px;
|
||||
border-top: 1px solid #e1e5e9;
|
||||
background: #f8f9fa;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel-btn {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.panel-btn.save {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.panel-btn.save:hover {
|
||||
background: #1e7e34;
|
||||
}
|
||||
|
||||
.panel-btn.delete {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.panel-btn.delete:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.panel-btn.cancel {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.panel-btn.cancel:hover {
|
||||
background: #545b62;
|
||||
}
|
||||
|
||||
/* 로딩 및 메시지 */
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 16px 24px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 24px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.message.success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.message.info {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
border: 1px solid #bee5eb;
|
||||
}
|
||||
|
||||
.message.warning {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
border: 1px solid #ffeaa7;
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 1200px) {
|
||||
.review-container {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.edit-panel {
|
||||
position: relative;
|
||||
top: 0;
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-section {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.table-section {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
min-width: 800px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-layout">
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<div id="content-container">
|
||||
<div class="page-header">
|
||||
<h1>🔍 작업보고서 검토</h1>
|
||||
<p class="subtitle">전체 현황을 파악하고 이상 사항을 빠르게 처리하세요.</p>
|
||||
</div>
|
||||
|
||||
<!-- 메시지 영역 -->
|
||||
<div id="message-container"></div>
|
||||
|
||||
<div class="review-container">
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<div class="main-content">
|
||||
<!-- 상단 대시보드 -->
|
||||
<div class="dashboard-section">
|
||||
<div class="dashboard-card total">
|
||||
<div class="dashboard-number" id="totalReports">-</div>
|
||||
<div class="dashboard-label">총 보고서</div>
|
||||
</div>
|
||||
<div class="dashboard-card error">
|
||||
<div class="dashboard-number" id="errorReports">-</div>
|
||||
<div class="dashboard-label">에러 발생</div>
|
||||
</div>
|
||||
<div class="dashboard-card warning">
|
||||
<div class="dashboard-number" id="warningReports">-</div>
|
||||
<div class="dashboard-label">주의 필요</div>
|
||||
</div>
|
||||
<div class="dashboard-card missing">
|
||||
<div class="dashboard-number" id="missingReports">-</div>
|
||||
<div class="dashboard-label">미검토</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 필터 섹션 -->
|
||||
<div class="filter-section">
|
||||
<div class="filter-row">
|
||||
<div class="filter-group">
|
||||
<label>시작 날짜</label>
|
||||
<input type="date" id="startDate" class="filter-input">
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>종료 날짜</label>
|
||||
<input type="date" id="endDate" class="filter-input">
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>작업자</label>
|
||||
<select id="workerFilter" class="filter-input">
|
||||
<option value="">전체 작업자</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>프로젝트</label>
|
||||
<select id="projectFilter" class="filter-input">
|
||||
<option value="">전체 프로젝트</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<button type="button" id="applyFilter" class="filter-btn">필터 적용</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 알림 영역 -->
|
||||
<div class="alerts-section">
|
||||
<div class="alerts-header">
|
||||
🚨 주의 필요 항목
|
||||
</div>
|
||||
<div id="alertsList">
|
||||
<!-- 알림 항목들이 여기에 표시됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 메인 테이블 -->
|
||||
<div class="table-section">
|
||||
<div class="table-header">
|
||||
<div class="table-title">작업보고서 목록</div>
|
||||
<div class="table-actions">
|
||||
<button class="action-btn" id="refreshBtn">🔄 새로고침</button>
|
||||
<button class="action-btn" id="exportBtn">📊 내보내기</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="overflow-x: auto;">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>날짜</th>
|
||||
<th>작업자</th>
|
||||
<th>출근형태</th>
|
||||
<th>기대시간</th>
|
||||
<th>실제시간</th>
|
||||
<th>시간상태</th>
|
||||
<th>프로젝트</th>
|
||||
<th>작업유형</th>
|
||||
<th>상태</th>
|
||||
<th>검토상태</th>
|
||||
<th>액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="reportsTableBody">
|
||||
<!-- 데이터가 여기에 표시됩니다 -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 우측 수정 패널 -->
|
||||
<div class="edit-panel">
|
||||
<div class="panel-header">
|
||||
<div class="panel-title">빠른 수정</div>
|
||||
<div class="panel-subtitle">항목을 선택하여 수정하세요</div>
|
||||
</div>
|
||||
<div class="panel-content" id="editPanelContent">
|
||||
<div class="panel-empty">
|
||||
<div class="panel-empty-icon">📝</div>
|
||||
<div>수정할 항목을 선택해주세요</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 로딩 오버레이 -->
|
||||
<div id="loadingOverlay" class="loading-overlay" style="display: none;">
|
||||
<div class="loading-spinner">
|
||||
<div style="font-size: 24px; margin-bottom: 16px;">⏳</div>
|
||||
<div>데이터를 처리하는 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module" src="/js/work-report-review.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,733 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>작업 보고서 입력 검증</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
// 날짜 범위별로 보고서 데이터 조회하는 헬퍼 함수
|
||||
async function getReportsByDateRange(startDate, endDate, workerId, projectId) {
|
||||
const allReports = [];
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
|
||||
// 날짜별로 개별 조회 (백엔드 API 구조상 날짜별 조회가 주된 방법)
|
||||
while (start <= end) {
|
||||
const dateStr = start.toISOString().split('T')[0];
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
date: dateStr,
|
||||
view_all: 'true' // 전체 조회 권한 요청
|
||||
});
|
||||
|
||||
if (workerId) params.append('worker_id', workerId);
|
||||
|
||||
const dayReports = await API.get(`/api/daily-work-reports?${params}`);
|
||||
|
||||
// 프로젝트 필터링 (클라이언트 사이드에서)
|
||||
let filteredReports = dayReports;
|
||||
if (projectId) {
|
||||
filteredReports = dayReports.filter(report =>
|
||||
report.project_id == projectId
|
||||
);
|
||||
}
|
||||
|
||||
allReports.push(...filteredReports);
|
||||
} catch (error) {
|
||||
console.warn(`${dateStr} 데이터 조회 실패:`, error);
|
||||
}
|
||||
|
||||
start.setDate(start.getDate() + 1);
|
||||
}
|
||||
|
||||
return allReports;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: #f5f7fa;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 30px;
|
||||
border-radius: 15px;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2.5em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.header p {
|
||||
font-size: 1.1em;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
background: white;
|
||||
padding: 25px;
|
||||
border-radius: 15px;
|
||||
margin-bottom: 25px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.filter-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.filter-group input, .filter-group select {
|
||||
padding: 12px;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.filter-group input:focus, .filter-group select:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 12px 24px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.validation-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||
gap: 25px;
|
||||
}
|
||||
|
||||
.validation-card {
|
||||
background: white;
|
||||
border-radius: 15px;
|
||||
padding: 25px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.validation-card:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 15px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
background: #fed7d7;
|
||||
color: #e53e3e;
|
||||
}
|
||||
|
||||
.warning-icon {
|
||||
background: #feebc8;
|
||||
color: #dd6b20;
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
background: #bee3f8;
|
||||
color: #3182ce;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
background: #c6f6d5;
|
||||
color: #38a169;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.3em;
|
||||
font-weight: 700;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2.5em;
|
||||
font-weight: 900;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.error-stat { color: #e53e3e; }
|
||||
.warning-stat { color: #dd6b20; }
|
||||
.info-stat { color: #3182ce; }
|
||||
.success-stat { color: #38a169; }
|
||||
|
||||
.issue-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.issue-item {
|
||||
padding: 12px;
|
||||
border-left: 4px solid #e2e8f0;
|
||||
margin-bottom: 10px;
|
||||
background: #f7fafc;
|
||||
border-radius: 0 8px 8px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.issue-item.error {
|
||||
border-left-color: #e53e3e;
|
||||
background: #fef5f5;
|
||||
}
|
||||
|
||||
.issue-item.warning {
|
||||
border-left-color: #dd6b20;
|
||||
background: #fffaf0;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 50px;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 5px solid #e2e8f0;
|
||||
border-top: 5px solid #667eea;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.summary-section {
|
||||
background: white;
|
||||
padding: 25px;
|
||||
border-radius: 15px;
|
||||
margin-bottom: 25px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
background: #f7fafc;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: 2em;
|
||||
font-weight: 900;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
color: #718096;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>📊 작업 보고서 입력 검증</h1>
|
||||
<p>일일 작업 보고서의 데이터 품질을 확인하고 누락된 정보를 찾아보세요</p>
|
||||
</div>
|
||||
|
||||
<div class="filter-section">
|
||||
<div class="filter-grid">
|
||||
<div class="filter-group">
|
||||
<label for="startDate">시작 날짜</label>
|
||||
<input type="date" id="startDate" value="">
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="endDate">종료 날짜</label>
|
||||
<input type="date" id="endDate" value="">
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="workerFilter">작업자</label>
|
||||
<select id="workerFilter">
|
||||
<option value="">전체</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="projectFilter">프로젝트</label>
|
||||
<select id="projectFilter">
|
||||
<option value="">전체</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<button class="btn" onclick="validateReports()">검증 실행</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="summarySection" class="summary-section" style="display: none;">
|
||||
<h3 style="margin-bottom: 20px;">📋 검증 요약</h3>
|
||||
<div class="summary-grid">
|
||||
<div class="summary-item">
|
||||
<div class="summary-value" id="totalReports">0</div>
|
||||
<div class="summary-label">총 보고서 수</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="summary-value error-stat" id="errorCount">0</div>
|
||||
<div class="summary-label">오류 항목</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="summary-value warning-stat" id="warningCount">0</div>
|
||||
<div class="summary-label">경고 항목</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="summary-value success-stat" id="validPercent">0%</div>
|
||||
<div class="summary-label">정상 비율</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="loadingSection" class="loading" style="display: none;">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>데이터를 검증하고 있습니다...</p>
|
||||
</div>
|
||||
|
||||
<div id="validationResults" class="validation-grid">
|
||||
<!-- 검증 결과가 여기에 표시됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
// API 설정
|
||||
import { API } from './js/api-config.js';
|
||||
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initializePage();
|
||||
});
|
||||
|
||||
async function initializePage() {
|
||||
// 기본 날짜 설정 (최근 30일)
|
||||
const endDate = new Date();
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - 30);
|
||||
|
||||
document.getElementById('startDate').value = startDate.toISOString().split('T')[0];
|
||||
document.getElementById('endDate').value = endDate.toISOString().split('T')[0];
|
||||
|
||||
// 필터 옵션 로드
|
||||
await loadFilterOptions();
|
||||
}
|
||||
|
||||
async function loadFilterOptions() {
|
||||
try {
|
||||
// 작업자 목록은 별도 API로 로드해야 함 (Workers 테이블)
|
||||
// 임시로 하드코딩된 데이터 사용
|
||||
const workerSelect = document.getElementById('workerFilter');
|
||||
const workers = [
|
||||
{ worker_id: 1, worker_name: '작업자1' },
|
||||
{ worker_id: 2, worker_name: '작업자2' },
|
||||
{ worker_id: 3, worker_name: '작업자3' }
|
||||
];
|
||||
|
||||
workers.forEach(worker => {
|
||||
const option = document.createElement('option');
|
||||
option.value = worker.worker_id;
|
||||
option.textContent = worker.worker_name;
|
||||
workerSelect.appendChild(option);
|
||||
});
|
||||
|
||||
// 프로젝트 목록도 별도 API로 로드해야 함 (Projects 테이블)
|
||||
// 임시로 하드코딩된 데이터 사용
|
||||
const projectSelect = document.getElementById('projectFilter');
|
||||
const projects = [
|
||||
{ project_id: 1, project_name: '프로젝트A' },
|
||||
{ project_id: 2, project_name: '프로젝트B' },
|
||||
{ project_id: 3, project_name: '프로젝트C' }
|
||||
];
|
||||
|
||||
projects.forEach(project => {
|
||||
const option = document.createElement('option');
|
||||
option.value = project.project_id;
|
||||
option.textContent = project.project_name;
|
||||
projectSelect.appendChild(option);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('필터 옵션 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateReports() {
|
||||
const startDate = document.getElementById('startDate').value;
|
||||
const endDate = document.getElementById('endDate').value;
|
||||
const workerId = document.getElementById('workerFilter').value;
|
||||
const projectId = document.getElementById('projectFilter').value;
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
alert('시작 날짜와 종료 날짜를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 로딩 표시
|
||||
document.getElementById('loadingSection').style.display = 'block';
|
||||
document.getElementById('validationResults').innerHTML = '';
|
||||
document.getElementById('summarySection').style.display = 'none';
|
||||
|
||||
try {
|
||||
// 보고서 데이터 조회 - 백엔드 API 구조에 맞게 수정
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (workerId && projectId) {
|
||||
// 작업자와 프로젝트가 모두 선택된 경우
|
||||
params.append('start_date', startDate);
|
||||
params.append('end_date', endDate);
|
||||
params.append('worker_id', workerId);
|
||||
params.append('project_id', projectId);
|
||||
params.append('view_all', 'true'); // 전체 조회 권한 요청
|
||||
|
||||
const reports = await API.get(`/api/daily-work-reports/search?${params}`);
|
||||
const reportData = reports.reports || [];
|
||||
|
||||
// 날짜별로 개별 조회하여 통합
|
||||
const allReports = await getReportsByDateRange(startDate, endDate, workerId, projectId);
|
||||
|
||||
// 검증 실행
|
||||
const validationResults = await performValidation(allReports, startDate, endDate);
|
||||
|
||||
// 결과 표시
|
||||
displayValidationResults(validationResults);
|
||||
updateSummary(validationResults, allReports.length);
|
||||
} else {
|
||||
// 날짜 범위로 조회
|
||||
const allReports = await getReportsByDateRange(startDate, endDate, workerId, projectId);
|
||||
|
||||
// 검증 실행
|
||||
const validationResults = await performValidation(allReports, startDate, endDate);
|
||||
|
||||
// 결과 표시
|
||||
displayValidationResults(validationResults);
|
||||
updateSummary(validationResults, allReports.length);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('검증 실행 실패:', error);
|
||||
alert('검증 실행 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
document.getElementById('loadingSection').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function performValidation(reports, startDate, endDate) {
|
||||
const results = {
|
||||
missingDates: [],
|
||||
invalidWorkHours: [],
|
||||
missingFields: [],
|
||||
duplicateEntries: [],
|
||||
unusualPatterns: [],
|
||||
dataConsistency: []
|
||||
};
|
||||
|
||||
// 1. 누락된 날짜 확인
|
||||
const expectedDates = getDateRange(startDate, endDate);
|
||||
const reportDates = [...new Set(reports.map(r => r.report_date))];
|
||||
results.missingDates = expectedDates.filter(date =>
|
||||
!reportDates.includes(date) && isWorkingDay(date)
|
||||
);
|
||||
|
||||
// 2. 잘못된 작업시간 확인
|
||||
results.invalidWorkHours = reports.filter(report => {
|
||||
const hours = parseFloat(report.work_hours);
|
||||
return isNaN(hours) || hours <= 0 || hours > 24;
|
||||
});
|
||||
|
||||
// 3. 필수 필드 누락 확인
|
||||
results.missingFields = reports.filter(report => {
|
||||
return !report.worker_id || !report.project_id ||
|
||||
!report.work_type_id || !report.work_status_id;
|
||||
});
|
||||
|
||||
// 4. 중복 항목 확인
|
||||
const reportKeys = new Map();
|
||||
reports.forEach(report => {
|
||||
const key = `${report.report_date}-${report.worker_id}-${report.project_id}`;
|
||||
if (reportKeys.has(key)) {
|
||||
results.duplicateEntries.push({
|
||||
...report,
|
||||
duplicateKey: key
|
||||
});
|
||||
} else {
|
||||
reportKeys.set(key, report);
|
||||
}
|
||||
});
|
||||
|
||||
// 5. 비정상적인 패턴 확인
|
||||
results.unusualPatterns = findUnusualPatterns(reports);
|
||||
|
||||
// 6. 데이터 일관성 확인
|
||||
results.dataConsistency = checkDataConsistency(reports);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function getDateRange(startDate, endDate) {
|
||||
const dates = [];
|
||||
const current = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
|
||||
while (current <= end) {
|
||||
dates.push(current.toISOString().split('T')[0]);
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
|
||||
return dates;
|
||||
}
|
||||
|
||||
function isWorkingDay(dateString) {
|
||||
const date = new Date(dateString);
|
||||
const dayOfWeek = date.getDay();
|
||||
return dayOfWeek >= 1 && dayOfWeek <= 5; // 월~금
|
||||
}
|
||||
|
||||
function findUnusualPatterns(reports) {
|
||||
const unusual = [];
|
||||
|
||||
// 작업자별 일일 총 작업시간이 8시간을 크게 초과하는 경우
|
||||
const dailyHours = {};
|
||||
reports.forEach(report => {
|
||||
const key = `${report.report_date}-${report.worker_id}`;
|
||||
dailyHours[key] = (dailyHours[key] || 0) + parseFloat(report.work_hours);
|
||||
});
|
||||
|
||||
Object.entries(dailyHours).forEach(([key, hours]) => {
|
||||
if (hours > 12) {
|
||||
const [date, workerId] = key.split('-');
|
||||
unusual.push({
|
||||
type: 'excessive_hours',
|
||||
date: date,
|
||||
worker_id: workerId,
|
||||
total_hours: hours,
|
||||
message: `${date} 작업자 ${workerId}의 총 작업시간이 ${hours}시간입니다`
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return unusual;
|
||||
}
|
||||
|
||||
function checkDataConsistency(reports) {
|
||||
const inconsistencies = [];
|
||||
|
||||
// 같은 프로젝트에서 완료 상태 이후 진행중 상태가 있는지 확인
|
||||
const projectStatus = {};
|
||||
reports.forEach(report => {
|
||||
const key = `${report.project_id}-${report.worker_id}`;
|
||||
if (!projectStatus[key]) {
|
||||
projectStatus[key] = [];
|
||||
}
|
||||
projectStatus[key].push({
|
||||
date: report.report_date,
|
||||
status: report.work_status_id,
|
||||
report: report
|
||||
});
|
||||
});
|
||||
|
||||
Object.entries(projectStatus).forEach(([key, statuses]) => {
|
||||
statuses.sort((a, b) => new Date(a.date) - new Date(b.date));
|
||||
// 여기서 상태 변화의 논리적 일관성을 확인할 수 있습니다
|
||||
});
|
||||
|
||||
return inconsistencies;
|
||||
}
|
||||
|
||||
function displayValidationResults(results) {
|
||||
const container = document.getElementById('validationResults');
|
||||
|
||||
// 누락된 날짜
|
||||
if (results.missingDates.length > 0) {
|
||||
container.appendChild(createValidationCard(
|
||||
'📅 누락된 작업일',
|
||||
'error',
|
||||
results.missingDates.length,
|
||||
results.missingDates.map(date => ({
|
||||
message: `${date} (${getDayName(date)}) - 작업 보고서 없음`
|
||||
}))
|
||||
));
|
||||
}
|
||||
|
||||
// 잘못된 작업시간
|
||||
if (results.invalidWorkHours.length > 0) {
|
||||
container.appendChild(createValidationCard(
|
||||
'⏰ 잘못된 작업시간',
|
||||
'error',
|
||||
results.invalidWorkHours.length,
|
||||
results.invalidWorkHours.map(report => ({
|
||||
message: `${report.report_date} - 작업자 ${report.worker_id}: ${report.work_hours}시간`
|
||||
}))
|
||||
));
|
||||
}
|
||||
|
||||
// 필수 필드 누락
|
||||
if (results.missingFields.length > 0) {
|
||||
container.appendChild(createValidationCard(
|
||||
'❗ 필수 필드 누락',
|
||||
'error',
|
||||
results.missingFields.length,
|
||||
results.missingFields.map(report => ({
|
||||
message: `${report.report_date} - ID: ${report.id} - 필수 정보 누락`
|
||||
}))
|
||||
));
|
||||
}
|
||||
|
||||
// 중복 항목
|
||||
if (results.duplicateEntries.length > 0) {
|
||||
container.appendChild(createValidationCard(
|
||||
'🔄 중복 항목',
|
||||
'warning',
|
||||
results.duplicateEntries.length,
|
||||
results.duplicateEntries.map(report => ({
|
||||
message: `${report.report_date} - 작업자 ${report.worker_id}, 프로젝트 ${report.project_id}`
|
||||
}))
|
||||
));
|
||||
}
|
||||
|
||||
// 비정상적인 패턴
|
||||
if (results.unusualPatterns.length > 0) {
|
||||
container.appendChild(createValidationCard(
|
||||
'⚠️ 비정상적인 패턴',
|
||||
'warning',
|
||||
results.unusualPatterns.length,
|
||||
results.unusualPatterns.map(pattern => ({
|
||||
message: pattern.message
|
||||
}))
|
||||
));
|
||||
}
|
||||
|
||||
// 검증 완료 메시지
|
||||
if (container.children.length === 0) {
|
||||
container.appendChild(createValidationCard(
|
||||
'✅ 검증 완료',
|
||||
'success',
|
||||
0,
|
||||
[{ message: '모든 데이터가 정상적으로 입력되었습니다!' }]
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
function createValidationCard(title, type, count, issues) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'validation-card';
|
||||
|
||||
const iconClass = type === 'error' ? 'error-icon' :
|
||||
type === 'warning' ? 'warning-icon' :
|
||||
type === 'success' ? 'success-icon' : 'info-icon';
|
||||
|
||||
const statClass = type === 'error' ? 'error-stat' :
|
||||
type === 'warning' ? 'warning-stat' :
|
||||
type === 'success' ? 'success-stat' : 'info-stat';
|
||||
|
||||
const icon = type === 'error' ? '❌' :
|
||||
type === 'warning' ? '⚠️' :
|
||||
type === 'success' ? '✅' : 'ℹ️';
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="card-header">
|
||||
<div class="card-icon ${iconClass}">${icon}</div>
|
||||
<div class="card-title">${title}</div>
|
||||
</div>
|
||||
<div class="stat-number ${statClass}">${count}</div>
|
||||
<div class="issue-list">
|
||||
${issues.map(issue => `
|
||||
<div class="issue-item ${type}">
|
||||
${issue.message}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
function updateSummary(results, totalReports) {
|
||||
const errorCount = results.missingDates.length +
|
||||
results.invalidWorkHours.length +
|
||||
results.missingFields.length;
|
||||
|
||||
const warningCount = results.duplicateEntries.length +
|
||||
results.unusualPatterns.length +
|
||||
results.dataConsistency.length;
|
||||
|
||||
const totalIssues = errorCount + warningCount;
|
||||
const validPercent = totalReports > 0 ?
|
||||
Math.round(((totalReports - totalIssues) / totalReports) * 100) : 100;
|
||||
|
||||
document.getElementById('totalReports').textContent = totalReports;
|
||||
document.getElementById('errorCount').textContent = errorCount;
|
||||
document.getElementById('warningCount').textContent = warningCount;
|
||||
document.getElementById('validPercent').textContent = validPercent + '%';
|
||||
|
||||
document.getElementById('summarySection').style.display = 'block';
|
||||
}
|
||||
|
||||
function getDayName(dateString) {
|
||||
const date = new Date(dateString);
|
||||
const days = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
return days[date.getDay()];
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,65 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>작업보고서 입력 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/main-layout.css">
|
||||
<link rel="stylesheet" href="/css/admin.css">
|
||||
<link rel="stylesheet" href="/css/work-report.css">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-layout">
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<div id="content-container">
|
||||
<div class="page-header">
|
||||
<h1>📝 작업보고서 입력</h1>
|
||||
<p class="subtitle">일일 작업 내용을 입력합니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>📅 날짜 선택</h3>
|
||||
<div id="calendar"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>📋 작업 내용</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="50">No</th>
|
||||
<th width="100">작업자</th>
|
||||
<th width="150">프로젝트</th>
|
||||
<th width="150">작업</th>
|
||||
<th width="80">잔업</th>
|
||||
<th width="100">근무형태</th>
|
||||
<th>메모</th>
|
||||
<th width="60">삭제</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="reportBody">
|
||||
<tr><td colspan="8" class="text-center">날짜를 먼저 선택하세요</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="form-actions" style="margin-top: 20px;">
|
||||
<button id="submitBtn" class="btn btn-primary">전체 등록</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module" src="/js/work-report-create.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,61 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>작업보고서 관리 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/main-layout.css">
|
||||
<link rel="stylesheet" href="/css/admin.css">
|
||||
<link rel="stylesheet" href="/css/work-report.css">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-layout">
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<div id="content-container">
|
||||
<div class="page-header">
|
||||
<h1>📋 작업보고서 관리</h1>
|
||||
<p class="subtitle">등록된 작업보고서를 수정하거나 삭제할 수 있습니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>📅 날짜 선택</h3>
|
||||
<div id="calendar"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>📋 선택된 날짜 보고서</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="50">No</th>
|
||||
<th width="100">작업자</th>
|
||||
<th width="150">프로젝트</th>
|
||||
<th width="150">작업</th>
|
||||
<th width="80">잔업</th>
|
||||
<th width="100">근무형태</th>
|
||||
<th>메모</th>
|
||||
<th width="120">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="reportBody">
|
||||
<tr><td colspan="8" class="text-center">날짜를 선택하면 보고서가 나타납니다.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module" src="/js/work-report-manage.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,164 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>개별 작업 보고서 | 테크니컬코리아</title>
|
||||
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/daily-work-report.css">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="work-report-container">
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="work-report-main">
|
||||
<!-- 뒤로가기 버튼 -->
|
||||
<a href="javascript:history.back()" class="back-button">
|
||||
← 뒤로가기
|
||||
</a>
|
||||
|
||||
<!-- 작업자 정보 카드 -->
|
||||
<div class="worker-info-card" id="workerInfoCard">
|
||||
<div class="worker-avatar-large">
|
||||
<span id="workerInitial">작</span>
|
||||
</div>
|
||||
<div class="worker-info-details">
|
||||
<h2 id="workerName">작업자명</h2>
|
||||
<p id="workerJob">직종</p>
|
||||
<p id="selectedDate">날짜</p>
|
||||
</div>
|
||||
<div class="worker-status-summary" id="workerStatusSummary">
|
||||
<div class="status-item">
|
||||
<span class="status-label">총 작업시간</span>
|
||||
<span class="status-value" id="totalHours">0h</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">작업 건수</span>
|
||||
<span class="status-value" id="workCount">0건</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 메시지 영역 -->
|
||||
<div id="message-container"></div>
|
||||
|
||||
<!-- 기존 작업 목록 -->
|
||||
<div class="existing-work-section" id="existingWorkSection">
|
||||
<div class="section-header">
|
||||
<h3>📋 기존 작업 목록</h3>
|
||||
<button class="btn btn-primary" id="addNewWorkBtn">
|
||||
➕ 새 작업 추가
|
||||
</button>
|
||||
</div>
|
||||
<div id="existingWorkList">
|
||||
<!-- 기존 작업들이 여기에 표시됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 새 작업 추가 폼 -->
|
||||
<div class="new-work-section" id="newWorkSection" style="display: none;">
|
||||
<div class="section-header">
|
||||
<h3>➕ 새 작업 추가</h3>
|
||||
<button class="btn btn-secondary" id="cancelNewWorkBtn">
|
||||
✖️ 취소
|
||||
</button>
|
||||
</div>
|
||||
<div class="work-entry" id="newWorkEntry">
|
||||
<div class="work-entry-grid">
|
||||
<div class="form-field-group">
|
||||
<label class="form-field-label">
|
||||
<span class="form-field-icon">🏗️</span>
|
||||
프로젝트
|
||||
</label>
|
||||
<select id="newProjectSelect" class="form-select" required>
|
||||
<option value="">프로젝트를 선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-field-group">
|
||||
<label class="form-field-label">
|
||||
<span class="form-field-icon">⚙️</span>
|
||||
작업 유형
|
||||
</label>
|
||||
<select id="newWorkTypeSelect" class="form-select" required>
|
||||
<option value="">작업 유형을 선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-field-group">
|
||||
<label class="form-field-label">
|
||||
<span class="form-field-icon">📊</span>
|
||||
업무 상태
|
||||
</label>
|
||||
<select id="newWorkStatusSelect" class="form-select" required>
|
||||
<option value="">업무 상태를 선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="error-type-section" id="newErrorTypeSection">
|
||||
<label class="form-field-label">
|
||||
<span class="form-field-icon">⚠️</span>
|
||||
에러 유형
|
||||
</label>
|
||||
<select id="newErrorTypeSelect" class="form-select">
|
||||
<option value="">에러 유형을 선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="time-input-section">
|
||||
<label class="form-field-label">
|
||||
<span class="form-field-icon">⏰</span>
|
||||
작업 시간 (시간)
|
||||
</label>
|
||||
<input type="number" id="newWorkHours" class="time-input" step="0.25" min="0.25" max="24" value="1.00" required>
|
||||
<div class="quick-time-buttons">
|
||||
<button type="button" class="quick-time-btn" data-hours="0.5">0.5h</button>
|
||||
<button type="button" class="quick-time-btn" data-hours="1">1h</button>
|
||||
<button type="button" class="quick-time-btn" data-hours="2">2h</button>
|
||||
<button type="button" class="quick-time-btn" data-hours="4">4h</button>
|
||||
<button type="button" class="quick-time-btn" data-hours="8">8h</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-success" id="saveNewWorkBtn">
|
||||
💾 작업 저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 휴가 처리 섹션 -->
|
||||
<div class="vacation-section" id="vacationSection">
|
||||
<div class="section-header">
|
||||
<h3>🏖️ 휴가 처리</h3>
|
||||
</div>
|
||||
<div class="vacation-buttons">
|
||||
<button class="btn btn-warning vacation-process-btn" data-type="full">
|
||||
🏖️ 연차 (8시간)
|
||||
</button>
|
||||
<button class="btn btn-warning vacation-process-btn" data-type="half-half">
|
||||
🌤️ 반반차 (6시간)
|
||||
</button>
|
||||
<button class="btn btn-warning vacation-process-btn" data-type="half">
|
||||
🌅 반차 (4시간)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 스크립트 -->
|
||||
<script type="module" src="/js/load-navbar.js?v=5"></script>
|
||||
<script type="module" src="/js/worker-individual-report.js?v=3"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
# Placeholder file to create admin directory
|
||||
@@ -1,215 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>관리자 설정 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/common.css?v=1">
|
||||
<link rel="stylesheet" href="/css/admin-settings.css?v=1">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="work-report-container">
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="work-report-main">
|
||||
<div class="dashboard-main">
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">
|
||||
<span class="title-icon">⚙️</span>
|
||||
관리자 설정
|
||||
</h1>
|
||||
<p class="page-description">시스템 사용자 계정 및 권한을 관리합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 사용자 관리 섹션 -->
|
||||
<div class="settings-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">
|
||||
<span class="section-icon">👥</span>
|
||||
사용자 계정 관리
|
||||
</h2>
|
||||
<button class="btn btn-primary" id="addUserBtn">
|
||||
<span class="btn-icon">➕</span>
|
||||
새 사용자 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="users-container">
|
||||
<div class="users-header">
|
||||
<div class="search-box">
|
||||
<input type="text" id="userSearch" placeholder="사용자 검색..." class="search-input">
|
||||
<span class="search-icon">🔍</span>
|
||||
</div>
|
||||
<div class="filter-buttons">
|
||||
<button class="filter-btn active" data-filter="all">전체</button>
|
||||
<button class="filter-btn" data-filter="admin">관리자</button>
|
||||
<button class="filter-btn" data-filter="leader">그룹장</button>
|
||||
<button class="filter-btn" data-filter="user">작업자</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="users-table-container">
|
||||
<table class="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>사용자명</th>
|
||||
<th>아이디</th>
|
||||
<th>역할</th>
|
||||
<th>상태</th>
|
||||
<th>최종 로그인</th>
|
||||
<th>관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="usersTableBody">
|
||||
<!-- 사용자 목록이 여기에 동적으로 생성됩니다 -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="empty-state" id="emptyState" style="display: none;">
|
||||
<div class="empty-icon">👥</div>
|
||||
<h3>등록된 사용자가 없습니다</h3>
|
||||
<p>새 사용자를 추가해보세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 사용자 추가/수정 모달 -->
|
||||
<div id="userModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h2 id="modalTitle">새 사용자 추가</h2>
|
||||
<button class="modal-close-btn" onclick="closeUserModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<form id="userForm">
|
||||
<div class="form-group">
|
||||
<label class="form-label">사용자명 *</label>
|
||||
<input type="text" id="userName" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">아이디 *</label>
|
||||
<input type="text" id="userId" class="form-control" required>
|
||||
<small class="form-help">영문, 숫자, 한글, 특수문자(._-) 사용 가능 (3-20자)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="passwordGroup">
|
||||
<label class="form-label">비밀번호 *</label>
|
||||
<input type="password" id="userPassword" class="form-control" required>
|
||||
<small class="form-help">최소 6자 이상</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">역할 *</label>
|
||||
<select id="userRole" class="form-control" required>
|
||||
<option value="">역할 선택</option>
|
||||
<option value="admin">관리자</option>
|
||||
<option value="user">사용자</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">이메일</label>
|
||||
<input type="email" id="userEmail" class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">전화번호</label>
|
||||
<input type="tel" id="userPhone" class="form-control">
|
||||
</div>
|
||||
|
||||
<!-- 페이지 권한 설정 (사용자 편집 시에만 표시) -->
|
||||
<div class="form-group" id="pageAccessGroup" style="display: none;">
|
||||
<label class="form-label">페이지 접근 권한</label>
|
||||
<small class="form-help">관리자는 모든 페이지에 자동으로 접근 가능합니다</small>
|
||||
<div id="pageAccessList" class="page-access-list">
|
||||
<!-- 페이지 체크박스 목록이 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeUserModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" id="saveUserBtn">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 사용자 삭제 확인 모달 -->
|
||||
<div id="deleteModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container small">
|
||||
<div class="modal-header">
|
||||
<h2>사용자 삭제</h2>
|
||||
<button class="modal-close-btn" onclick="closeDeleteModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="delete-warning">
|
||||
<div class="warning-icon">⚠️</div>
|
||||
<p>정말로 이 사용자를 삭제하시겠습니까?</p>
|
||||
<p class="warning-text">삭제된 사용자는 복구할 수 없습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeDeleteModal()">취소</button>
|
||||
<button type="button" class="btn btn-danger" id="confirmDeleteBtn">삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 페이지 권한 관리 모달 -->
|
||||
<div id="pageAccessModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h2 id="pageAccessModalTitle">페이지 권한 관리</h2>
|
||||
<button class="modal-close-btn" onclick="closePageAccessModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="page-access-user-info">
|
||||
<div class="user-avatar-small" id="pageAccessUserAvatar">U</div>
|
||||
<div>
|
||||
<h3 id="pageAccessUserName">사용자</h3>
|
||||
<p id="pageAccessUserRole">역할</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">접근 가능한 페이지</label>
|
||||
<small class="form-help">체크된 페이지에만 접근할 수 있습니다</small>
|
||||
<div id="pageAccessModalList" class="page-access-list">
|
||||
<!-- 페이지 체크박스 목록 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closePageAccessModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" id="savePageAccessBtn">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 토스트 알림 -->
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script type="module" src="/js/api-config.js?v=13"></script>
|
||||
<script type="module" src="/js/load-navbar.js?v=4"></script>
|
||||
<script src="/js/admin-settings.js?v=8"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,493 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>출퇴근-작업보고서 대조 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<style>
|
||||
.comparison-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.comparison-card {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
.comparison-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
color: #111827;
|
||||
}
|
||||
.discrepancy-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.badge-match {
|
||||
background-color: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
.badge-mismatch {
|
||||
background-color: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
.badge-missing-attendance {
|
||||
background-color: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
.badge-missing-report {
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
.filter-section {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.summary-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.stat-card {
|
||||
background: white;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
text-align: center;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
.detail-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
.detail-label {
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
}
|
||||
.detail-value {
|
||||
color: #111827;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="main-content">
|
||||
<div class="dashboard-main">
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">
|
||||
<span class="title-icon">🔍</span>
|
||||
출퇴근-작업보고서 대조
|
||||
</h1>
|
||||
<p class="page-description">출퇴근 기록과 작업보고서를 비교 분석합니다</p>
|
||||
</div>
|
||||
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-primary" onclick="loadComparisonData()">
|
||||
<span>🔄 새로고침</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 필터 섹션 -->
|
||||
<div class="content-section">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="filter-section">
|
||||
<div style="flex: 1; min-width: 200px;">
|
||||
<label for="startDate">시작일</label>
|
||||
<input type="date" id="startDate" class="form-control">
|
||||
</div>
|
||||
<div style="flex: 1; min-width: 200px;">
|
||||
<label for="endDate">종료일</label>
|
||||
<input type="date" id="endDate" class="form-control">
|
||||
</div>
|
||||
<div style="flex: 1; min-width: 200px;">
|
||||
<label for="workerFilter">작업자</label>
|
||||
<select id="workerFilter" class="form-control">
|
||||
<option value="">전체</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="flex: 1; min-width: 200px;">
|
||||
<label for="statusFilter">상태</label>
|
||||
<select id="statusFilter" class="form-control">
|
||||
<option value="">전체</option>
|
||||
<option value="match">일치</option>
|
||||
<option value="mismatch">불일치</option>
|
||||
<option value="missing-attendance">출퇴근 누락</option>
|
||||
<option value="missing-report">보고서 누락</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="align-self: flex-end;">
|
||||
<button class="btn btn-primary" onclick="loadComparisonData()">
|
||||
<span>🔍 조회</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 요약 통계 -->
|
||||
<div class="content-section">
|
||||
<div class="summary-stats" id="summaryStats">
|
||||
<!-- 요약 통계가 여기에 동적으로 렌더링됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 대조 결과 -->
|
||||
<div class="content-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">대조 결과</h2>
|
||||
<p class="text-muted">출퇴근 기록과 작업보고서의 시간을 비교합니다</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="comparisonList" class="data-table-container">
|
||||
<!-- 대조 결과가 여기에 동적으로 렌더링됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script type="module" src="/js/load-navbar.js?v=5"></script>
|
||||
<script type="module">
|
||||
import '/js/api-config.js?v=3';
|
||||
</script>
|
||||
<script>
|
||||
// axios 기본 설정
|
||||
(function() {
|
||||
const checkApiConfig = setInterval(() => {
|
||||
if (window.API_BASE_URL) {
|
||||
clearInterval(checkApiConfig);
|
||||
axios.defaults.baseURL = window.API_BASE_URL;
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
axios.interceptors.request.use(
|
||||
config => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
error => Promise.reject(error)
|
||||
);
|
||||
|
||||
axios.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
if (error.response?.status === 401) {
|
||||
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
|
||||
window.location.href = '/pages/login.html';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
}, 50);
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// 전역 변수
|
||||
let workers = [];
|
||||
let comparisonData = [];
|
||||
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await waitForAxiosConfig();
|
||||
initializePage();
|
||||
});
|
||||
|
||||
function waitForAxiosConfig() {
|
||||
return new Promise((resolve) => {
|
||||
const check = setInterval(() => {
|
||||
if (axios.defaults.baseURL) {
|
||||
clearInterval(check);
|
||||
resolve();
|
||||
}
|
||||
}, 50);
|
||||
setTimeout(() => {
|
||||
clearInterval(check);
|
||||
resolve();
|
||||
}, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
async function initializePage() {
|
||||
// 기본 날짜 설정 (이번 주)
|
||||
const today = new Date();
|
||||
const weekAgo = new Date(today);
|
||||
weekAgo.setDate(today.getDate() - 7);
|
||||
|
||||
document.getElementById('startDate').value = weekAgo.toISOString().split('T')[0];
|
||||
document.getElementById('endDate').value = today.toISOString().split('T')[0];
|
||||
|
||||
try {
|
||||
await loadWorkers();
|
||||
await loadComparisonData();
|
||||
} catch (error) {
|
||||
console.error('초기화 오류:', error);
|
||||
alert('페이지 초기화 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorkers() {
|
||||
try {
|
||||
const response = await axios.get('/workers');
|
||||
if (response.data.success) {
|
||||
workers = response.data.data.filter(w => w.employment_status === 'employed');
|
||||
|
||||
const select = document.getElementById('workerFilter');
|
||||
workers.forEach(worker => {
|
||||
const option = document.createElement('option');
|
||||
option.value = worker.worker_id;
|
||||
option.textContent = worker.worker_name;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('작업자 목록 로드 오류:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadComparisonData() {
|
||||
const startDate = document.getElementById('startDate').value;
|
||||
const endDate = document.getElementById('endDate').value;
|
||||
const workerId = document.getElementById('workerFilter').value;
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
alert('시작일과 종료일을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 출퇴근 기록 로드
|
||||
const attendanceResponse = await axios.get('/attendance/records', {
|
||||
params: {
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
worker_id: workerId || undefined
|
||||
}
|
||||
});
|
||||
|
||||
// 작업 보고서 로드
|
||||
const reportsResponse = await axios.get('/daily-work-reports', {
|
||||
params: {
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
worker_id: workerId || undefined
|
||||
}
|
||||
});
|
||||
|
||||
const attendanceRecords = attendanceResponse.data.success ? attendanceResponse.data.data : [];
|
||||
const workReports = reportsResponse.data.success ? reportsResponse.data.data : [];
|
||||
|
||||
// 데이터 비교
|
||||
comparisonData = compareData(attendanceRecords, workReports);
|
||||
|
||||
// 필터 적용
|
||||
const statusFilter = document.getElementById('statusFilter').value;
|
||||
if (statusFilter) {
|
||||
comparisonData = comparisonData.filter(item => item.status === statusFilter);
|
||||
}
|
||||
|
||||
renderSummary();
|
||||
renderComparisonList();
|
||||
} catch (error) {
|
||||
console.error('데이터 로드 오류:', error);
|
||||
document.getElementById('comparisonList').innerHTML = `
|
||||
<div class="empty-state">
|
||||
<p>데이터를 불러오는 중 오류가 발생했습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function compareData(attendanceRecords, workReports) {
|
||||
const results = [];
|
||||
const dateWorkerMap = new Map();
|
||||
|
||||
// 출퇴근 기록 맵핑
|
||||
attendanceRecords.forEach(record => {
|
||||
const key = `${record.attendance_date}_${record.worker_id}`;
|
||||
dateWorkerMap.set(key, {
|
||||
date: record.attendance_date,
|
||||
worker_id: record.worker_id,
|
||||
worker_name: record.worker_name,
|
||||
attendance: record,
|
||||
reports: []
|
||||
});
|
||||
});
|
||||
|
||||
// 작업 보고서 맵핑
|
||||
workReports.forEach(report => {
|
||||
const key = `${report.report_date}_${report.worker_id}`;
|
||||
if (dateWorkerMap.has(key)) {
|
||||
dateWorkerMap.get(key).reports.push(report);
|
||||
} else {
|
||||
dateWorkerMap.set(key, {
|
||||
date: report.report_date,
|
||||
worker_id: report.worker_id,
|
||||
worker_name: report.worker_name,
|
||||
attendance: null,
|
||||
reports: [report]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 비교 분석
|
||||
dateWorkerMap.forEach(item => {
|
||||
const attendanceHours = item.attendance?.total_hours || 0;
|
||||
const reportTotalHours = item.reports.reduce((sum, r) => sum + (r.total_hours || 0), 0);
|
||||
|
||||
let status = 'match';
|
||||
let message = '일치';
|
||||
|
||||
if (!item.attendance && item.reports.length > 0) {
|
||||
status = 'missing-attendance';
|
||||
message = '출퇴근 기록 누락';
|
||||
} else if (item.attendance && item.reports.length === 0) {
|
||||
status = 'missing-report';
|
||||
message = '작업보고서 누락';
|
||||
} else if (Math.abs(attendanceHours - reportTotalHours) > 0.5) {
|
||||
status = 'mismatch';
|
||||
message = `시간 불일치 (차이: ${Math.abs(attendanceHours - reportTotalHours).toFixed(1)}시간)`;
|
||||
}
|
||||
|
||||
results.push({
|
||||
...item,
|
||||
attendanceHours,
|
||||
reportTotalHours,
|
||||
status,
|
||||
message
|
||||
});
|
||||
});
|
||||
|
||||
// 날짜 역순 정렬
|
||||
return results.sort((a, b) => b.date.localeCompare(a.date));
|
||||
}
|
||||
|
||||
function renderSummary() {
|
||||
const summaryStats = document.getElementById('summaryStats');
|
||||
|
||||
const total = comparisonData.length;
|
||||
const matches = comparisonData.filter(item => item.status === 'match').length;
|
||||
const mismatches = comparisonData.filter(item => item.status === 'mismatch').length;
|
||||
const missingAttendance = comparisonData.filter(item => item.status === 'missing-attendance').length;
|
||||
const missingReport = comparisonData.filter(item => item.status === 'missing-report').length;
|
||||
|
||||
summaryStats.innerHTML = `
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">전체</div>
|
||||
<div class="stat-value">${total}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">일치</div>
|
||||
<div class="stat-value" style="color: #059669;">${matches}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">불일치</div>
|
||||
<div class="stat-value" style="color: #dc2626;">${mismatches}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">출퇴근 누락</div>
|
||||
<div class="stat-value" style="color: #d97706;">${missingAttendance}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">보고서 누락</div>
|
||||
<div class="stat-value" style="color: #2563eb;">${missingReport}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderComparisonList() {
|
||||
const container = document.getElementById('comparisonList');
|
||||
|
||||
if (comparisonData.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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${comparisonData.map(item => {
|
||||
const diff = Math.abs(item.attendanceHours - item.reportTotalHours);
|
||||
const badgeClass = `badge-${item.status}`;
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${item.date}</td>
|
||||
<td><strong>${item.worker_name}</strong></td>
|
||||
<td>${item.attendanceHours.toFixed(1)}시간</td>
|
||||
<td>${item.reportTotalHours.toFixed(1)}시간</td>
|
||||
<td>${diff.toFixed(1)}시간</td>
|
||||
<td>
|
||||
<span class="discrepancy-badge ${badgeClass}">
|
||||
${item.message}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
|
||||
container.innerHTML = tableHTML;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,302 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>코드 관리 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 레이아웃: 사이드바 + 콘텐츠 -->
|
||||
<div class="page-container">
|
||||
<!-- 사이드바 -->
|
||||
<aside class="sidebar">
|
||||
<nav class="sidebar-nav">
|
||||
<div class="sidebar-header">
|
||||
<h3 class="sidebar-title">관리 메뉴</h3>
|
||||
</div>
|
||||
<ul class="sidebar-menu">
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/projects.html">
|
||||
<span class="menu-icon">📁</span>
|
||||
<span class="menu-text">프로젝트 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/workers.html">
|
||||
<span class="menu-icon">👥</span>
|
||||
<span class="menu-text">작업자 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/workplaces.html">
|
||||
<span class="menu-icon">🏗️</span>
|
||||
<span class="menu-text">작업장 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/equipments.html">
|
||||
<span class="menu-icon">⚙️</span>
|
||||
<span class="menu-text">설비 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/tasks.html">
|
||||
<span class="menu-icon">📋</span>
|
||||
<span class="menu-text">작업 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item active">
|
||||
<a href="/pages/admin/codes.html">
|
||||
<span class="menu-icon">🏷️</span>
|
||||
<span class="menu-text">코드 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-divider"></li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/dashboard.html">
|
||||
<span class="menu-icon">🏠</span>
|
||||
<span class="menu-text">대시보드로</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="main-content">
|
||||
<div class="dashboard-main">
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">
|
||||
<span class="title-icon">🏷️</span>
|
||||
코드 관리
|
||||
</h1>
|
||||
<p class="page-description">작업 상태, 오류 유형, 작업 유형 등 시스템에서 사용하는 코드를 관리합니다</p>
|
||||
</div>
|
||||
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-secondary" onclick="refreshAllCodes()">
|
||||
<span class="btn-icon">🔄</span>
|
||||
전체 새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 코드 유형 탭 -->
|
||||
<div class="code-tabs">
|
||||
<button class="tab-btn active" data-tab="work-status" onclick="switchCodeTab('work-status')">
|
||||
<span class="tab-icon">📊</span>
|
||||
작업 상태 유형
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="error-types" onclick="switchCodeTab('error-types')">
|
||||
<span class="tab-icon">⚠️</span>
|
||||
오류 유형
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="work-types" onclick="switchCodeTab('work-types')">
|
||||
<span class="tab-icon">🔧</span>
|
||||
작업 유형
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 작업 상태 유형 관리 -->
|
||||
<div id="work-status-tab" class="code-tab-content active">
|
||||
<div class="code-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">
|
||||
<span class="section-icon">📊</span>
|
||||
작업 상태 유형 관리
|
||||
</h2>
|
||||
<div class="section-actions">
|
||||
<button class="btn btn-primary" onclick="openCodeModal('work-status')">
|
||||
<span class="btn-icon">➕</span>
|
||||
새 상태 추가
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="code-stats">
|
||||
<span class="stat-item">
|
||||
<span class="stat-icon">📊</span>
|
||||
총 <span id="workStatusCount">0</span>개
|
||||
</span>
|
||||
<span class="stat-item">
|
||||
<span class="stat-icon">✅</span>
|
||||
정상 <span id="normalStatusCount">0</span>개
|
||||
</span>
|
||||
<span class="stat-item">
|
||||
<span class="stat-icon">❌</span>
|
||||
오류 <span id="errorStatusCount">0</span>개
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="code-grid" id="workStatusGrid">
|
||||
<!-- 작업 상태 유형 카드들이 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 오류 유형 관리 -->
|
||||
<div id="error-types-tab" class="code-tab-content">
|
||||
<div class="code-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">
|
||||
<span class="section-icon">⚠️</span>
|
||||
오류 유형 관리
|
||||
</h2>
|
||||
<div class="section-actions">
|
||||
<button class="btn btn-primary" onclick="openCodeModal('error-types')">
|
||||
<span class="btn-icon">➕</span>
|
||||
새 오류 유형 추가
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="code-stats">
|
||||
<span class="stat-item">
|
||||
<span class="stat-icon">⚠️</span>
|
||||
총 <span id="errorTypesCount">0</span>개
|
||||
</span>
|
||||
<span class="stat-item critical-stat">
|
||||
<span class="stat-icon">🔴</span>
|
||||
심각 <span id="criticalErrorsCount">0</span>개
|
||||
</span>
|
||||
<span class="stat-item high-stat">
|
||||
<span class="stat-icon">🟠</span>
|
||||
높음 <span id="highErrorsCount">0</span>개
|
||||
</span>
|
||||
<span class="stat-item medium-stat">
|
||||
<span class="stat-icon">🟡</span>
|
||||
보통 <span id="mediumErrorsCount">0</span>개
|
||||
</span>
|
||||
<span class="stat-item low-stat">
|
||||
<span class="stat-icon">🟢</span>
|
||||
낮음 <span id="lowErrorsCount">0</span>개
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="code-grid" id="errorTypesGrid">
|
||||
<!-- 오류 유형 카드들이 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업 유형 관리 -->
|
||||
<div id="work-types-tab" class="code-tab-content">
|
||||
<div class="code-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">
|
||||
<span class="section-icon">🔧</span>
|
||||
작업 유형 관리
|
||||
</h2>
|
||||
<div class="section-actions">
|
||||
<button class="btn btn-primary" onclick="openCodeModal('work-types')">
|
||||
<span class="btn-icon">➕</span>
|
||||
새 작업 유형 추가
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="code-stats">
|
||||
<span class="stat-item">
|
||||
<span class="stat-icon">🔧</span>
|
||||
총 <span id="workTypesCount">0</span>개
|
||||
</span>
|
||||
<span class="stat-item">
|
||||
<span class="stat-icon">📁</span>
|
||||
카테고리 <span id="workCategoriesCount">0</span>개
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="code-grid" id="workTypesGrid">
|
||||
<!-- 작업 유형 카드들이 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 코드 추가/수정 모달 -->
|
||||
<div id="codeModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h2 id="modalTitle">코드 추가</h2>
|
||||
<button class="modal-close-btn" onclick="closeCodeModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<form id="codeForm" onsubmit="event.preventDefault(); saveCode();">
|
||||
<input type="hidden" id="codeId">
|
||||
<input type="hidden" id="codeType">
|
||||
|
||||
<!-- 공통 필드 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">이름 *</label>
|
||||
<input type="text" id="codeName" class="form-control" placeholder="코드명을 입력하세요" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">설명</label>
|
||||
<textarea id="codeDescription" class="form-control" rows="3" placeholder="상세 설명을 입력하세요"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- 작업 상태 유형 전용 필드 -->
|
||||
<div class="form-group" id="isErrorGroup" style="display: none;">
|
||||
<label class="form-label">
|
||||
<input type="checkbox" id="isError" class="form-checkbox">
|
||||
오류 상태로 분류
|
||||
</label>
|
||||
<small class="form-help">체크하면 이 상태는 오류로 간주됩니다</small>
|
||||
</div>
|
||||
|
||||
<!-- 오류 유형 전용 필드 -->
|
||||
<div class="form-group" id="severityGroup" style="display: none;">
|
||||
<label class="form-label">심각도 *</label>
|
||||
<select id="severity" class="form-control">
|
||||
<option value="low">낮음 (Low)</option>
|
||||
<option value="medium" selected>보통 (Medium)</option>
|
||||
<option value="high">높음 (High)</option>
|
||||
<option value="critical">심각 (Critical)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="solutionGuideGroup" style="display: none;">
|
||||
<label class="form-label">해결 가이드</label>
|
||||
<textarea id="solutionGuide" class="form-control" rows="4" placeholder="이 오류 발생 시 해결 방법을 입력하세요"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- 작업 유형 전용 필드 -->
|
||||
<div class="form-group" id="categoryGroup" style="display: none;">
|
||||
<label class="form-label">카테고리</label>
|
||||
<input type="text" id="category" class="form-control" placeholder="작업 카테고리 (예: PKG, Vessel)" list="categoryList">
|
||||
<datalist id="categoryList">
|
||||
<!-- 기존 카테고리 목록이 여기에 동적으로 생성됩니다 -->
|
||||
</datalist>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeCodeModal()">취소</button>
|
||||
<button type="button" class="btn btn-danger" id="deleteCodeBtn" onclick="deleteCode()" style="display: none;">
|
||||
🗑️ 삭제
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveCode()">
|
||||
💾 저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js?v=5"></script>
|
||||
<script type="module" src="/js/code-management.js?v=2"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,272 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>설비 관리 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 레이아웃: 사이드바 + 콘텐츠 -->
|
||||
<div class="page-container">
|
||||
<!-- 사이드바 -->
|
||||
<aside class="sidebar">
|
||||
<nav class="sidebar-nav">
|
||||
<div class="sidebar-header">
|
||||
<h3 class="sidebar-title">관리 메뉴</h3>
|
||||
</div>
|
||||
<ul class="sidebar-menu">
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/projects.html">
|
||||
<span class="menu-icon">📁</span>
|
||||
<span class="menu-text">프로젝트 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/workers.html">
|
||||
<span class="menu-icon">👥</span>
|
||||
<span class="menu-text">작업자 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/workplaces.html">
|
||||
<span class="menu-icon">🏗️</span>
|
||||
<span class="menu-text">작업장 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item active">
|
||||
<a href="/pages/admin/equipments.html">
|
||||
<span class="menu-icon">⚙️</span>
|
||||
<span class="menu-text">설비 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/tasks.html">
|
||||
<span class="menu-icon">📋</span>
|
||||
<span class="menu-text">작업 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/codes.html">
|
||||
<span class="menu-icon">🏷️</span>
|
||||
<span class="menu-text">코드 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-divider"></li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/dashboard.html">
|
||||
<span class="menu-icon">🏠</span>
|
||||
<span class="menu-text">대시보드로</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="main-content">
|
||||
<div class="dashboard-main">
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">
|
||||
<span class="title-icon">⚙️</span>
|
||||
설비 관리
|
||||
</h1>
|
||||
<p class="page-description">작업장별 설비 정보를 등록하고 관리합니다</p>
|
||||
</div>
|
||||
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-primary" onclick="openEquipmentModal()">
|
||||
<span>+ 설비 추가</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 필터 영역 -->
|
||||
<div class="filter-section">
|
||||
<div class="filter-group">
|
||||
<label for="filterWorkplace">작업장</label>
|
||||
<select id="filterWorkplace" class="form-control" onchange="filterEquipments()">
|
||||
<option value="">전체</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="filterType">설비 유형</label>
|
||||
<select id="filterType" class="form-control" onchange="filterEquipments()">
|
||||
<option value="">전체</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="filterStatus">상태</label>
|
||||
<select id="filterStatus" class="form-control" onchange="filterEquipments()">
|
||||
<option value="">전체</option>
|
||||
<option value="active">활성</option>
|
||||
<option value="maintenance">정비중</option>
|
||||
<option value="inactive">비활성</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="searchInput">검색</label>
|
||||
<input type="text" id="searchInput" class="form-control" placeholder="설비명 또는 코드 검색" oninput="filterEquipments()">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 설비 목록 -->
|
||||
<div class="content-section">
|
||||
<div id="equipmentList" class="data-table-container">
|
||||
<!-- 설비 목록이 여기에 동적으로 렌더링됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 설비 추가/수정 모달 -->
|
||||
<div id="equipmentModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 700px;">
|
||||
<div class="modal-header">
|
||||
<h2 id="modalTitle">설비 추가</h2>
|
||||
<button class="btn-close" onclick="closeEquipmentModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="equipmentForm">
|
||||
<input type="hidden" id="equipmentId">
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="equipmentCode">설비 코드 *</label>
|
||||
<input type="text" id="equipmentCode" class="form-control" placeholder="예: CNC-01" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="equipmentName">설비명 *</label>
|
||||
<input type="text" id="equipmentName" class="form-control" placeholder="예: CNC 머시닝 센터" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="equipmentType">설비 유형</label>
|
||||
<input type="text" id="equipmentType" class="form-control" placeholder="예: CNC, 선반, 밀링 등">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="workplaceId">작업장</label>
|
||||
<select id="workplaceId" class="form-control">
|
||||
<option value="">선택 안함</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="manufacturer">제조사</label>
|
||||
<input type="text" id="manufacturer" class="form-control" placeholder="예: DMG MORI">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="modelName">모델명</label>
|
||||
<input type="text" id="modelName" class="form-control" placeholder="예: NHX-5000">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="serialNumber">시리얼 번호</label>
|
||||
<input type="text" id="serialNumber" class="form-control">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="installationDate">설치일</label>
|
||||
<input type="date" id="installationDate" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="equipmentStatus">상태</label>
|
||||
<select id="equipmentStatus" class="form-control">
|
||||
<option value="active">활성</option>
|
||||
<option value="maintenance">정비중</option>
|
||||
<option value="inactive">비활성</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="specifications">사양 정보</label>
|
||||
<textarea id="specifications" class="form-control" rows="3" placeholder="설비 사양 정보를 입력하세요"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">비고</label>
|
||||
<textarea id="notes" class="form-control" rows="2" placeholder="기타 메모사항"></textarea>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeEquipmentModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveEquipment()">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script type="module" src="/js/load-navbar.js?v=5"></script>
|
||||
<script type="module">
|
||||
// API 설정 먼저 로드
|
||||
import '/js/api-config.js?v=3';
|
||||
</script>
|
||||
<script>
|
||||
// axios 기본 설정
|
||||
(function() {
|
||||
// api-config.js가 로드될 때까지 대기
|
||||
const checkApiConfig = setInterval(() => {
|
||||
if (window.API_BASE_URL) {
|
||||
clearInterval(checkApiConfig);
|
||||
axios.defaults.baseURL = window.API_BASE_URL;
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// axios 요청 인터셉터 추가 (모든 요청에 토큰 자동 추가)
|
||||
axios.interceptors.request.use(
|
||||
config => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
console.log('📤 요청:', config.method.toUpperCase(), config.url);
|
||||
return config;
|
||||
},
|
||||
error => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// axios 응답 인터셉터 추가 (에러 처리)
|
||||
axios.interceptors.response.use(
|
||||
response => {
|
||||
console.log('✅ 응답:', response.status, response.config.url);
|
||||
return response;
|
||||
},
|
||||
error => {
|
||||
console.error('❌ 에러:', error.response?.status, error.config?.url, error.message);
|
||||
if (error.response?.status === 401) {
|
||||
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
|
||||
window.location.href = '/pages/login.html';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
console.log('✅ Axios 설정 완료:', axios.defaults.baseURL);
|
||||
}
|
||||
}, 50);
|
||||
})();
|
||||
</script>
|
||||
<script src="/js/equipment-management.js?v=4"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,140 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>페이지 권한 관리 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/common.css?v=1">
|
||||
<link rel="stylesheet" href="/css/admin-settings.css?v=1">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script type="module" src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="work-report-container">
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="work-report-main">
|
||||
<div class="dashboard-main">
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">
|
||||
<span class="title-icon">🔐</span>
|
||||
페이지 접근 권한 관리
|
||||
</h1>
|
||||
<p class="page-description">작업자에게 특정 페이지 접근 권한을 부여하거나 회수합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 사용자 목록 섹션 -->
|
||||
<div class="settings-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">
|
||||
<span class="section-icon">👥</span>
|
||||
사용자 목록
|
||||
</h2>
|
||||
<div class="filter-buttons">
|
||||
<button class="filter-btn active" data-filter="all">전체</button>
|
||||
<button class="filter-btn" data-filter="with-access">권한 있음</button>
|
||||
<button class="filter-btn" data-filter="no-access">권한 없음</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="users-container">
|
||||
<div class="users-table-container">
|
||||
<table class="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>사용자명</th>
|
||||
<th>아이디</th>
|
||||
<th>역할</th>
|
||||
<th>작업자</th>
|
||||
<th>접근 가능 페이지</th>
|
||||
<th>관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="usersTableBody">
|
||||
<tr>
|
||||
<td colspan="6" style="text-align: center; padding: 2rem;">
|
||||
<div class="spinner"></div>
|
||||
<p>사용자 목록을 불러오는 중...</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="empty-state" id="emptyState" style="display: none;">
|
||||
<div class="empty-icon">👥</div>
|
||||
<h3>등록된 사용자가 없습니다</h3>
|
||||
<p>권한을 부여할 사용자 계정이 없습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 페이지 권한 설정 모달 -->
|
||||
<div id="pageAccessModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 700px;">
|
||||
<div class="modal-header">
|
||||
<h2 id="modalTitle">페이지 권한 설정</h2>
|
||||
<button class="modal-close-btn" onclick="closePageAccessModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="user-info-section" style="background: #f9fafb; padding: 1rem; border-radius: 0.5rem; margin-bottom: 1.5rem;">
|
||||
<div style="display: flex; align-items: center; gap: 0.75rem;">
|
||||
<div style="width: 40px; height: 40px; border-radius: 50%; background: linear-gradient(135deg, #3b82f6, #2563eb); color: white; display: flex; align-items: center; justify-content: center; font-weight: 600;">
|
||||
<span id="modalUserInitial">-</span>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-weight: 600; font-size: 1rem; color: #111827;" id="modalUserName">사용자명</div>
|
||||
<div style="font-size: 0.875rem; color: #6b7280;">
|
||||
<span id="modalUsername">username</span>
|
||||
<span style="margin: 0 0.5rem;">•</span>
|
||||
<span id="modalWorkerName">작업자</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-access-list">
|
||||
<h3 style="font-size: 1rem; font-weight: 600; color: #374151; margin-bottom: 1rem;">
|
||||
접근 가능 페이지 선택
|
||||
</h3>
|
||||
|
||||
<div id="pageListContainer" style="max-height: 400px; overflow-y: auto; border: 1px solid #e5e7eb; border-radius: 0.5rem; padding: 0.5rem;">
|
||||
<div style="text-align: center; padding: 2rem; color: #6b7280;">
|
||||
<div class="spinner" style="margin: 0 auto 0.5rem;"></div>
|
||||
페이지 목록을 불러오는 중...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 1.5rem; padding: 1rem; background: #fffbeb; border: 1px solid #fde047; border-radius: 0.5rem;">
|
||||
<p style="margin: 0; font-size: 0.875rem; color: #92400e;">
|
||||
<strong>💡 참고:</strong> Admin 및 System Admin은 모든 페이지에 자동으로 접근할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closePageAccessModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" id="savePageAccessBtn">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 토스트 알림 -->
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script type="module" src="/js/api-config.js?v=13"></script>
|
||||
<script type="module" src="/js/load-navbar.js?v=4"></script>
|
||||
<script src="/js/page-access-management.js?v=1"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,258 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>프로젝트 관리 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=6">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 레이아웃: 사이드바 + 콘텐츠 -->
|
||||
<div class="page-container">
|
||||
<!-- 사이드바 -->
|
||||
<aside class="sidebar">
|
||||
<nav class="sidebar-nav">
|
||||
<div class="sidebar-header">
|
||||
<h3 class="sidebar-title">관리 메뉴</h3>
|
||||
</div>
|
||||
<ul class="sidebar-menu">
|
||||
<li class="menu-item active">
|
||||
<a href="/pages/admin/projects.html">
|
||||
<span class="menu-icon">📁</span>
|
||||
<span class="menu-text">프로젝트 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/workers.html">
|
||||
<span class="menu-icon">👥</span>
|
||||
<span class="menu-text">작업자 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/workplaces.html">
|
||||
<span class="menu-icon">🏗️</span>
|
||||
<span class="menu-text">작업장 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/equipments.html">
|
||||
<span class="menu-icon">⚙️</span>
|
||||
<span class="menu-text">설비 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/tasks.html">
|
||||
<span class="menu-icon">📋</span>
|
||||
<span class="menu-text">작업 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/codes.html">
|
||||
<span class="menu-icon">🏷️</span>
|
||||
<span class="menu-text">코드 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-divider"></li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/dashboard.html">
|
||||
<span class="menu-icon">🏠</span>
|
||||
<span class="menu-text">대시보드로</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="main-content">
|
||||
<div class="dashboard-main">
|
||||
<!-- 페이지 헤더: 타이틀 + 액션 버튼 -->
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">
|
||||
<span class="title-icon">📁</span>
|
||||
프로젝트 관리
|
||||
</h1>
|
||||
<p class="page-description">프로젝트 등록, 수정, 삭제 및 기본 정보를 관리합니다</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-primary" onclick="openProjectModal()">
|
||||
<span class="btn-icon">➕</span>
|
||||
새 프로젝트 등록
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="refreshProjectList()">
|
||||
<span class="btn-icon">🔄</span>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 검색 및 필터 -->
|
||||
<div class="search-section">
|
||||
<div class="search-bar">
|
||||
<input type="text" id="searchInput" placeholder="프로젝트명 또는 Job No.로 검색..." class="search-input">
|
||||
<button class="search-btn" onclick="searchProjects()">
|
||||
<span class="search-icon">🔍</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-options">
|
||||
<select id="statusFilter" class="filter-select" onchange="filterProjects()">
|
||||
<option value="">전체 상태</option>
|
||||
<option value="active">진행중</option>
|
||||
<option value="completed">완료</option>
|
||||
<option value="paused">중단</option>
|
||||
</select>
|
||||
|
||||
<select id="sortBy" class="filter-select" onchange="sortProjects()">
|
||||
<option value="created_at">등록일순</option>
|
||||
<option value="project_name">프로젝트명순</option>
|
||||
<option value="due_date">납기일순</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 프로젝트 목록 -->
|
||||
<div class="projects-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">등록된 프로젝트</h2>
|
||||
<div class="project-stats">
|
||||
<span class="stat-item active-stat" onclick="filterByStatus('active')" title="활성 프로젝트만 보기">
|
||||
<span class="stat-icon">🟢</span>
|
||||
활성 <span id="activeProjects">0</span>개
|
||||
</span>
|
||||
<span class="stat-item inactive-stat" onclick="filterByStatus('inactive')" title="비활성 프로젝트만 보기">
|
||||
<span class="stat-icon">🔴</span>
|
||||
비활성 <span id="inactiveProjects">0</span>개
|
||||
</span>
|
||||
<span class="stat-item total-stat" onclick="filterByStatus('all')" title="전체 프로젝트 보기">
|
||||
<span class="stat-icon">📊</span>
|
||||
총 <span id="totalProjects">0</span>개
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="projects-grid" id="projectsGrid">
|
||||
<!-- 프로젝트 카드들이 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
|
||||
<div class="empty-state" id="emptyState" style="display: none;">
|
||||
<div class="empty-icon">📁</div>
|
||||
<h3>등록된 프로젝트가 없습니다</h3>
|
||||
<p>새 프로젝트를 등록해보세요.</p>
|
||||
<button class="btn btn-primary" onclick="openProjectModal()">
|
||||
<span class="btn-icon">➕</span>
|
||||
첫 번째 프로젝트 등록
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 프로젝트 등록/수정 모달 -->
|
||||
<div id="projectModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h2 id="modalTitle">새 프로젝트 등록</h2>
|
||||
<button class="modal-close-btn" onclick="closeProjectModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<form id="projectForm">
|
||||
<input type="hidden" id="projectId">
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Job No. *</label>
|
||||
<input type="text" id="jobNo" class="form-control" required placeholder="예: TK-2024-001">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">프로젝트명 *</label>
|
||||
<input type="text" id="projectName" class="form-control" required placeholder="프로젝트명을 입력하세요">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">계약일</label>
|
||||
<input type="date" id="contractDate" class="form-control">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">납기일</label>
|
||||
<input type="date" id="dueDate" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">납품방법</label>
|
||||
<select id="deliveryMethod" class="form-control">
|
||||
<option value="">선택하세요</option>
|
||||
<option value="직접납품">직접납품</option>
|
||||
<option value="택배">택배</option>
|
||||
<option value="화물">화물</option>
|
||||
<option value="현장설치">현장설치</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">현장</label>
|
||||
<input type="text" id="site" class="form-control" placeholder="현장 위치를 입력하세요">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">PM (프로젝트 매니저)</label>
|
||||
<input type="text" id="pm" class="form-control" placeholder="담당 PM을 입력하세요">
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">프로젝트 상태</label>
|
||||
<select id="projectStatus" class="form-control">
|
||||
<option value="planning">📋 계획</option>
|
||||
<option value="active" selected>🚀 진행중</option>
|
||||
<option value="completed">✅ 완료</option>
|
||||
<option value="cancelled">❌ 취소</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">완료일 (납품일)</label>
|
||||
<input type="date" id="completedDate" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<input type="checkbox" id="isActive" checked style="margin: 0;">
|
||||
<span>프로젝트 활성화</span>
|
||||
</label>
|
||||
<small style="color: #6b7280; font-size: 0.8rem;">체크 해제 시 작업보고서 입력에서 숨겨집니다</small>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeProjectModal()">취소</button>
|
||||
<button type="button" class="btn btn-danger" id="deleteProjectBtn" onclick="deleteProject()" style="display: none;">
|
||||
🗑️ 삭제
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveProject()">
|
||||
💾 저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<script type="module" src="/js/load-navbar.js?v=5"></script>
|
||||
<script type="module" src="/js/project-management.js?v=3"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,596 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>안전 체크리스트 관리 - TK-FB</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/common.css">
|
||||
<style>
|
||||
.page-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #111827);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: linear-gradient(135deg, #3b82f6, #2563eb);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-add:hover {
|
||||
background: linear-gradient(135deg, #2563eb, #1d4ed8);
|
||||
}
|
||||
|
||||
/* 탭 메뉴 */
|
||||
.tab-menu {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
transition: all 0.2s;
|
||||
margin-bottom: -2px;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
color: #3b82f6;
|
||||
border-bottom-color: #3b82f6;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 체크리스트 카드 */
|
||||
.checklist-group {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
margin-bottom: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.25rem;
|
||||
background: #f9fafb;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.group-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.group-count {
|
||||
background: #e5e7eb;
|
||||
color: #6b7280;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.checklist-items {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.checklist-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.875rem 1.25rem;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.checklist-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.item-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-weight: 500;
|
||||
color: #111827;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.item-meta {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.item-badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge-required {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.badge-optional {
|
||||
background: #e5e7eb;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
background: #eff6ff;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.btn-edit:hover {
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
/* 필터/검색 */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
/* 모달 */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 1000;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-input, .form-select, .form-textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.form-input:focus, .form-select:focus, .form-textarea:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
min-height: 80px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-radio-group {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.form-radio {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-radio input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-checkbox input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.conditional-fields {
|
||||
display: none;
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.conditional-fields.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #e5e7eb;
|
||||
color: #374151;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #d1d5db;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* 날씨 아이콘 */
|
||||
.weather-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.weather-icon.rain::before { content: '🌧️'; }
|
||||
.weather-icon.snow::before { content: '❄️'; }
|
||||
.weather-icon.heat::before { content: '🔥'; }
|
||||
.weather-icon.cold::before { content: '🥶'; }
|
||||
.weather-icon.wind::before { content: '💨'; }
|
||||
.weather-icon.fog::before { content: '🌫️'; }
|
||||
.weather-icon.dust::before { content: '😷'; }
|
||||
.weather-icon.clear::before { content: '☀️'; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.checklist-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">안전 체크리스트 관리</h1>
|
||||
<button class="btn-add" onclick="openAddModal()">
|
||||
<span>+</span> 항목 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 탭 메뉴 -->
|
||||
<div class="tab-menu">
|
||||
<button class="tab-btn active" data-tab="basic" onclick="switchTab('basic')">
|
||||
기본 사항
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="weather" onclick="switchTab('weather')">
|
||||
날씨별
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="task" onclick="switchTab('task')">
|
||||
작업별
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 기본 사항 탭 -->
|
||||
<div id="basicTab" class="tab-content active">
|
||||
<div id="basicChecklistContainer">
|
||||
<!-- 동적 생성 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 날씨별 탭 -->
|
||||
<div id="weatherTab" class="tab-content">
|
||||
<div class="filter-bar">
|
||||
<select id="weatherFilter" class="filter-select" onchange="filterByWeather()">
|
||||
<option value="">모든 날씨 조건</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="weatherChecklistContainer">
|
||||
<!-- 동적 생성 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업별 탭 -->
|
||||
<div id="taskTab" class="tab-content">
|
||||
<div class="filter-bar">
|
||||
<select id="workTypeFilter" class="filter-select" onchange="filterByWorkType()">
|
||||
<option value="">공정 선택</option>
|
||||
</select>
|
||||
<select id="taskFilter" class="filter-select" onchange="filterByTask()">
|
||||
<option value="">작업 선택</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="taskChecklistContainer">
|
||||
<!-- 동적 생성 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 추가/수정 모달 -->
|
||||
<div id="checkModal" class="modal-overlay">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title" id="modalTitle">체크 항목 추가</h2>
|
||||
<button class="modal-close" onclick="closeModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="checkForm">
|
||||
<input type="hidden" id="checkId">
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">유형</label>
|
||||
<div class="form-radio-group">
|
||||
<label class="form-radio">
|
||||
<input type="radio" name="checkType" value="basic" checked onchange="toggleConditionalFields()">
|
||||
<span>기본</span>
|
||||
</label>
|
||||
<label class="form-radio">
|
||||
<input type="radio" name="checkType" value="weather" onchange="toggleConditionalFields()">
|
||||
<span>날씨별</span>
|
||||
</label>
|
||||
<label class="form-radio">
|
||||
<input type="radio" name="checkType" value="task" onchange="toggleConditionalFields()">
|
||||
<span>작업별</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 기본 유형: 카테고리 선택 -->
|
||||
<div id="basicFields" class="conditional-fields show">
|
||||
<div class="form-group">
|
||||
<label class="form-label">카테고리</label>
|
||||
<select id="checkCategory" class="form-select">
|
||||
<option value="PPE">PPE (개인보호장비)</option>
|
||||
<option value="EQUIPMENT">EQUIPMENT (장비점검)</option>
|
||||
<option value="ENVIRONMENT">ENVIRONMENT (작업환경)</option>
|
||||
<option value="EMERGENCY">EMERGENCY (비상대응)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 날씨별 유형: 날씨 조건 선택 -->
|
||||
<div id="weatherFields" class="conditional-fields">
|
||||
<div class="form-group">
|
||||
<label class="form-label">날씨 조건</label>
|
||||
<select id="weatherCondition" class="form-select">
|
||||
<!-- 동적 로드 -->
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업별 유형: 공정/작업 선택 -->
|
||||
<div id="taskFields" class="conditional-fields">
|
||||
<div class="form-group">
|
||||
<label class="form-label">공정</label>
|
||||
<select id="modalWorkType" class="form-select" onchange="loadModalTasks()">
|
||||
<option value="">공정 선택</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">작업</label>
|
||||
<select id="modalTask" class="form-select">
|
||||
<option value="">작업 선택</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">체크 항목</label>
|
||||
<input type="text" id="checkItem" class="form-input" placeholder="예: 안전모 착용 확인" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">설명 (선택)</label>
|
||||
<textarea id="checkDescription" class="form-textarea" placeholder="항목에 대한 상세 설명"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-checkbox">
|
||||
<input type="checkbox" id="isRequired" checked>
|
||||
<span>필수 체크 항목</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">표시 순서</label>
|
||||
<input type="number" id="displayOrder" class="form-input" value="0" min="0">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn-secondary" onclick="closeModal()">취소</button>
|
||||
<button type="button" class="btn-primary" onclick="saveCheck()">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/api-config.js"></script>
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/safety-checklist-manage.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,291 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>안전관리 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/common.css?v=2">
|
||||
<link rel="stylesheet" href="/css/project-management.css?v=3">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<style>
|
||||
.status-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
border-bottom: 2px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.status-tab {
|
||||
padding: 12px 24px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: var(--gray-600);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.status-tab:hover {
|
||||
color: var(--primary-600);
|
||||
}
|
||||
|
||||
.status-tab.active {
|
||||
color: var(--primary-600);
|
||||
border-bottom-color: var(--primary-600);
|
||||
}
|
||||
|
||||
.request-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: white;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.request-table th {
|
||||
background: var(--gray-50);
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--gray-700);
|
||||
border-bottom: 2px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.request-table td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.request-table tr:hover {
|
||||
background: var(--gray-50);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-badge.pending {
|
||||
background: var(--yellow-100);
|
||||
color: var(--yellow-700);
|
||||
}
|
||||
|
||||
.status-badge.approved {
|
||||
background: var(--green-100);
|
||||
color: var(--green-700);
|
||||
}
|
||||
|
||||
.status-badge.rejected {
|
||||
background: var(--red-100);
|
||||
color: var(--red-700);
|
||||
}
|
||||
|
||||
.status-badge.training_completed {
|
||||
background: var(--blue-100);
|
||||
color: var(--blue-700);
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 32px;
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow-2xl);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-weight: 600;
|
||||
color: var(--gray-600);
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: var(--gray-900);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 64px 32px;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 20px;
|
||||
background: white;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-sm);
|
||||
border-left: 4px solid var(--primary-500);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-600);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: var(--text-3xl);
|
||||
font-weight: 700;
|
||||
color: var(--gray-900);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="work-report-container">
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="work-report-main">
|
||||
<div class="dashboard-main">
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">안전관리</h1>
|
||||
<p class="page-description">출입 신청 승인 및 안전교육 관리</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 통계 -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">승인 대기</div>
|
||||
<div class="stat-value" id="statPending">0</div>
|
||||
</div>
|
||||
<div class="stat-card" style="border-left-color: var(--green-500);">
|
||||
<div class="stat-label">승인 완료</div>
|
||||
<div class="stat-value" id="statApproved">0</div>
|
||||
</div>
|
||||
<div class="stat-card" style="border-left-color: var(--blue-500);">
|
||||
<div class="stat-label">교육 완료</div>
|
||||
<div class="stat-value" id="statTrainingCompleted">0</div>
|
||||
</div>
|
||||
<div class="stat-card" style="border-left-color: var(--red-500);">
|
||||
<div class="stat-label">반려</div>
|
||||
<div class="stat-value" id="statRejected">0</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 탭 -->
|
||||
<div class="code-section">
|
||||
<div class="status-tabs">
|
||||
<button class="status-tab active" data-status="pending" onclick="switchTab('pending')">
|
||||
승인 대기
|
||||
</button>
|
||||
<button class="status-tab" data-status="approved" onclick="switchTab('approved')">
|
||||
승인 완료
|
||||
</button>
|
||||
<button class="status-tab" data-status="training_completed" onclick="switchTab('training_completed')">
|
||||
교육 완료
|
||||
</button>
|
||||
<button class="status-tab" data-status="rejected" onclick="switchTab('rejected')">
|
||||
반려
|
||||
</button>
|
||||
<button class="status-tab" data-status="all" onclick="switchTab('all')">
|
||||
전체
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 테이블 -->
|
||||
<div id="requestTableContainer">
|
||||
<!-- 동적으로 로드됨 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 상세보기 모달 -->
|
||||
<div id="detailModal" class="modal-overlay">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>출입 신청 상세</h2>
|
||||
<button class="btn btn-secondary btn-sm" onclick="closeDetailModal()">닫기</button>
|
||||
</div>
|
||||
<div id="detailContent">
|
||||
<!-- 동적으로 로드됨 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 반려 사유 입력 모달 -->
|
||||
<div id="rejectModal" class="modal-overlay">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>반려 사유 입력</h2>
|
||||
<button class="btn btn-secondary btn-sm" onclick="closeRejectModal()">취소</button>
|
||||
</div>
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<label for="rejectionReason">반려 사유 *</label>
|
||||
<textarea id="rejectionReason" rows="4" style="width: 100%; padding: 12px; border: 1px solid var(--gray-300); border-radius: var(--radius-md);" placeholder="반려 사유를 입력하세요"></textarea>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: flex-end; gap: 12px; margin-top: 24px;">
|
||||
<button class="btn btn-secondary" onclick="closeRejectModal()">취소</button>
|
||||
<button class="btn btn-danger" onclick="confirmReject()">반려 확정</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script type="module" src="/js/load-navbar.js?v=5"></script>
|
||||
<script src="/js/safety-management.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,327 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>안전교육 진행 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/common.css?v=2">
|
||||
<link rel="stylesheet" href="/css/project-management.css?v=3">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<style>
|
||||
.training-container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.request-info-card {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
border-left: 4px solid var(--primary-500);
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-600);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
color: var(--gray-900);
|
||||
}
|
||||
|
||||
.checklist-section {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.checklist-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
background: var(--gray-50);
|
||||
border-radius: var(--radius-md);
|
||||
border: 2px solid transparent;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.checklist-item:hover {
|
||||
border-color: var(--primary-300);
|
||||
background: var(--primary-50);
|
||||
}
|
||||
|
||||
.checklist-item input[type="checkbox"] {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checklist-item label {
|
||||
flex: 1;
|
||||
font-size: var(--text-base);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.checklist-item input[type="checkbox"]:checked + label {
|
||||
color: var(--gray-500);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.signature-section {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.signature-canvas-container {
|
||||
border: 2px solid var(--gray-300);
|
||||
border-radius: var(--radius-md);
|
||||
background: white;
|
||||
margin-top: 16px;
|
||||
position: relative;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.signature-canvas {
|
||||
display: block;
|
||||
cursor: crosshair;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.signature-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.signature-placeholder {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: var(--gray-400);
|
||||
font-size: var(--text-base);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.warning-box {
|
||||
background: var(--yellow-50);
|
||||
border: 2px solid var(--yellow-300);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 16px;
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
align-items: start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.warning-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
flex: 1;
|
||||
color: var(--yellow-800);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.saved-signature-card {
|
||||
background: var(--gray-50);
|
||||
border: 2px solid var(--gray-300);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.saved-signature-card img {
|
||||
max-width: 300px;
|
||||
height: auto;
|
||||
border: 1px solid var(--gray-300);
|
||||
border-radius: var(--radius-sm);
|
||||
background: white;
|
||||
}
|
||||
|
||||
.saved-signature-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.saved-signature-number {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
color: var(--primary-600);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.saved-signature-date {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-600);
|
||||
}
|
||||
|
||||
.saved-signature-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="work-report-container">
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="work-report-main">
|
||||
<div class="dashboard-main">
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">안전교육 진행</h1>
|
||||
<p class="page-description">방문자 안전교육 실시 및 서명 받기</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="training-container">
|
||||
<!-- 출입 신청 정보 -->
|
||||
<div class="request-info-card">
|
||||
<h2 class="section-title" style="margin-bottom: 16px;">출입 신청 정보</h2>
|
||||
<div id="requestInfo" class="info-grid">
|
||||
<!-- 동적으로 로드됨 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 안전교육 체크리스트 -->
|
||||
<div class="checklist-section">
|
||||
<h2 class="section-title" style="margin-bottom: 16px;">안전교육 체크리스트</h2>
|
||||
<p style="color: var(--gray-600); margin-bottom: 20px;">
|
||||
방문자에게 다음 안전 사항을 교육하고 체크해주세요.
|
||||
</p>
|
||||
|
||||
<div id="checklistContainer">
|
||||
<div class="checklist-item">
|
||||
<input type="checkbox" id="check1" name="safety-check" value="개인보호구 착용" onchange="updateCompleteButton()">
|
||||
<label for="check1">개인보호구(안전모, 안전화, 안전복) 착용 방법 교육</label>
|
||||
</div>
|
||||
<div class="checklist-item">
|
||||
<input type="checkbox" id="check2" name="safety-check" value="작업장 위험요소" onchange="updateCompleteButton()">
|
||||
<label for="check2">작업장 내 위험요소 및 주의사항 안내</label>
|
||||
</div>
|
||||
<div class="checklist-item">
|
||||
<input type="checkbox" id="check3" name="safety-check" value="비상대피로" onchange="updateCompleteButton()">
|
||||
<label for="check3">비상대피로 및 비상연락망 안내</label>
|
||||
</div>
|
||||
<div class="checklist-item">
|
||||
<input type="checkbox" id="check4" name="safety-check" value="출입통제구역" onchange="updateCompleteButton()">
|
||||
<label for="check4">출입통제구역 및 금지사항 안내</label>
|
||||
</div>
|
||||
<div class="checklist-item">
|
||||
<input type="checkbox" id="check5" name="safety-check" value="사고발생시 대응" onchange="updateCompleteButton()">
|
||||
<label for="check5">사고 발생 시 대응 절차 교육</label>
|
||||
</div>
|
||||
<div class="checklist-item">
|
||||
<input type="checkbox" id="check6" name="safety-check" value="안전수칙 준수" onchange="updateCompleteButton()">
|
||||
<label for="check6">현장 안전수칙 준수 서약</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 경고 -->
|
||||
<div class="warning-box">
|
||||
<div class="warning-icon">⚠️</div>
|
||||
<div class="warning-text">
|
||||
<strong>중요:</strong> 모든 체크리스트 항목을 완료하고 방문자의 서명을 받은 후 교육 완료 처리를 해주세요.
|
||||
교육 완료 후에는 수정할 수 없습니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 서명 섹션 -->
|
||||
<div class="signature-section">
|
||||
<h2 class="section-title" style="margin-bottom: 16px;">방문자 서명 (<span id="signatureCount">0</span>명)</h2>
|
||||
<p style="color: var(--gray-600); margin-bottom: 20px;">
|
||||
각 방문자가 왼쪽에 이름을 쓰고 오른쪽에 서명한 후 "저장" 버튼을 눌러주세요.
|
||||
</p>
|
||||
|
||||
<div class="signature-canvas-container" style="position: relative;">
|
||||
<!-- 이름과 서명 구분선 및 라벨 -->
|
||||
<div style="position: absolute; top: 10px; left: 10px; right: 10px; display: flex; justify-content: space-between; z-index: 1; pointer-events: none;">
|
||||
<span style="font-size: var(--text-sm); color: var(--gray-500); font-weight: 600;">이름</span>
|
||||
<span style="position: absolute; left: 250px; top: 0; bottom: 0; width: 2px; background: var(--gray-300);"></span>
|
||||
<span style="font-size: var(--text-sm); color: var(--gray-500); font-weight: 600); margin-left: auto;">서명</span>
|
||||
</div>
|
||||
<canvas id="signatureCanvas" class="signature-canvas" width="800" height="300"></canvas>
|
||||
<div id="signaturePlaceholder" class="signature-placeholder" style="display: flex; flex-direction: column; align-items: center; gap: 8px;">
|
||||
<div>왼쪽에 이름을 쓰고, 오른쪽에 서명해주세요</div>
|
||||
<div style="font-size: var(--text-sm); color: var(--gray-400);">(마우스, 터치, 또는 Apple Pencil 사용)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="signature-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="clearSignature()">
|
||||
서명 지우기
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveSignature()">
|
||||
서명 저장
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style="font-size: var(--text-sm); color: var(--gray-600); margin-top: 12px;">
|
||||
서명 날짜: <span id="signatureDate"></span>
|
||||
</div>
|
||||
|
||||
<!-- 저장된 서명 목록 -->
|
||||
<div id="savedSignatures" style="margin-top: 24px;">
|
||||
<!-- 동적으로 추가됨 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 제출 버튼 -->
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="goBack()">
|
||||
취소
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" onclick="completeTraining()" id="completeBtn" disabled>
|
||||
교육 완료 처리
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script type="module" src="/js/load-navbar.js?v=5"></script>
|
||||
<script src="/js/safety-training-conduct.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,236 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>작업 관리 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 레이아웃: 사이드바 + 콘텐츠 -->
|
||||
<div class="page-container">
|
||||
<!-- 사이드바 -->
|
||||
<aside class="sidebar">
|
||||
<nav class="sidebar-nav">
|
||||
<div class="sidebar-header">
|
||||
<h3 class="sidebar-title">관리 메뉴</h3>
|
||||
</div>
|
||||
<ul class="sidebar-menu">
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/projects.html">
|
||||
<span class="menu-icon">📁</span>
|
||||
<span class="menu-text">프로젝트 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/workers.html">
|
||||
<span class="menu-icon">👥</span>
|
||||
<span class="menu-text">작업자 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/workplaces.html">
|
||||
<span class="menu-icon">🏗️</span>
|
||||
<span class="menu-text">작업장 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/equipments.html">
|
||||
<span class="menu-icon">⚙️</span>
|
||||
<span class="menu-text">설비 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item active">
|
||||
<a href="/pages/admin/tasks.html">
|
||||
<span class="menu-icon">📋</span>
|
||||
<span class="menu-text">작업 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/codes.html">
|
||||
<span class="menu-icon">🏷️</span>
|
||||
<span class="menu-text">코드 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-divider"></li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/dashboard.html">
|
||||
<span class="menu-icon">🏠</span>
|
||||
<span class="menu-text">대시보드로</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="main-content">
|
||||
<div class="dashboard-main">
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">
|
||||
<span class="title-icon">📋</span>
|
||||
작업 관리
|
||||
</h1>
|
||||
<p class="page-description">공정별 세부 작업을 등록하고 관리합니다</p>
|
||||
</div>
|
||||
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-primary" onclick="openWorkTypeModal()">
|
||||
<span class="btn-icon">🔧</span>
|
||||
공정 추가
|
||||
</button>
|
||||
<button class="btn btn-primary" onclick="openTaskModal()">
|
||||
<span class="btn-icon">➕</span>
|
||||
작업 추가
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="refreshTasks()">
|
||||
<span class="btn-icon">🔄</span>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 공정(work_types) 탭 -->
|
||||
<div class="code-tabs" id="workTypeTabs">
|
||||
<button class="tab-btn active" data-work-type="" onclick="switchWorkType('')">
|
||||
<span class="tab-icon">📋</span>
|
||||
전체
|
||||
</button>
|
||||
<!-- 공정 탭들이 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
|
||||
<!-- 작업 목록 -->
|
||||
<div class="code-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">
|
||||
<span class="section-icon">🔧</span>
|
||||
작업 목록
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="code-stats" id="taskStats">
|
||||
<span class="stat-item">
|
||||
<span class="stat-icon">📋</span>
|
||||
전체 <span id="totalCount">0</span>개
|
||||
</span>
|
||||
<span class="stat-item">
|
||||
<span class="stat-icon">✅</span>
|
||||
활성 <span id="activeCount">0</span>개
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="code-grid" id="taskGrid">
|
||||
<!-- 작업 카드들이 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 작업 추가/수정 모달 -->
|
||||
<div id="taskModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h2 id="taskModalTitle">작업 추가</h2>
|
||||
<button class="modal-close-btn" onclick="closeTaskModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<form id="taskForm" onsubmit="event.preventDefault(); saveTask();">
|
||||
<input type="hidden" id="taskId">
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">소속 공정 *</label>
|
||||
<select id="taskWorkTypeId" class="form-control" required>
|
||||
<option value="">공정 선택...</option>
|
||||
<!-- 공정 목록이 동적으로 생성됩니다 -->
|
||||
</select>
|
||||
<small class="form-help">이 작업이 속한 공정을 선택하세요</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">작업명 *</label>
|
||||
<input type="text" id="taskName" class="form-control" placeholder="예: 서스 용접, 프레임 조립" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">설명</label>
|
||||
<textarea id="taskDescription" class="form-control" rows="4" placeholder="작업에 대한 설명을 입력하세요"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<input type="checkbox" id="taskIsActive" checked style="margin: 0;">
|
||||
<span>활성화</span>
|
||||
</label>
|
||||
<small class="form-help">비활성화하면 TBM 입력 시 표시되지 않습니다</small>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeTaskModal()">취소</button>
|
||||
<button type="button" class="btn btn-danger" id="deleteTaskBtn" onclick="deleteTask()" style="display: none;">
|
||||
🗑️ 삭제
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveTask()">
|
||||
💾 저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 공정 추가/수정 모달 -->
|
||||
<div id="workTypeModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h2 id="workTypeModalTitle">공정 추가</h2>
|
||||
<button class="modal-close-btn" onclick="closeWorkTypeModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<form id="workTypeForm" onsubmit="event.preventDefault(); saveWorkType();">
|
||||
<input type="hidden" id="workTypeId">
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">공정명 *</label>
|
||||
<input type="text" id="workTypeName" class="form-control" placeholder="예: Base(구조물), Vessel(용기)" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">카테고리</label>
|
||||
<input type="text" id="workTypeCategory" class="form-control" placeholder="예: 제작, 조립">
|
||||
<small class="form-help">공정을 그룹화할 카테고리 (선택사항)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">설명</label>
|
||||
<textarea id="workTypeDescription" class="form-control" rows="3" placeholder="공정에 대한 설명"></textarea>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeWorkTypeModal()">취소</button>
|
||||
<button type="button" class="btn btn-danger" id="deleteWorkTypeBtn" onclick="deleteWorkType()" style="display: none;">
|
||||
🗑️ 삭제
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveWorkType()">
|
||||
💾 저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js?v=5"></script>
|
||||
<script type="module" src="/js/task-management.js?v=1"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,291 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>작업자 관리 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=6">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 레이아웃: 사이드바 + 콘텐츠 -->
|
||||
<div class="page-container">
|
||||
<!-- 사이드바 -->
|
||||
<aside class="sidebar">
|
||||
<nav class="sidebar-nav">
|
||||
<div class="sidebar-header">
|
||||
<h3 class="sidebar-title">관리 메뉴</h3>
|
||||
</div>
|
||||
<ul class="sidebar-menu">
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/projects.html">
|
||||
<span class="menu-icon">📁</span>
|
||||
<span class="menu-text">프로젝트 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item active">
|
||||
<a href="/pages/admin/workers.html">
|
||||
<span class="menu-icon">👥</span>
|
||||
<span class="menu-text">작업자 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/workplaces.html">
|
||||
<span class="menu-icon">🏗️</span>
|
||||
<span class="menu-text">작업장 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/equipments.html">
|
||||
<span class="menu-icon">⚙️</span>
|
||||
<span class="menu-text">설비 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/tasks.html">
|
||||
<span class="menu-icon">📋</span>
|
||||
<span class="menu-text">작업 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/codes.html">
|
||||
<span class="menu-icon">🏷️</span>
|
||||
<span class="menu-text">코드 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-divider"></li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/dashboard.html">
|
||||
<span class="menu-icon">🏠</span>
|
||||
<span class="menu-text">대시보드로</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="main-content">
|
||||
<div class="dashboard-main">
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">
|
||||
<span class="title-icon">👥</span>
|
||||
작업자 관리
|
||||
</h1>
|
||||
<p class="page-description">작업자 등록, 수정, 삭제 및 기본 정보를 관리합니다</p>
|
||||
</div>
|
||||
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-primary" onclick="openWorkerModal()">
|
||||
<span class="btn-icon">➕</span>
|
||||
새 작업자 등록
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="refreshWorkerList()">
|
||||
<span class="btn-icon">🔄</span>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 검색 및 필터 -->
|
||||
<div class="search-section">
|
||||
<div class="search-bar">
|
||||
<input type="text" id="searchInput" class="search-input" placeholder="작업자명, 직책, 전화번호로 검색...">
|
||||
<button class="search-btn" onclick="searchWorkers()">
|
||||
<span>🔍</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-options">
|
||||
<select id="jobTypeFilter" class="filter-select" onchange="filterWorkers()">
|
||||
<option value="">모든 직책</option>
|
||||
<option value="leader">그룹장</option>
|
||||
<option value="worker">작업자</option>
|
||||
<option value="admin">관리자</option>
|
||||
</select>
|
||||
|
||||
<select id="statusFilter" class="filter-select" onchange="filterWorkers()">
|
||||
<option value="">모든 상태</option>
|
||||
<option value="active">활성</option>
|
||||
<option value="inactive">비활성</option>
|
||||
</select>
|
||||
|
||||
<select id="sortBy" class="filter-select" onchange="sortWorkers()">
|
||||
<option value="created_at">등록일순</option>
|
||||
<option value="worker_name">이름순</option>
|
||||
<option value="job_type">직책순</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업자 목록 -->
|
||||
<div class="projects-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">등록된 작업자</h2>
|
||||
<div class="project-stats">
|
||||
<span class="stat-item active-stat" onclick="filterByStatus('active')" title="활성 작업자만 보기">
|
||||
<span class="stat-icon">🟢</span>
|
||||
활성 <span id="activeWorkers">0</span>명
|
||||
</span>
|
||||
<span class="stat-item inactive-stat" onclick="filterByStatus('inactive')" title="비활성 작업자만 보기">
|
||||
<span class="stat-icon">🔴</span>
|
||||
비활성 <span id="inactiveWorkers">0</span>명
|
||||
</span>
|
||||
<span class="stat-item total-stat" onclick="filterByStatus('all')" title="전체 작업자 보기">
|
||||
<span class="stat-icon">📊</span>
|
||||
총 <span id="totalWorkers">0</span>명
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업자 테이블 -->
|
||||
<div class="table-container">
|
||||
<table class="data-table" id="workersTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 60px;">상태</th>
|
||||
<th style="width: 100px;">이름</th>
|
||||
<th style="width: 100px;">직책</th>
|
||||
<th style="width: 130px;">전화번호</th>
|
||||
<th style="width: 180px;">이메일</th>
|
||||
<th style="width: 100px;">입사일</th>
|
||||
<th style="width: 100px;">부서</th>
|
||||
<th style="width: 80px;">계정</th>
|
||||
<th style="width: 80px;">현장직</th>
|
||||
<th style="width: 120px;">등록일</th>
|
||||
<th style="width: 100px;">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="workersGrid">
|
||||
<!-- 작업자 행들이 여기에 동적으로 생성됩니다 -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div class="empty-state" id="emptyState" style="display: none;">
|
||||
<div class="empty-icon">👥</div>
|
||||
<h3>등록된 작업자가 없습니다.</h3>
|
||||
<p>"새 작업자 등록" 버튼을 눌러 작업자를 등록해보세요.</p>
|
||||
<button class="btn btn-primary" onclick="openWorkerModal()">
|
||||
➕ 첫 작업자 등록하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 작업자 추가/수정 모달 -->
|
||||
<div id="workerModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h2 id="modalTitle">새 작업자 등록</h2>
|
||||
<button class="modal-close-btn" onclick="closeWorkerModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<form id="workerForm" onsubmit="event.preventDefault(); saveWorker();">
|
||||
<input type="hidden" id="workerId">
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">작업자명 *</label>
|
||||
<input type="text" id="workerName" class="form-control" placeholder="작업자 이름을 입력하세요" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">직책</label>
|
||||
<select id="jobType" class="form-control">
|
||||
<option value="worker">👷 작업자</option>
|
||||
<option value="leader">👨💼 그룹장</option>
|
||||
<option value="admin">👨💻 관리자</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">전화번호</label>
|
||||
<input type="tel" id="phoneNumber" class="form-control" placeholder="010-0000-0000">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">이메일</label>
|
||||
<input type="email" id="email" class="form-control" placeholder="example@company.com">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">입사일</label>
|
||||
<input type="date" id="hireDate" class="form-control">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">부서</label>
|
||||
<input type="text" id="department" class="form-control" placeholder="소속 부서">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">비고</label>
|
||||
<textarea id="notes" class="form-control" rows="3" placeholder="추가 정보나 특이사항을 입력하세요"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- 상태 관리 섹션 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label" style="font-weight: 600; margin-bottom: 0.75rem; display: block;">상태 관리</label>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 0.75rem;">
|
||||
<!-- 계정 생성/연동 -->
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
|
||||
<input type="checkbox" id="hasAccount" style="margin: 0; cursor: pointer;">
|
||||
<span>🔐 계정 생성/연동</span>
|
||||
</label>
|
||||
<small style="color: #6b7280; font-size: 0.75rem; margin-top: -0.5rem; margin-left: 1.5rem;">
|
||||
체크 시 로그인 계정이 자동 생성됩니다 (나의 대시보드, 연차/출퇴근 관리 가능)
|
||||
</small>
|
||||
|
||||
<!-- 현장직/사무직 구분 -->
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
|
||||
<input type="checkbox" id="isActive" checked style="margin: 0; cursor: pointer;">
|
||||
<span>🏭 현장직 (활성화)</span>
|
||||
</label>
|
||||
<small style="color: #6b7280; font-size: 0.75rem; margin-top: -0.5rem; margin-left: 1.5rem;">
|
||||
체크: 현장직 (출퇴근 관리 필요) / 체크 해제: 사무직 (출퇴근 관리 불필요)
|
||||
</small>
|
||||
|
||||
<!-- 퇴사 처리 -->
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
|
||||
<input type="checkbox" id="isResigned" style="margin: 0; cursor: pointer;">
|
||||
<span style="color: #ef4444;">🚪 퇴사 처리</span>
|
||||
</label>
|
||||
<small style="color: #ef4444; font-size: 0.75rem; margin-top: -0.5rem; margin-left: 1.5rem;">
|
||||
퇴사한 작업자로 표시됩니다. 작업 보고서에서 제외됩니다
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeWorkerModal()">취소</button>
|
||||
<button type="button" class="btn btn-danger" id="deleteWorkerBtn" onclick="deleteWorker()" style="display: none;">
|
||||
🗑️ 삭제
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveWorker()">
|
||||
💾 저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js?v=5"></script>
|
||||
<script type="module" src="/js/worker-management.js?v=7"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,414 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>작업장 관리 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 레이아웃: 사이드바 + 콘텐츠 -->
|
||||
<div class="page-container">
|
||||
<!-- 사이드바 -->
|
||||
<aside class="sidebar">
|
||||
<nav class="sidebar-nav">
|
||||
<div class="sidebar-header">
|
||||
<h3 class="sidebar-title">관리 메뉴</h3>
|
||||
</div>
|
||||
<ul class="sidebar-menu">
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/projects.html">
|
||||
<span class="menu-icon">📁</span>
|
||||
<span class="menu-text">프로젝트 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/workers.html">
|
||||
<span class="menu-icon">👥</span>
|
||||
<span class="menu-text">작업자 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item active">
|
||||
<a href="/pages/admin/workplaces.html">
|
||||
<span class="menu-icon">🏗️</span>
|
||||
<span class="menu-text">작업장 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/equipments.html">
|
||||
<span class="menu-icon">⚙️</span>
|
||||
<span class="menu-text">설비 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/tasks.html">
|
||||
<span class="menu-icon">📋</span>
|
||||
<span class="menu-text">작업 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/codes.html">
|
||||
<span class="menu-icon">🏷️</span>
|
||||
<span class="menu-text">코드 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-divider"></li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/dashboard.html">
|
||||
<span class="menu-icon">🏠</span>
|
||||
<span class="menu-text">대시보드로</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="main-content">
|
||||
<div class="dashboard-main">
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">
|
||||
<span class="title-icon">🏗️</span>
|
||||
작업장 관리
|
||||
</h1>
|
||||
<p class="page-description">공장 및 작업장을 등록하고 관리합니다</p>
|
||||
</div>
|
||||
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-primary" onclick="openCategoryModal()">
|
||||
<span class="btn-icon">➕</span>
|
||||
공장 추가
|
||||
</button>
|
||||
<button class="btn btn-primary" onclick="openWorkplaceModal()">
|
||||
<span class="btn-icon">➕</span>
|
||||
작업장 추가
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="refreshWorkplaces()">
|
||||
<span class="btn-icon">🔄</span>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 공장(카테고리) 탭 -->
|
||||
<div class="code-tabs" id="categoryTabs">
|
||||
<button class="tab-btn active" data-category="" onclick="switchCategory('')">
|
||||
<span class="tab-icon">🏗️</span>
|
||||
전체
|
||||
</button>
|
||||
<!-- 공장 탭들이 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
|
||||
<!-- 공장 레이아웃 지도 관리 섹션 (카테고리가 선택된 경우에만 표시) -->
|
||||
<div class="code-section" id="layoutMapSection" style="display: none;">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">
|
||||
<span class="section-icon">🗺️</span>
|
||||
<span id="selectedCategoryName"></span> 레이아웃 지도
|
||||
</h2>
|
||||
<button class="btn btn-secondary" onclick="openLayoutMapModal()">
|
||||
<span class="btn-icon">⚙️</span>
|
||||
지도 설정
|
||||
</button>
|
||||
</div>
|
||||
<div id="layoutMapPreview" style="padding: 20px; background: #f9fafb; border-radius: 8px; text-align: center;">
|
||||
<!-- 레이아웃 이미지 미리보기가 여기에 표시됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업장 목록 -->
|
||||
<div class="code-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">
|
||||
<span class="section-icon">🏭</span>
|
||||
작업장 목록
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="code-stats" id="workplaceStats">
|
||||
<span class="stat-item">
|
||||
<span class="stat-icon">🏗️</span>
|
||||
전체 <span id="totalCount">0</span>개
|
||||
</span>
|
||||
<span class="stat-item">
|
||||
<span class="stat-icon">✅</span>
|
||||
활성 <span id="activeCount">0</span>개
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="code-grid" id="workplaceGrid">
|
||||
<!-- 작업장 카드들이 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 공장(카테고리) 추가/수정 모달 -->
|
||||
<div id="categoryModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h2 id="categoryModalTitle">공장 추가</h2>
|
||||
<button class="modal-close-btn" onclick="closeCategoryModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<form id="categoryForm" onsubmit="event.preventDefault(); saveCategory();">
|
||||
<input type="hidden" id="categoryId">
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">공장명 *</label>
|
||||
<input type="text" id="categoryName" class="form-control" placeholder="예: 제 1공장" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">설명</label>
|
||||
<textarea id="categoryDescription" class="form-control" rows="3" placeholder="공장에 대한 설명을 입력하세요"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">표시 순서</label>
|
||||
<input type="number" id="categoryOrder" class="form-control" value="0" min="0">
|
||||
<small class="form-help">작은 숫자가 먼저 표시됩니다</small>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeCategoryModal()">취소</button>
|
||||
<button type="button" class="btn btn-danger" id="deleteCategoryBtn" onclick="deleteCategory()" style="display: none;">
|
||||
🗑️ 삭제
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveCategory()">
|
||||
💾 저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업장 추가/수정 모달 -->
|
||||
<div id="workplaceModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h2 id="workplaceModalTitle">작업장 추가</h2>
|
||||
<button class="modal-close-btn" onclick="closeWorkplaceModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<form id="workplaceForm" onsubmit="event.preventDefault(); saveWorkplace();">
|
||||
<input type="hidden" id="workplaceId">
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">소속 공장</label>
|
||||
<select id="workplaceCategoryId" class="form-control">
|
||||
<option value="">공장 선택</option>
|
||||
<!-- 공장 목록이 동적으로 생성됩니다 -->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">작업장명 *</label>
|
||||
<input type="text" id="workplaceName" class="form-control" placeholder="예: 서스작업장, 조립구역" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">작업장 용도</label>
|
||||
<select id="workplacePurpose" class="form-control">
|
||||
<option value="">선택 안 함</option>
|
||||
<option value="작업구역">작업구역</option>
|
||||
<option value="설비">설비</option>
|
||||
<option value="휴게시설">휴게시설</option>
|
||||
<option value="회의실">회의실</option>
|
||||
<option value="창고">창고</option>
|
||||
<option value="기타">기타</option>
|
||||
</select>
|
||||
<small class="form-help">작업장의 주요 용도를 선택하세요</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">표시 순서</label>
|
||||
<input type="number" id="displayPriority" class="form-control" value="0" min="0">
|
||||
<small class="form-help">숫자가 작을수록 먼저 표시됩니다</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">설명</label>
|
||||
<textarea id="workplaceDescription" class="form-control" rows="4" placeholder="작업장에 대한 설명을 입력하세요"></textarea>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeWorkplaceModal()">취소</button>
|
||||
<button type="button" class="btn btn-danger" id="deleteWorkplaceBtn" onclick="deleteWorkplace()" style="display: none;">
|
||||
🗑️ 삭제
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveWorkplace()">
|
||||
💾 저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업장 지도 관리 모달 -->
|
||||
<div id="workplaceMapModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 90vw; max-height: 90vh;">
|
||||
<div class="modal-header">
|
||||
<h2 id="workplaceMapModalTitle">작업장 지도 관리</h2>
|
||||
<button class="modal-close-btn" onclick="closeWorkplaceMapModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body" style="overflow-y: auto;">
|
||||
<!-- Step 1: 이미지 업로드 -->
|
||||
<div class="form-section" style="border-bottom: 2px solid #e5e7eb; padding-bottom: 20px; margin-bottom: 20px;">
|
||||
<h3 style="font-size: 16px; font-weight: 600; margin-bottom: 12px;">Step 1. 작업장 레이아웃 이미지 업로드</h3>
|
||||
<p style="color: #64748b; font-size: 14px; margin-bottom: 16px;">
|
||||
작업장의 상세 레이아웃 이미지를 업로드하세요
|
||||
</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">현재 이미지</label>
|
||||
<div id="workplaceLayoutPreview" style="background: #f9fafb; border: 2px dashed #cbd5e1; padding: 20px; border-radius: 8px; text-align: center; min-height: 200px;">
|
||||
<span style="color: #94a3b8;">업로드된 이미지가 없습니다</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">새 이미지 업로드</label>
|
||||
<input type="file" id="workplaceLayoutFile" accept="image/*" class="form-control" onchange="previewWorkplaceLayoutImage(event)">
|
||||
<small class="form-help">JPG, PNG, GIF 형식 지원 (최대 5MB)</small>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-primary" onclick="uploadWorkplaceLayout()">
|
||||
📤 이미지 업로드
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: 설비/영역 정의 -->
|
||||
<div class="form-section">
|
||||
<h3 style="font-size: 16px; font-weight: 600; margin-bottom: 12px;">Step 2. 설비 위치 정의 (선택사항)</h3>
|
||||
<p style="color: #64748b; font-size: 14px; margin-bottom: 16px;">
|
||||
작업장 이미지 위에 마우스로 드래그하여 각 설비의 위치를 지정하세요
|
||||
</p>
|
||||
|
||||
<!-- 영역 그리기 캔버스 -->
|
||||
<div style="position: relative; display: inline-block; margin-bottom: 20px;" id="workplaceCanvasContainer">
|
||||
<canvas id="workplaceRegionCanvas" style="border: 2px solid #cbd5e1; cursor: crosshair; max-width: 100%;"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- 설비 선택 및 영역 목록 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">설비 이름 입력</label>
|
||||
<input type="text" id="equipmentNameInput" class="form-control" placeholder="예: CNC-01, 선반기-A" style="margin-bottom: 12px;">
|
||||
<small class="form-help">드래그로 영역을 선택한 후 설비 이름을 입력하고 저장하세요</small>
|
||||
<div style="display: flex; gap: 8px; margin-top: 12px;">
|
||||
<button type="button" class="btn btn-secondary" onclick="clearWorkplaceCurrentRegion()">
|
||||
🗑️ 현재 영역 지우기
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveWorkplaceEquipmentRegion()">
|
||||
💾 설비 위치 저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 정의된 영역 목록 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">정의된 설비 목록</label>
|
||||
<div id="workplaceEquipmentList" style="background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px; min-height: 100px;">
|
||||
<p style="color: #94a3b8; text-align: center;">아직 정의된 설비가 없습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeWorkplaceMapModal()">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 레이아웃 지도 설정 모달 -->
|
||||
<div id="layoutMapModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 90vw; max-height: 90vh;">
|
||||
<div class="modal-header">
|
||||
<h2>🗺️ 공장 레이아웃 지도 설정</h2>
|
||||
<button class="modal-close-btn" onclick="closeLayoutMapModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body" style="overflow-y: auto;">
|
||||
<!-- Step 1: 이미지 업로드 -->
|
||||
<div class="form-section" style="border-bottom: 2px solid #e5e7eb; padding-bottom: 20px; margin-bottom: 20px;">
|
||||
<h3 style="font-size: 16px; font-weight: 600; margin-bottom: 12px;">Step 1. 공장 레이아웃 이미지 업로드</h3>
|
||||
<div class="form-group">
|
||||
<label class="form-label">현재 이미지</label>
|
||||
<div id="currentLayoutImage" style="background: #f9fafb; border: 2px dashed #cbd5e1; padding: 20px; border-radius: 8px; text-align: center;">
|
||||
<span style="color: #94a3b8;">업로드된 이미지가 없습니다</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">새 이미지 업로드</label>
|
||||
<input type="file" id="layoutImageFile" accept="image/*" class="form-control" onchange="previewLayoutImage(event)">
|
||||
<small class="form-help">JPG, PNG, GIF 형식 지원 (최대 5MB)</small>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary" onclick="uploadLayoutImage()">
|
||||
📤 이미지 업로드
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: 작업장 영역 정의 -->
|
||||
<div class="form-section">
|
||||
<h3 style="font-size: 16px; font-weight: 600; margin-bottom: 12px;">Step 2. 작업장 영역 정의</h3>
|
||||
<p style="color: #64748b; font-size: 14px; margin-bottom: 16px;">
|
||||
이미지 위에 마우스로 드래그하여 각 작업장의 위치를 지정하세요
|
||||
</p>
|
||||
|
||||
<!-- 영역 그리기 캔버스 -->
|
||||
<div style="position: relative; display: inline-block; margin-bottom: 20px;" id="canvasContainer">
|
||||
<canvas id="regionCanvas" style="border: 2px solid #cbd5e1; cursor: crosshair; max-width: 100%;"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- 작업장 선택 및 영역 목록 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">작업장 선택</label>
|
||||
<select id="regionWorkplaceSelect" class="form-control" style="margin-bottom: 12px;">
|
||||
<option value="">작업장을 선택하세요</option>
|
||||
</select>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button type="button" class="btn btn-secondary" onclick="clearCurrentRegion()">
|
||||
🗑️ 현재 영역 지우기
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveRegion()">
|
||||
💾 선택 영역 저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 정의된 영역 목록 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">정의된 영역 목록</label>
|
||||
<div id="regionList" style="background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px; min-height: 100px;">
|
||||
<!-- 영역 목록이 여기에 표시됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeLayoutMapModal()">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js?v=5"></script>
|
||||
<script type="module" src="/js/workplace-management.js?v=3"></script>
|
||||
<script type="module" src="/js/workplace-layout-map.js?v=1"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,143 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>연간 연차 현황 | 테크니컬코리아</title>
|
||||
|
||||
<!-- 모던 디자인 시스템 적용 -->
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/annual-vacation-overview.css">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
|
||||
<!-- 스크립트 -->
|
||||
<script type="module" src="/js/api-config.js"></script>
|
||||
<script type="module" src="/js/auth-check.js" defer></script>
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0"></script>
|
||||
<script type="module" src="/js/annual-vacation-overview.js" defer></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- 메인 컨테이너 -->
|
||||
<div class="page-container">
|
||||
|
||||
<!-- 네비게이션 헤더 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="main-content">
|
||||
<div class="content-wrapper">
|
||||
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">📊 연간 연차 현황</h1>
|
||||
<p class="page-description">모든 작업자의 연간 휴가 현황을 차트로 확인합니다</p>
|
||||
</div>
|
||||
|
||||
<!-- 필터 섹션 -->
|
||||
<section class="filter-section">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="filter-controls">
|
||||
<div class="form-group">
|
||||
<label for="yearSelect">조회 연도</label>
|
||||
<select id="yearSelect" class="form-select">
|
||||
<!-- JavaScript로 동적 생성 -->
|
||||
</select>
|
||||
</div>
|
||||
<button id="refreshBtn" class="btn btn-primary">
|
||||
조회
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 탭 네비게이션 -->
|
||||
<section class="tabs-section">
|
||||
<div class="tabs-nav">
|
||||
<button class="tab-btn active" data-tab="annualUsage">연간 사용 기록</button>
|
||||
<button class="tab-btn" data-tab="monthlyDetails">월별 상세 기록</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 탭 1: 연간 사용 기록 -->
|
||||
<section id="annualUsageTab" class="tab-content active">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">월별 휴가 사용 현황</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="chart-container">
|
||||
<canvas id="annualUsageChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 탭 2: 월별 상세 기록 -->
|
||||
<section id="monthlyDetailsTab" class="tab-content">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">월별 상세 기록</h2>
|
||||
<div class="month-controls">
|
||||
<select id="monthSelect" class="form-select">
|
||||
<option value="1">1월</option>
|
||||
<option value="2">2월</option>
|
||||
<option value="3">3월</option>
|
||||
<option value="4">4월</option>
|
||||
<option value="5">5월</option>
|
||||
<option value="6">6월</option>
|
||||
<option value="7">7월</option>
|
||||
<option value="8">8월</option>
|
||||
<option value="9">9월</option>
|
||||
<option value="10">10월</option>
|
||||
<option value="11">11월</option>
|
||||
<option value="12">12월</option>
|
||||
</select>
|
||||
<button id="exportExcelBtn" class="btn btn-sm btn-secondary">
|
||||
📥 엑셀 다운로드
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>작업자명</th>
|
||||
<th>휴가 유형</th>
|
||||
<th>시작일</th>
|
||||
<th>종료일</th>
|
||||
<th>사용 일수</th>
|
||||
<th>사유</th>
|
||||
<th>상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="monthlyTableBody">
|
||||
<tr>
|
||||
<td colspan="7" class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>데이터를 불러오는 중...</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 알림 토스트 -->
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,395 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>일일 출퇴근 입력 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="main-content">
|
||||
<div class="dashboard-main">
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">
|
||||
<span class="title-icon">📅</span>
|
||||
일일 출퇴근 입력
|
||||
</h1>
|
||||
<p class="page-description">오늘 출근한 작업자들의 출퇴근 기록을 입력합니다</p>
|
||||
</div>
|
||||
|
||||
<div class="page-actions">
|
||||
<input type="date" id="selectedDate" class="form-control" style="width: auto;">
|
||||
<button class="btn btn-primary" onclick="loadAttendanceRecords()">
|
||||
<span>🔄 새로고침</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 출퇴근 입력 폼 -->
|
||||
<div class="content-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">작업자 출퇴근 기록</h2>
|
||||
<p class="text-muted">근태 구분을 선택하면 근무시간이 자동 입력됩니다. 연장근로는 별도로 입력 가능합니다. (조퇴: 2시간, 반반차: 6시간, 반차: 4시간, 정시: 8시간, 주말근무: 0시간)</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="attendanceList" class="data-table-container">
|
||||
<!-- 출퇴근 기록 목록이 여기에 동적으로 렌더링됩니다 -->
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin-top: 2rem;">
|
||||
<button class="btn btn-primary" onclick="saveAllAttendance()" style="padding: 1rem 3rem; font-size: 1.1rem;">
|
||||
<span>💾 전체 저장</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script type="module" src="/js/load-navbar.js?v=5"></script>
|
||||
<script type="module">
|
||||
import '/js/api-config.js?v=3';
|
||||
</script>
|
||||
<script>
|
||||
// axios 기본 설정
|
||||
(function() {
|
||||
const checkApiConfig = setInterval(() => {
|
||||
if (window.API_BASE_URL) {
|
||||
clearInterval(checkApiConfig);
|
||||
axios.defaults.baseURL = window.API_BASE_URL;
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
axios.interceptors.request.use(
|
||||
config => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
error => Promise.reject(error)
|
||||
);
|
||||
|
||||
axios.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
if (error.response?.status === 401) {
|
||||
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
|
||||
window.location.href = '/pages/login.html';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
}, 50);
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// 전역 변수
|
||||
let workers = [];
|
||||
let attendanceRecords = [];
|
||||
|
||||
// 근태 구분 옵션 (근무시간 자동 설정, 연장근로는 별도 입력)
|
||||
const attendanceTypes = [
|
||||
{ value: 'on_time', label: '정시', hours: 8 },
|
||||
{ value: 'half_leave', label: '반차', hours: 4 },
|
||||
{ value: 'quarter_leave', label: '반반차', hours: 6 },
|
||||
{ value: 'early_leave', label: '조퇴', hours: 2 },
|
||||
{ value: 'weekend_work', label: '주말근무', hours: 0 },
|
||||
{ value: 'annual_leave', label: '연차', hours: 0 },
|
||||
{ value: 'custom', label: '특이사항', hours: 0 }
|
||||
];
|
||||
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await waitForAxiosConfig();
|
||||
initializePage();
|
||||
});
|
||||
|
||||
function waitForAxiosConfig() {
|
||||
return new Promise((resolve) => {
|
||||
const check = setInterval(() => {
|
||||
if (axios.defaults.baseURL) {
|
||||
clearInterval(check);
|
||||
resolve();
|
||||
}
|
||||
}, 50);
|
||||
setTimeout(() => {
|
||||
clearInterval(check);
|
||||
resolve();
|
||||
}, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
async function initializePage() {
|
||||
// 오늘 날짜 설정
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
document.getElementById('selectedDate').value = today;
|
||||
|
||||
try {
|
||||
await loadWorkers();
|
||||
await loadAttendanceRecords();
|
||||
} catch (error) {
|
||||
console.error('초기화 오류:', error);
|
||||
alert('페이지 초기화 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorkers() {
|
||||
try {
|
||||
const response = await axios.get('/workers');
|
||||
if (response.data.success) {
|
||||
workers = response.data.data.filter(w => w.employment_status === 'employed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('작업자 목록 로드 오류:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAttendanceRecords() {
|
||||
const selectedDate = document.getElementById('selectedDate').value;
|
||||
if (!selectedDate) {
|
||||
alert('날짜를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 출퇴근 기록과 체크인 목록(휴가 정보 포함)을 동시에 가져오기
|
||||
const [recordsResponse, checkinResponse] = await Promise.all([
|
||||
axios.get(`/attendance/records?date=${selectedDate}`).catch(() => ({ data: { success: false, data: [] } })),
|
||||
axios.get(`/attendance/checkin-list?date=${selectedDate}`).catch(() => ({ data: { success: false, data: [] } }))
|
||||
]);
|
||||
|
||||
const existingRecords = recordsResponse.data.success ? recordsResponse.data.data : [];
|
||||
const checkinList = checkinResponse.data.success ? checkinResponse.data.data : [];
|
||||
|
||||
// 체크인 목록을 기준으로 출퇴근 기록 생성 (연차 정보 포함)
|
||||
attendanceRecords = checkinList.map(worker => {
|
||||
const existingRecord = existingRecords.find(r => r.worker_id === worker.worker_id);
|
||||
const isOnVacation = worker.vacation_status === 'approved';
|
||||
|
||||
// 기존 기록이 있으면 사용, 없으면 초기화
|
||||
if (existingRecord) {
|
||||
return existingRecord;
|
||||
} else {
|
||||
return {
|
||||
worker_id: worker.worker_id,
|
||||
worker_name: worker.worker_name,
|
||||
attendance_date: selectedDate,
|
||||
total_hours: isOnVacation ? 0 : 8,
|
||||
overtime_hours: 0,
|
||||
attendance_type: isOnVacation ? 'annual_leave' : 'on_time',
|
||||
is_custom: false,
|
||||
is_new: true,
|
||||
is_on_vacation: isOnVacation,
|
||||
vacation_type_name: worker.vacation_type_name
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
renderAttendanceList();
|
||||
} catch (error) {
|
||||
console.error('출퇴근 기록 로드 오류:', error);
|
||||
alert('출퇴근 기록 조회 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
function initializeAttendanceRecords() {
|
||||
const selectedDate = document.getElementById('selectedDate').value;
|
||||
attendanceRecords = workers.map(worker => ({
|
||||
worker_id: worker.worker_id,
|
||||
worker_name: worker.worker_name,
|
||||
attendance_date: selectedDate,
|
||||
total_hours: 8,
|
||||
overtime_hours: 0,
|
||||
attendance_type: 'on_time',
|
||||
is_custom: false,
|
||||
is_new: true
|
||||
}));
|
||||
renderAttendanceList();
|
||||
}
|
||||
|
||||
function renderAttendanceList() {
|
||||
const container = document.getElementById('attendanceList');
|
||||
|
||||
if (attendanceRecords.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<p>등록된 작업자가 없거나 출퇴근 기록이 없습니다.</p>
|
||||
<button class="btn btn-primary" onclick="initializeAttendanceRecords()">
|
||||
작업자 목록으로 초기화
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const tableHTML = `
|
||||
<table class="data-table" style="font-size: 0.95rem;">
|
||||
<thead style="background-color: #f8f9fa;">
|
||||
<tr>
|
||||
<th style="width: 120px;">작업자</th>
|
||||
<th style="width: 180px;">근태 구분</th>
|
||||
<th style="width: 100px;">근무시간</th>
|
||||
<th style="width: 120px;">연장근로</th>
|
||||
<th style="width: 100px;">특이사항</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${attendanceRecords.map((record, index) => {
|
||||
const isCustom = record.is_custom || record.attendance_type === 'custom';
|
||||
const isHoursReadonly = !isCustom; // 특이사항이 아니면 근무시간은 읽기 전용
|
||||
|
||||
const isOnVacation = record.is_on_vacation || false;
|
||||
const vacationLabel = record.vacation_type_name ? ` (${record.vacation_type_name})` : '';
|
||||
|
||||
return `
|
||||
<tr style="border-bottom: 1px solid #e5e7eb; ${isOnVacation ? 'background-color: #f0f9ff;' : ''}">
|
||||
<td style="padding: 0.75rem; font-weight: 600;">
|
||||
${record.worker_name}
|
||||
${isOnVacation ? `<span style="margin-left: 0.5rem; display: inline-block; padding: 0.125rem 0.5rem; background-color: #dbeafe; color: #1e40af; border-radius: 0.25rem; font-size: 0.75rem; font-weight: 500;">연차${vacationLabel}</span>` : ''}
|
||||
</td>
|
||||
<td style="padding: 0.75rem;">
|
||||
<select class="form-control"
|
||||
onchange="updateAttendanceType(${index}, this.value)"
|
||||
style="width: 160px; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 0.375rem;">
|
||||
${attendanceTypes.map(type => `
|
||||
<option value="${type.value}" ${record.attendance_type === type.value ? 'selected' : ''}>
|
||||
${type.label}
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</td>
|
||||
<td style="padding: 0.75rem;">
|
||||
<input type="number" class="form-control"
|
||||
id="hours_${index}"
|
||||
value="${record.total_hours || 0}"
|
||||
min="0" max="24" step="0.5"
|
||||
${isHoursReadonly ? 'readonly' : ''}
|
||||
onchange="updateTotalHours(${index}, this.value)"
|
||||
style="width: 90px; padding: 0.5rem; border: 1px solid ${isHoursReadonly ? '#e5e7eb' : '#d1d5db'};
|
||||
border-radius: 0.375rem; background-color: ${isHoursReadonly ? '#f9fafb' : 'white'}; text-align: center;">
|
||||
</td>
|
||||
<td style="padding: 0.75rem;">
|
||||
<input type="number" class="form-control"
|
||||
id="overtime_${index}"
|
||||
value="${record.overtime_hours || 0}"
|
||||
min="0" max="12" step="0.5"
|
||||
onchange="updateOvertimeHours(${index}, this.value)"
|
||||
style="width: 90px; padding: 0.5rem; border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem; background-color: white; text-align: center;">
|
||||
</td>
|
||||
<td style="padding: 0.75rem; text-align: center;">
|
||||
${isCustom ?
|
||||
'<span style="color: #dc2626; font-weight: 600;">✓</span>' :
|
||||
'<span style="color: #9ca3af;">-</span>'}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
|
||||
container.innerHTML = tableHTML;
|
||||
}
|
||||
|
||||
function updateTotalHours(index, value) {
|
||||
attendanceRecords[index].total_hours = parseFloat(value) || 0;
|
||||
}
|
||||
|
||||
function updateOvertimeHours(index, value) {
|
||||
attendanceRecords[index].overtime_hours = parseFloat(value) || 0;
|
||||
}
|
||||
|
||||
function updateAttendanceType(index, value) {
|
||||
const record = attendanceRecords[index];
|
||||
record.attendance_type = value;
|
||||
|
||||
// 근태 구분에 따라 자동으로 근무시간 설정
|
||||
const attendanceType = attendanceTypes.find(t => t.value === value);
|
||||
|
||||
if (value === 'custom') {
|
||||
// 특이사항 선택 시 수동 입력 가능
|
||||
record.is_custom = true;
|
||||
// 기존 값 유지, 수동 입력 가능
|
||||
} else if (attendanceType) {
|
||||
// 다른 근태 구분 선택 시 근무시간만 자동 설정
|
||||
record.is_custom = false;
|
||||
record.total_hours = attendanceType.hours;
|
||||
// 연장근로는 유지 (별도 입력 가능)
|
||||
}
|
||||
|
||||
// UI 다시 렌더링
|
||||
renderAttendanceList();
|
||||
}
|
||||
|
||||
async function saveAllAttendance() {
|
||||
const selectedDate = document.getElementById('selectedDate').value;
|
||||
|
||||
if (!selectedDate) {
|
||||
alert('날짜를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (attendanceRecords.length === 0) {
|
||||
alert('저장할 출퇴근 기록이 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 모든 기록을 API 형식에 맞게 변환
|
||||
const recordsToSave = attendanceRecords.map(record => ({
|
||||
worker_id: record.worker_id,
|
||||
attendance_date: selectedDate,
|
||||
total_hours: record.total_hours || 0,
|
||||
overtime_hours: record.overtime_hours || 0,
|
||||
attendance_type: record.attendance_type || 'on_time',
|
||||
is_custom: record.is_custom || false
|
||||
}));
|
||||
|
||||
try {
|
||||
// 각 기록을 순차적으로 저장
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const data of recordsToSave) {
|
||||
try {
|
||||
const response = await axios.post('/attendance/records', data);
|
||||
if (response.data.success) {
|
||||
successCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`작업자 ${data.worker_id} 저장 오류:`, error);
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (errorCount === 0) {
|
||||
alert(`${successCount}건의 출퇴근 기록이 모두 저장되었습니다.`);
|
||||
await loadAttendanceRecords(); // 저장 후 새로고침
|
||||
} else {
|
||||
alert(`${successCount}건 저장 완료, ${errorCount}건 저장 실패\n자세한 내용은 콘솔을 확인해주세요.`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('전체 저장 오류:', error);
|
||||
alert('저장 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,490 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>월별 출퇴근 현황 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<style>
|
||||
.calendar-container {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
.calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 1px;
|
||||
background-color: #e5e7eb;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
.calendar-header {
|
||||
background-color: #f3f4f6;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
.calendar-day {
|
||||
background-color: white;
|
||||
padding: 0.5rem;
|
||||
min-height: 100px;
|
||||
position: relative;
|
||||
}
|
||||
.calendar-day.today {
|
||||
background-color: #fef3c7;
|
||||
}
|
||||
.calendar-day.other-month {
|
||||
background-color: #f9fafb;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.day-number {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.day-info {
|
||||
font-size: 0.75rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.attendance-badge {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.badge-normal {
|
||||
background-color: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
.badge-overtime {
|
||||
background-color: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
.badge-annual {
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
.badge-half {
|
||||
background-color: #e0e7ff;
|
||||
color: #3730a3;
|
||||
}
|
||||
.badge-quarter {
|
||||
background-color: #f3e8ff;
|
||||
color: #5b21b6;
|
||||
}
|
||||
.badge-early {
|
||||
background-color: #fce7f3;
|
||||
color: #9f1239;
|
||||
}
|
||||
.worker-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.summary-card {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.summary-item {
|
||||
background: white;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
.summary-label {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.summary-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="main-content">
|
||||
<div class="dashboard-main">
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">
|
||||
<span class="title-icon">📆</span>
|
||||
월별 출퇴근 현황
|
||||
</h1>
|
||||
<p class="page-description">이번 달 출퇴근 현황을 조회합니다</p>
|
||||
</div>
|
||||
|
||||
<div class="page-actions">
|
||||
<input type="month" id="selectedMonth" class="form-control" style="width: auto;">
|
||||
<button class="btn btn-primary" onclick="loadMonthlyData()">
|
||||
<span>🔄 새로고침</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업자 선택 -->
|
||||
<div class="content-section">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="worker-selector">
|
||||
<label for="workerSelect" style="font-weight: 600;">작업자:</label>
|
||||
<select id="workerSelect" class="form-control" style="width: 300px;" onchange="loadMonthlyData()">
|
||||
<option value="">작업자를 선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 월별 요약 통계 -->
|
||||
<div class="content-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">월별 요약</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="summary-card" id="summarySection">
|
||||
<!-- 요약 통계가 여기에 동적으로 렌더링됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 달력 -->
|
||||
<div class="content-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title" id="calendarTitle">출퇴근 달력</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="calendar-container">
|
||||
<div class="calendar-grid">
|
||||
<div class="calendar-header">일</div>
|
||||
<div class="calendar-header">월</div>
|
||||
<div class="calendar-header">화</div>
|
||||
<div class="calendar-header">수</div>
|
||||
<div class="calendar-header">목</div>
|
||||
<div class="calendar-header">금</div>
|
||||
<div class="calendar-header">토</div>
|
||||
<div id="calendarDays">
|
||||
<!-- 달력 날짜가 여기에 동적으로 렌더링됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script type="module" src="/js/load-navbar.js?v=5"></script>
|
||||
<script type="module">
|
||||
import '/js/api-config.js?v=3';
|
||||
</script>
|
||||
<script>
|
||||
// axios 기본 설정
|
||||
(function() {
|
||||
const checkApiConfig = setInterval(() => {
|
||||
if (window.API_BASE_URL) {
|
||||
clearInterval(checkApiConfig);
|
||||
axios.defaults.baseURL = window.API_BASE_URL;
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
axios.interceptors.request.use(
|
||||
config => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
error => Promise.reject(error)
|
||||
);
|
||||
|
||||
axios.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
if (error.response?.status === 401) {
|
||||
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
|
||||
window.location.href = '/pages/login.html';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
}, 50);
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// 전역 변수
|
||||
let workers = [];
|
||||
let attendanceRecords = [];
|
||||
let currentUser = null;
|
||||
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await waitForAxiosConfig();
|
||||
initializePage();
|
||||
});
|
||||
|
||||
function waitForAxiosConfig() {
|
||||
return new Promise((resolve) => {
|
||||
const check = setInterval(() => {
|
||||
if (axios.defaults.baseURL) {
|
||||
clearInterval(check);
|
||||
resolve();
|
||||
}
|
||||
}, 50);
|
||||
setTimeout(() => {
|
||||
clearInterval(check);
|
||||
resolve();
|
||||
}, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
async function initializePage() {
|
||||
// 현재 년월 설정
|
||||
const now = new Date();
|
||||
const yearMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
||||
document.getElementById('selectedMonth').value = yearMonth;
|
||||
|
||||
// 현재 사용자 정보 가져오기
|
||||
try {
|
||||
const userInfo = JSON.parse(localStorage.getItem('user'));
|
||||
currentUser = userInfo;
|
||||
|
||||
await loadWorkers();
|
||||
|
||||
// 관리자가 아니면 자동으로 자신 선택
|
||||
if (currentUser.access_level !== 'system' && currentUser.access_level !== 'admin') {
|
||||
const workerSelect = document.getElementById('workerSelect');
|
||||
if (currentUser.worker_id) {
|
||||
workerSelect.value = currentUser.worker_id;
|
||||
}
|
||||
}
|
||||
|
||||
await loadMonthlyData();
|
||||
} catch (error) {
|
||||
console.error('초기화 오류:', error);
|
||||
alert('페이지 초기화 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorkers() {
|
||||
try {
|
||||
const response = await axios.get('/workers');
|
||||
if (response.data.success) {
|
||||
workers = response.data.data.filter(w => w.employment_status === 'employed');
|
||||
|
||||
const select = document.getElementById('workerSelect');
|
||||
select.innerHTML = '<option value="">작업자를 선택하세요</option>';
|
||||
|
||||
workers.forEach(worker => {
|
||||
const option = document.createElement('option');
|
||||
option.value = worker.worker_id;
|
||||
option.textContent = worker.worker_name;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('작업자 목록 로드 오류:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMonthlyData() {
|
||||
const selectedMonth = document.getElementById('selectedMonth').value;
|
||||
const workerId = document.getElementById('workerSelect').value;
|
||||
|
||||
if (!selectedMonth) {
|
||||
alert('월을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!workerId) {
|
||||
alert('작업자를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 선택한 월의 첫날과 마지막 날 계산
|
||||
const [year, month] = selectedMonth.split('-');
|
||||
const startDate = `${year}-${month}-01`;
|
||||
const endDate = new Date(year, month, 0).getDate();
|
||||
const endDateStr = `${year}-${month}-${String(endDate).padStart(2, '0')}`;
|
||||
|
||||
// 출퇴근 기록 로드
|
||||
const response = await axios.get(`/attendance/records`, {
|
||||
params: {
|
||||
worker_id: workerId,
|
||||
start_date: startDate,
|
||||
end_date: endDateStr
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
attendanceRecords = response.data.data || [];
|
||||
renderCalendar();
|
||||
renderSummary();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('월별 데이터 로드 오류:', error);
|
||||
if (error.response?.status === 404) {
|
||||
attendanceRecords = [];
|
||||
renderCalendar();
|
||||
renderSummary();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderCalendar() {
|
||||
const selectedMonth = document.getElementById('selectedMonth').value;
|
||||
const [year, month] = selectedMonth.split('-');
|
||||
const firstDay = new Date(year, month - 1, 1);
|
||||
const lastDay = new Date(year, month, 0);
|
||||
const today = new Date();
|
||||
|
||||
// 달력 제목 업데이트
|
||||
document.getElementById('calendarTitle').textContent = `${year}년 ${month}월 출퇴근 달력`;
|
||||
|
||||
// 달력 그리드 생성
|
||||
const calendarDays = document.getElementById('calendarDays');
|
||||
calendarDays.innerHTML = '';
|
||||
|
||||
// 이전 달의 빈 칸
|
||||
for (let i = 0; i < firstDay.getDay(); i++) {
|
||||
const emptyDay = document.createElement('div');
|
||||
emptyDay.className = 'calendar-day other-month';
|
||||
calendarDays.appendChild(emptyDay);
|
||||
}
|
||||
|
||||
// 현재 달의 날짜
|
||||
for (let day = 1; day <= lastDay.getDate(); day++) {
|
||||
const dayElement = document.createElement('div');
|
||||
const currentDate = new Date(year, month - 1, day);
|
||||
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
|
||||
dayElement.className = 'calendar-day';
|
||||
|
||||
// 오늘 날짜 표시
|
||||
if (currentDate.toDateString() === today.toDateString()) {
|
||||
dayElement.classList.add('today');
|
||||
}
|
||||
|
||||
// 날짜 번호
|
||||
const dayNumber = document.createElement('div');
|
||||
dayNumber.className = 'day-number';
|
||||
dayNumber.textContent = day;
|
||||
dayElement.appendChild(dayNumber);
|
||||
|
||||
// 출퇴근 기록 찾기
|
||||
const record = attendanceRecords.find(r => r.attendance_date === dateStr);
|
||||
if (record) {
|
||||
const infoDiv = document.createElement('div');
|
||||
infoDiv.className = 'day-info';
|
||||
|
||||
// 근무시간
|
||||
const hoursSpan = document.createElement('div');
|
||||
hoursSpan.textContent = `${record.total_hours}시간`;
|
||||
infoDiv.appendChild(hoursSpan);
|
||||
|
||||
// 야근 표시
|
||||
if (record.is_overtime) {
|
||||
const overtimeBadge = document.createElement('span');
|
||||
overtimeBadge.className = 'attendance-badge badge-overtime';
|
||||
overtimeBadge.textContent = '야근';
|
||||
infoDiv.appendChild(overtimeBadge);
|
||||
}
|
||||
|
||||
// 근태 구분
|
||||
if (record.attendance_type && record.attendance_type !== 'normal') {
|
||||
const typeBadge = document.createElement('span');
|
||||
typeBadge.className = 'attendance-badge';
|
||||
|
||||
switch (record.attendance_type) {
|
||||
case 'annual_leave':
|
||||
typeBadge.classList.add('badge-annual');
|
||||
typeBadge.textContent = '연차';
|
||||
break;
|
||||
case 'half_leave':
|
||||
typeBadge.classList.add('badge-half');
|
||||
typeBadge.textContent = '반차';
|
||||
break;
|
||||
case 'quarter_leave':
|
||||
typeBadge.classList.add('badge-quarter');
|
||||
typeBadge.textContent = '반반차';
|
||||
break;
|
||||
case 'early_leave':
|
||||
typeBadge.classList.add('badge-early');
|
||||
typeBadge.textContent = '조퇴';
|
||||
break;
|
||||
default:
|
||||
typeBadge.classList.add('badge-normal');
|
||||
typeBadge.textContent = '정상';
|
||||
}
|
||||
|
||||
infoDiv.appendChild(typeBadge);
|
||||
}
|
||||
|
||||
dayElement.appendChild(infoDiv);
|
||||
}
|
||||
|
||||
calendarDays.appendChild(dayElement);
|
||||
}
|
||||
}
|
||||
|
||||
function renderSummary() {
|
||||
const summarySection = document.getElementById('summarySection');
|
||||
|
||||
// 통계 계산
|
||||
const totalDays = attendanceRecords.length;
|
||||
const totalHours = attendanceRecords.reduce((sum, r) => sum + (r.total_hours || 0), 0);
|
||||
const overtimeDays = attendanceRecords.filter(r => r.is_overtime).length;
|
||||
const annualLeaveDays = attendanceRecords.filter(r => r.attendance_type === 'annual_leave').length;
|
||||
const halfLeaveDays = attendanceRecords.filter(r => r.attendance_type === 'half_leave').length;
|
||||
const quarterLeaveDays = attendanceRecords.filter(r => r.attendance_type === 'quarter_leave').length;
|
||||
|
||||
summarySection.innerHTML = `
|
||||
<div class="summary-item">
|
||||
<div class="summary-label">총 근무일수</div>
|
||||
<div class="summary-value">${totalDays}일</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="summary-label">총 근무시간</div>
|
||||
<div class="summary-value">${totalHours.toFixed(1)}시간</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="summary-label">야근일수</div>
|
||||
<div class="summary-value">${overtimeDays}일</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="summary-label">연차 사용</div>
|
||||
<div class="summary-value">${annualLeaveDays}일</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="summary-label">반차 사용</div>
|
||||
<div class="summary-value">${halfLeaveDays}일</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="summary-label">반반차 사용</div>
|
||||
<div class="summary-value">${quarterLeaveDays}일</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,354 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>휴가 발생 입력 | 테크니컬코리아</title>
|
||||
|
||||
<!-- 모던 디자인 시스템 적용 -->
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/vacation-allocation.css">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
|
||||
<!-- 스크립트 -->
|
||||
<script type="module" src="/js/api-config.js"></script>
|
||||
<script type="module" src="/js/auth-check.js" defer></script>
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/vacation-allocation.js" defer></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- 메인 컨테이너 -->
|
||||
<div class="page-container">
|
||||
|
||||
<!-- 네비게이션 헤더 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="main-content">
|
||||
<div class="content-wrapper">
|
||||
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">➕ 휴가 발생 입력</h1>
|
||||
<p class="page-description">작업자별 휴가를 입력하고 특별 휴가를 관리합니다</p>
|
||||
</div>
|
||||
|
||||
<!-- 탭 네비게이션 -->
|
||||
<div class="tab-navigation">
|
||||
<button class="tab-button active" data-tab="individual">개별 입력</button>
|
||||
<button class="tab-button" data-tab="bulk">일괄 입력</button>
|
||||
<button class="tab-button" data-tab="special">특별 휴가 관리</button>
|
||||
</div>
|
||||
|
||||
<!-- 탭 1: 개별 입력 -->
|
||||
<section id="tab-individual" class="tab-content active">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">개별 작업자 휴가 입력</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
<!-- 입력 폼 -->
|
||||
<div class="form-section">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="individualWorker">작업자 선택 <span class="required">*</span></label>
|
||||
<select id="individualWorker" class="form-select" required>
|
||||
<option value="">선택하세요</option>
|
||||
<!-- JavaScript로 동적 생성 -->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="individualYear">연도 <span class="required">*</span></label>
|
||||
<select id="individualYear" class="form-select" required>
|
||||
<!-- JavaScript로 동적 생성 -->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="individualVacationType">휴가 유형 <span class="required">*</span></label>
|
||||
<select id="individualVacationType" class="form-select" required>
|
||||
<option value="">선택하세요</option>
|
||||
<!-- JavaScript로 동적 생성 -->
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 자동 계산 섹션 -->
|
||||
<div class="auto-calculate-section">
|
||||
<div class="section-header">
|
||||
<h3>자동 계산 (연차만 해당)</h3>
|
||||
<button id="autoCalculateBtn" class="btn btn-secondary btn-sm">
|
||||
🔄 입사일 기준 자동 계산
|
||||
</button>
|
||||
</div>
|
||||
<div id="autoCalculateResult" class="alert alert-info" style="display: none;">
|
||||
<!-- 계산 결과 표시 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 수동 입력 -->
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="individualTotalDays">총 부여 일수 <span class="required">*</span></label>
|
||||
<input type="number" id="individualTotalDays" class="form-input" min="0" step="0.5" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="individualUsedDays">사용 일수</label>
|
||||
<input type="number" id="individualUsedDays" class="form-input" min="0" step="0.5" value="0">
|
||||
</div>
|
||||
|
||||
<div class="form-group full-width">
|
||||
<label for="individualNotes">비고</label>
|
||||
<input type="text" id="individualNotes" class="form-input" placeholder="예: 2026년 연차">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button id="individualSubmitBtn" class="btn btn-primary">
|
||||
저장
|
||||
</button>
|
||||
<button id="individualResetBtn" class="btn btn-secondary">
|
||||
초기화
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 기존 데이터 테이블 -->
|
||||
<div class="existing-data-section">
|
||||
<h3>기존 입력 내역</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>작업자</th>
|
||||
<th>연도</th>
|
||||
<th>휴가 유형</th>
|
||||
<th>총 일수</th>
|
||||
<th>사용 일수</th>
|
||||
<th>잔여 일수</th>
|
||||
<th>비고</th>
|
||||
<th>작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="individualTableBody">
|
||||
<tr>
|
||||
<td colspan="8" class="loading-state">
|
||||
<p>작업자를 선택하세요</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 탭 2: 일괄 입력 -->
|
||||
<section id="tab-bulk" class="tab-content">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">근속년수별 연차 일괄 생성</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
<div class="alert alert-warning">
|
||||
<strong>주의:</strong> 이 기능은 연차(ANNUAL) 휴가 유형만 생성합니다. 각 작업자의 입사일을 기준으로 근속년수를 계산하여 자동으로 연차를 부여합니다.
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="bulkYear">대상 연도 <span class="required">*</span></label>
|
||||
<select id="bulkYear" class="form-select" required>
|
||||
<!-- JavaScript로 동적 생성 -->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="bulkEmploymentStatus">재직 상태</label>
|
||||
<select id="bulkEmploymentStatus" class="form-select">
|
||||
<option value="employed">재직 중만</option>
|
||||
<option value="all">전체</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button id="bulkPreviewBtn" class="btn btn-secondary">
|
||||
미리보기
|
||||
</button>
|
||||
<button id="bulkSubmitBtn" class="btn btn-primary" disabled>
|
||||
일괄 생성
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 미리보기 테이블 -->
|
||||
<div id="bulkPreviewSection" class="preview-section" style="display: none;">
|
||||
<h3>생성 예정 내역</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>작업자</th>
|
||||
<th>입사일</th>
|
||||
<th>근속년수</th>
|
||||
<th>부여 연차</th>
|
||||
<th>계산 근거</th>
|
||||
<th>상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="bulkPreviewTableBody">
|
||||
<!-- JavaScript로 동적 생성 -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 탭 3: 특별 휴가 관리 -->
|
||||
<section id="tab-special" class="tab-content">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">특별 휴가 유형 관리</h2>
|
||||
<button id="addSpecialTypeBtn" class="btn btn-primary btn-sm">
|
||||
+ 새 휴가 유형 추가
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>유형명</th>
|
||||
<th>코드</th>
|
||||
<th>우선순위</th>
|
||||
<th>특별 휴가</th>
|
||||
<th>시스템 유형</th>
|
||||
<th>설명</th>
|
||||
<th>작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="specialTypesTableBody">
|
||||
<tr>
|
||||
<td colspan="7" class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>데이터를 불러오는 중...</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 알림 토스트 -->
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
|
||||
<!-- 모달: 휴가 유형 추가/수정 -->
|
||||
<div id="vacationTypeModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="modalTitle">휴가 유형 추가</h3>
|
||||
<button class="modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="vacationTypeForm">
|
||||
<input type="hidden" id="modalTypeId">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="modalTypeName">유형명 <span class="required">*</span></label>
|
||||
<input type="text" id="modalTypeName" class="form-input" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="modalTypeCode">코드 <span class="required">*</span></label>
|
||||
<input type="text" id="modalTypeCode" class="form-input" required>
|
||||
<small>예: ANNUAL, SICK, MATERNITY (영문 대문자)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="modalPriority">우선순위 <span class="required">*</span></label>
|
||||
<input type="number" id="modalPriority" class="form-input" min="1" required>
|
||||
<small>낮을수록 먼저 차감</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" id="modalIsSpecial">
|
||||
특별 휴가
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="modalDescription">설명</label>
|
||||
<textarea id="modalDescription" class="form-input" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">저장</button>
|
||||
<button type="button" class="btn btn-secondary modal-close">취소</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 모달: 휴가 수정 -->
|
||||
<div id="editBalanceModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>휴가 수정</h3>
|
||||
<button class="modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="editBalanceForm">
|
||||
<input type="hidden" id="editBalanceId">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="editTotalDays">총 일수 <span class="required">*</span></label>
|
||||
<input type="number" id="editTotalDays" class="form-input" min="0" step="0.5" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="editUsedDays">사용 일수 <span class="required">*</span></label>
|
||||
<input type="number" id="editUsedDays" class="form-input" min="0" step="0.5" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="editNotes">비고</label>
|
||||
<input type="text" id="editNotes" class="form-input">
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">저장</button>
|
||||
<button type="button" class="btn btn-secondary modal-close">취소</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,267 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>휴가 승인 관리 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<style>
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
}
|
||||
.tab {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.tab:hover {
|
||||
color: #111827;
|
||||
}
|
||||
.tab.active {
|
||||
color: #3b82f6;
|
||||
border-bottom-color: #3b82f6;
|
||||
}
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="main-content">
|
||||
<div class="dashboard-main">
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">
|
||||
<span class="title-icon">✅</span>
|
||||
휴가 승인 관리
|
||||
</h1>
|
||||
<p class="page-description">휴가 신청을 승인하거나 거부합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 탭 메뉴 -->
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="switchTab('pending')">승인 대기 목록</button>
|
||||
<button class="tab" onclick="switchTab('all')">전체 신청 내역</button>
|
||||
</div>
|
||||
|
||||
<!-- 승인 대기 목록 탭 -->
|
||||
<div id="pendingTab" class="tab-content active">
|
||||
<div class="content-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">승인 대기 목록</h2>
|
||||
<p class="text-muted">대기 중인 휴가 신청을 승인하거나 거부할 수 있습니다</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="pendingRequestsList" class="data-table-container">
|
||||
<!-- 승인 대기 목록이 여기에 동적으로 렌더링됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 전체 신청 내역 탭 -->
|
||||
<div id="allTab" class="tab-content">
|
||||
<div class="content-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">전체 신청 내역</h2>
|
||||
<div class="page-actions">
|
||||
<input type="date" id="filterStartDate" class="form-control" style="width: auto;">
|
||||
<span style="margin: 0 0.5rem;">~</span>
|
||||
<input type="date" id="filterEndDate" class="form-control" style="width: auto;">
|
||||
<button class="btn btn-secondary" onclick="filterAllRequests()">
|
||||
<span>🔍 조회</span>
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="resetFilter()">
|
||||
<span>🔄 전체</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="allRequestsList" class="data-table-container">
|
||||
<!-- 전체 신청 내역이 여기에 동적으로 렌더링됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script src="/js/vacation-common.js"></script>
|
||||
<script type="module" src="/js/load-navbar.js?v=5"></script>
|
||||
<script type="module">
|
||||
import '/js/api-config.js?v=3';
|
||||
</script>
|
||||
<script>
|
||||
// axios 기본 설정
|
||||
(function() {
|
||||
const checkApiConfig = setInterval(() => {
|
||||
if (window.API_BASE_URL) {
|
||||
clearInterval(checkApiConfig);
|
||||
axios.defaults.baseURL = window.API_BASE_URL;
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
axios.interceptors.request.use(
|
||||
config => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
error => Promise.reject(error)
|
||||
);
|
||||
|
||||
axios.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
if (error.response?.status === 401) {
|
||||
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
|
||||
window.location.href = '/pages/login.html';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
}, 50);
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
let allRequestsData = [];
|
||||
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await waitForAxiosConfig();
|
||||
initializePage();
|
||||
});
|
||||
|
||||
async function initializePage() {
|
||||
try {
|
||||
const currentUser = getCurrentUser();
|
||||
|
||||
// 관리자 권한 체크
|
||||
if (currentUser.access_level !== 'system' && currentUser.access_level !== 'admin') {
|
||||
alert('관리자만 접근할 수 있습니다.');
|
||||
window.location.href = '/pages/common/my-vacation.html';
|
||||
return;
|
||||
}
|
||||
|
||||
await loadPendingRequests();
|
||||
await loadAllRequests();
|
||||
|
||||
// 휴가 업데이트 이벤트 리스너
|
||||
window.addEventListener('vacation-updated', () => {
|
||||
loadPendingRequests();
|
||||
loadAllRequests();
|
||||
});
|
||||
|
||||
// 날짜 필터 초기화 (최근 3개월)
|
||||
const today = new Date();
|
||||
const threeMonthsAgo = new Date();
|
||||
threeMonthsAgo.setMonth(today.getMonth() - 3);
|
||||
|
||||
document.getElementById('filterStartDate').value = threeMonthsAgo.toISOString().split('T')[0];
|
||||
document.getElementById('filterEndDate').value = today.toISOString().split('T')[0];
|
||||
} catch (error) {
|
||||
console.error('초기화 오류:', error);
|
||||
alert('페이지 초기화 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
function switchTab(tabName) {
|
||||
// 모든 탭과 컨텐츠 비활성화
|
||||
document.querySelectorAll('.tab').forEach(tab => tab.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
|
||||
|
||||
// 선택한 탭 활성화
|
||||
if (tabName === 'pending') {
|
||||
document.querySelector('.tab:nth-child(1)').classList.add('active');
|
||||
document.getElementById('pendingTab').classList.add('active');
|
||||
} else if (tabName === 'all') {
|
||||
document.querySelector('.tab:nth-child(2)').classList.add('active');
|
||||
document.getElementById('allTab').classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPendingRequests() {
|
||||
try {
|
||||
const response = await axios.get('/vacation-requests/pending');
|
||||
if (response.data.success) {
|
||||
renderVacationRequests(response.data.data, 'pendingRequestsList', true, 'approval');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('승인 대기 목록 로드 오류:', error);
|
||||
document.getElementById('pendingRequestsList').innerHTML = `
|
||||
<div class="empty-state">
|
||||
<p>승인 대기 중인 신청이 없습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAllRequests() {
|
||||
try {
|
||||
const response = await axios.get('/vacation-requests');
|
||||
if (response.data.success) {
|
||||
allRequestsData = response.data.data;
|
||||
renderVacationRequests(allRequestsData, 'allRequestsList', false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('전체 신청 내역 로드 오류:', error);
|
||||
document.getElementById('allRequestsList').innerHTML = `
|
||||
<div class="empty-state">
|
||||
<p>신청 내역이 없습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function filterAllRequests() {
|
||||
const startDate = document.getElementById('filterStartDate').value;
|
||||
const endDate = document.getElementById('filterEndDate').value;
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
alert('시작일과 종료일을 모두 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = allRequestsData.filter(req => {
|
||||
return req.start_date >= startDate && req.start_date <= endDate;
|
||||
});
|
||||
|
||||
renderVacationRequests(filtered, 'allRequestsList', false);
|
||||
}
|
||||
|
||||
function resetFilter() {
|
||||
renderVacationRequests(allRequestsData, 'allRequestsList', false);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,294 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>휴가 직접 입력 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="main-content">
|
||||
<div class="dashboard-main">
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">
|
||||
<span class="title-icon">📝</span>
|
||||
휴가 직접 입력
|
||||
</h1>
|
||||
<p class="page-description">관리자 권한으로 작업자의 휴가 정보를 직접 입력합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 휴가 직접 입력 폼 -->
|
||||
<div class="content-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">휴가 정보 입력</h2>
|
||||
<p class="text-muted">승인 절차 없이 휴가 정보를 직접 입력합니다. 입력 즉시 승인 상태로 저장됩니다.</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="vacationInputForm" onsubmit="submitVacationInput(event)">
|
||||
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem;">
|
||||
<div class="form-group">
|
||||
<label for="inputWorker">작업자 *</label>
|
||||
<select id="inputWorker" class="form-control" required onchange="updateVacationBalance()">
|
||||
<option value="">선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="inputVacationType">휴가 유형 *</label>
|
||||
<select id="inputVacationType" class="form-control" required>
|
||||
<option value="">선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="inputStartDate">시작일 *</label>
|
||||
<input type="date" id="inputStartDate" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="inputEndDate">종료일 *</label>
|
||||
<input type="date" id="inputEndDate" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="inputDaysUsed">사용 일수 *</label>
|
||||
<input type="number" id="inputDaysUsed" class="form-control" min="0.5" step="0.5" value="1.0" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>작업자 휴가 잔여</label>
|
||||
<div id="workerVacationBalance" style="padding: 0.75rem; background-color: #f9fafb; border-radius: 0.375rem; border: 1px solid #e5e7eb;">
|
||||
<span class="text-muted">작업자를 선택하세요</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="grid-column: 1 / -1;">
|
||||
<label for="inputReason">사유</label>
|
||||
<textarea id="inputReason" class="form-control" rows="3" placeholder="휴가 사유를 입력하세요 (선택)"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin-top: 2rem;">
|
||||
<button type="submit" class="btn btn-primary" style="padding: 1rem 3rem;">
|
||||
<span>💾 즉시 입력 (자동 승인)</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 최근 입력 내역 -->
|
||||
<div class="content-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">최근 입력 내역</h2>
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-secondary" onclick="loadRecentInputs()">
|
||||
<span>🔄 새로고침</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="recentInputsList" class="data-table-container">
|
||||
<!-- 최근 입력 내역이 여기에 동적으로 렌더링됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script src="/js/vacation-common.js"></script>
|
||||
<script type="module" src="/js/load-navbar.js?v=5"></script>
|
||||
<script type="module">
|
||||
import '/js/api-config.js?v=3';
|
||||
</script>
|
||||
<script>
|
||||
// axios 기본 설정
|
||||
(function() {
|
||||
const checkApiConfig = setInterval(() => {
|
||||
if (window.API_BASE_URL) {
|
||||
clearInterval(checkApiConfig);
|
||||
axios.defaults.baseURL = window.API_BASE_URL;
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
axios.interceptors.request.use(
|
||||
config => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
error => Promise.reject(error)
|
||||
);
|
||||
|
||||
axios.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
if (error.response?.status === 401) {
|
||||
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
|
||||
window.location.href = '/pages/login.html';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
}, 50);
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await waitForAxiosConfig();
|
||||
initializePage();
|
||||
});
|
||||
|
||||
async function initializePage() {
|
||||
try {
|
||||
const currentUser = getCurrentUser();
|
||||
|
||||
// 관리자 권한 체크
|
||||
if (currentUser.access_level !== 'system' && currentUser.access_level !== 'admin') {
|
||||
alert('관리자만 접근할 수 있습니다.');
|
||||
window.location.href = '/pages/common/my-vacation.html';
|
||||
return;
|
||||
}
|
||||
|
||||
await loadWorkers();
|
||||
await loadVacationTypes();
|
||||
await loadRecentInputs();
|
||||
|
||||
// 휴가 업데이트 이벤트 리스너
|
||||
window.addEventListener('vacation-updated', loadRecentInputs);
|
||||
} catch (error) {
|
||||
console.error('초기화 오류:', error);
|
||||
alert('페이지 초기화 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
async function updateVacationBalance() {
|
||||
const workerId = document.getElementById('inputWorker').value;
|
||||
const balanceDiv = document.getElementById('workerVacationBalance');
|
||||
|
||||
if (!workerId) {
|
||||
balanceDiv.innerHTML = '<span class="text-muted">작업자를 선택하세요</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(`/attendance/vacation-balance/${workerId}`);
|
||||
if (response.data.success) {
|
||||
const balance = response.data.data;
|
||||
|
||||
if (!balance || Object.keys(balance).length === 0) {
|
||||
balanceDiv.innerHTML = '<span class="text-muted">휴가 잔여 정보가 없습니다</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
const balanceHTML = Object.keys(balance).map(key => {
|
||||
const info = balance[key];
|
||||
return `
|
||||
<div style="display: inline-block; margin-right: 1rem;">
|
||||
<span style="color: #6b7280; font-size: 0.875rem;">${key}:</span>
|
||||
<strong style="color: #111827; font-size: 1rem; margin-left: 0.25rem;">${info.remaining || 0}일</strong>
|
||||
<span style="color: #9ca3af; font-size: 0.75rem; margin-left: 0.25rem;">(전체: ${info.total || 0}일)</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
balanceDiv.innerHTML = balanceHTML;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('휴가 잔여 조회 오류:', error);
|
||||
balanceDiv.innerHTML = '<span class="text-muted" style="color: #dc2626;">조회 실패</span>';
|
||||
}
|
||||
}
|
||||
|
||||
async function submitVacationInput(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const data = {
|
||||
worker_id: parseInt(document.getElementById('inputWorker').value),
|
||||
vacation_type_id: parseInt(document.getElementById('inputVacationType').value),
|
||||
start_date: document.getElementById('inputStartDate').value,
|
||||
end_date: document.getElementById('inputEndDate').value,
|
||||
days_used: parseFloat(document.getElementById('inputDaysUsed').value),
|
||||
reason: document.getElementById('inputReason').value || null,
|
||||
auto_approve: true // 자동 승인 플래그
|
||||
};
|
||||
|
||||
if (!confirm(`${document.getElementById('inputWorker').selectedOptions[0].text}의 휴가를 즉시 입력하시겠습니까?\n\n입력 즉시 승인 상태로 저장됩니다.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 휴가 신청 생성
|
||||
const response = await axios.post('/vacation-requests', data);
|
||||
if (response.data.success) {
|
||||
const requestId = response.data.data.request_id;
|
||||
|
||||
// 즉시 승인 처리
|
||||
try {
|
||||
const approveResponse = await axios.patch(`/vacation-requests/${requestId}/approve`);
|
||||
if (approveResponse.data.success) {
|
||||
alert('휴가 정보가 입력되고 자동 승인되었습니다.');
|
||||
document.getElementById('vacationInputForm').reset();
|
||||
document.getElementById('workerVacationBalance').innerHTML = '<span class="text-muted">작업자를 선택하세요</span>';
|
||||
window.dispatchEvent(new Event('vacation-updated'));
|
||||
}
|
||||
} catch (approveError) {
|
||||
console.error('자동 승인 오류:', approveError);
|
||||
alert('휴가 정보는 입력되었으나 자동 승인에 실패했습니다. 승인 관리 페이지에서 수동으로 승인해주세요.');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('휴가 입력 오류:', error);
|
||||
alert(error.response?.data?.message || '휴가 입력 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRecentInputs() {
|
||||
try {
|
||||
const response = await axios.get('/vacation-requests');
|
||||
if (response.data.success) {
|
||||
// 최근 30일 이내 승인된 항목만 표시
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
const thirtyDaysAgoStr = thirtyDaysAgo.toISOString().split('T')[0];
|
||||
|
||||
const recentApproved = response.data.data.filter(req =>
|
||||
req.status === 'approved' &&
|
||||
req.created_at >= thirtyDaysAgoStr
|
||||
).slice(0, 20); // 최근 20개만
|
||||
|
||||
renderVacationRequests(recentApproved, 'recentInputsList', false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('최근 입력 내역 로드 오류:', error);
|
||||
document.getElementById('recentInputsList').innerHTML = `
|
||||
<div class="empty-state">
|
||||
<p>최근 입력 내역이 없습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,461 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>휴가 관리 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<style>
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
}
|
||||
.tab {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.tab:hover {
|
||||
color: #111827;
|
||||
}
|
||||
.tab.active {
|
||||
color: #3b82f6;
|
||||
border-bottom-color: #3b82f6;
|
||||
}
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="main-content">
|
||||
<div class="dashboard-main">
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">
|
||||
<span class="title-icon">🏖️</span>
|
||||
휴가 관리
|
||||
</h1>
|
||||
<p class="page-description">휴가 신청을 승인하고 작업자 휴가 정보를 관리합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 탭 메뉴 -->
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="switchTab('approval')">승인 대기 목록</button>
|
||||
<button class="tab" onclick="switchTab('input')">직접 입력</button>
|
||||
<button class="tab" onclick="switchTab('all')">전체 신청 내역</button>
|
||||
</div>
|
||||
|
||||
<!-- 승인 대기 목록 탭 -->
|
||||
<div id="approvalTab" class="tab-content active">
|
||||
<div class="content-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">승인 대기 목록</h2>
|
||||
<p class="text-muted">대기 중인 휴가 신청을 승인하거나 거부할 수 있습니다</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="pendingRequestsList" class="data-table-container">
|
||||
<!-- 승인 대기 목록이 여기에 동적으로 렌더링됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 직접 입력 탭 -->
|
||||
<div id="inputTab" class="tab-content">
|
||||
<div class="content-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">휴가 정보 직접 입력</h2>
|
||||
<p class="text-muted">승인 절차 없이 휴가 정보를 직접 입력합니다. 입력 즉시 승인 상태로 저장됩니다.</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="vacationInputForm" onsubmit="submitVacationInput(event)">
|
||||
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem;">
|
||||
<div class="form-group">
|
||||
<label for="inputWorker">작업자 *</label>
|
||||
<select id="inputWorker" class="form-control" required onchange="updateVacationBalance()">
|
||||
<option value="">선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="inputVacationType">휴가 유형 *</label>
|
||||
<select id="inputVacationType" class="form-control" required>
|
||||
<option value="">선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="inputStartDate">시작일 *</label>
|
||||
<input type="date" id="inputStartDate" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="inputEndDate">종료일 *</label>
|
||||
<input type="date" id="inputEndDate" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="inputDaysUsed">사용 일수 *</label>
|
||||
<input type="number" id="inputDaysUsed" class="form-control" min="0.5" step="0.5" value="1.0" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>작업자 휴가 잔여</label>
|
||||
<div id="workerVacationBalance" style="padding: 0.75rem; background-color: #f9fafb; border-radius: 0.375rem; border: 1px solid #e5e7eb;">
|
||||
<span class="text-muted">작업자를 선택하세요</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="grid-column: 1 / -1;">
|
||||
<label for="inputReason">사유</label>
|
||||
<textarea id="inputReason" class="form-control" rows="3" placeholder="휴가 사유를 입력하세요 (선택)"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin-top: 2rem;">
|
||||
<button type="submit" class="btn btn-primary" style="padding: 1rem 3rem;">
|
||||
<span>💾 즉시 입력 (자동 승인)</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 최근 입력 내역 -->
|
||||
<div class="card" style="margin-top: 2rem;">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">최근 입력 내역</h2>
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-secondary" onclick="loadRecentInputs()">
|
||||
<span>🔄 새로고침</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="recentInputsList" class="data-table-container">
|
||||
<!-- 최근 입력 내역이 여기에 동적으로 렌더링됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 전체 신청 내역 탭 -->
|
||||
<div id="allTab" class="tab-content">
|
||||
<div class="content-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">전체 신청 내역</h2>
|
||||
<div class="page-actions">
|
||||
<input type="date" id="filterStartDate" class="form-control" style="width: auto;">
|
||||
<span style="margin: 0 0.5rem;">~</span>
|
||||
<input type="date" id="filterEndDate" class="form-control" style="width: auto;">
|
||||
<button class="btn btn-secondary" onclick="filterAllRequests()">
|
||||
<span>🔍 조회</span>
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="resetFilter()">
|
||||
<span>🔄 전체</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="allRequestsList" class="data-table-container">
|
||||
<!-- 전체 신청 내역이 여기에 동적으로 렌더링됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script src="/js/vacation-common.js"></script>
|
||||
<script type="module" src="/js/load-navbar.js?v=5"></script>
|
||||
<script type="module">
|
||||
import '/js/api-config.js?v=3';
|
||||
</script>
|
||||
<script>
|
||||
// axios 기본 설정
|
||||
(function() {
|
||||
const checkApiConfig = setInterval(() => {
|
||||
if (window.API_BASE_URL) {
|
||||
clearInterval(checkApiConfig);
|
||||
axios.defaults.baseURL = window.API_BASE_URL;
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
axios.interceptors.request.use(
|
||||
config => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
error => Promise.reject(error)
|
||||
);
|
||||
|
||||
axios.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
if (error.response?.status === 401) {
|
||||
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
|
||||
window.location.href = '/pages/login.html';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
}, 50);
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
let allRequestsData = [];
|
||||
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await waitForAxiosConfig();
|
||||
initializePage();
|
||||
});
|
||||
|
||||
async function initializePage() {
|
||||
try {
|
||||
const currentUser = getCurrentUser();
|
||||
|
||||
// 관리자 권한 체크
|
||||
if (currentUser.access_level !== 'system' && currentUser.access_level !== 'admin') {
|
||||
alert('관리자만 접근할 수 있습니다.');
|
||||
window.location.href = '/pages/common/vacation-request.html';
|
||||
return;
|
||||
}
|
||||
|
||||
await loadWorkers();
|
||||
await loadVacationTypes();
|
||||
await loadPendingRequests();
|
||||
await loadAllRequests();
|
||||
|
||||
// 휴가 업데이트 이벤트 리스너
|
||||
window.addEventListener('vacation-updated', () => {
|
||||
loadPendingRequests();
|
||||
loadAllRequests();
|
||||
});
|
||||
|
||||
// 날짜 필터 초기화 (최근 3개월)
|
||||
const today = new Date();
|
||||
const threeMonthsAgo = new Date();
|
||||
threeMonthsAgo.setMonth(today.getMonth() - 3);
|
||||
|
||||
document.getElementById('filterStartDate').value = threeMonthsAgo.toISOString().split('T')[0];
|
||||
document.getElementById('filterEndDate').value = today.toISOString().split('T')[0];
|
||||
} catch (error) {
|
||||
console.error('초기화 오류:', error);
|
||||
alert('페이지 초기화 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
function switchTab(tabName) {
|
||||
// 모든 탭과 컨텐츠 비활성화
|
||||
document.querySelectorAll('.tab').forEach(tab => tab.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
|
||||
|
||||
// 선택한 탭 활성화
|
||||
if (tabName === 'approval') {
|
||||
document.querySelector('.tab:nth-child(1)').classList.add('active');
|
||||
document.getElementById('approvalTab').classList.add('active');
|
||||
} else if (tabName === 'input') {
|
||||
document.querySelector('.tab:nth-child(2)').classList.add('active');
|
||||
document.getElementById('inputTab').classList.add('active');
|
||||
} else if (tabName === 'all') {
|
||||
document.querySelector('.tab:nth-child(3)').classList.add('active');
|
||||
document.getElementById('allTab').classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPendingRequests() {
|
||||
try {
|
||||
const response = await axios.get('/vacation-requests/pending');
|
||||
if (response.data.success) {
|
||||
renderVacationRequests(response.data.data, 'pendingRequestsList', true, 'approval');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('승인 대기 목록 로드 오류:', error);
|
||||
document.getElementById('pendingRequestsList').innerHTML = `
|
||||
<div class="empty-state">
|
||||
<p>승인 대기 중인 신청이 없습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAllRequests() {
|
||||
try {
|
||||
const response = await axios.get('/vacation-requests');
|
||||
if (response.data.success) {
|
||||
allRequestsData = response.data.data;
|
||||
renderVacationRequests(allRequestsData, 'allRequestsList', false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('전체 신청 내역 로드 오류:', error);
|
||||
document.getElementById('allRequestsList').innerHTML = `
|
||||
<div class="empty-state">
|
||||
<p>신청 내역이 없습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateVacationBalance() {
|
||||
const workerId = document.getElementById('inputWorker').value;
|
||||
const balanceDiv = document.getElementById('workerVacationBalance');
|
||||
|
||||
if (!workerId) {
|
||||
balanceDiv.innerHTML = '<span class="text-muted">작업자를 선택하세요</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(`/attendance/vacation-balance/${workerId}`);
|
||||
if (response.data.success) {
|
||||
const balance = response.data.data;
|
||||
|
||||
if (!balance || Object.keys(balance).length === 0) {
|
||||
balanceDiv.innerHTML = '<span class="text-muted">휴가 잔여 정보가 없습니다</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
const balanceHTML = Object.keys(balance).map(key => {
|
||||
const info = balance[key];
|
||||
return `
|
||||
<div style="display: inline-block; margin-right: 1rem;">
|
||||
<span style="color: #6b7280; font-size: 0.875rem;">${key}:</span>
|
||||
<strong style="color: #111827; font-size: 1rem; margin-left: 0.25rem;">${info.remaining || 0}일</strong>
|
||||
<span style="color: #9ca3af; font-size: 0.75rem; margin-left: 0.25rem;">(전체: ${info.total || 0}일)</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
balanceDiv.innerHTML = balanceHTML;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('휴가 잔여 조회 오류:', error);
|
||||
balanceDiv.innerHTML = '<span class="text-muted" style="color: #dc2626;">조회 실패</span>';
|
||||
}
|
||||
}
|
||||
|
||||
async function submitVacationInput(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const data = {
|
||||
worker_id: parseInt(document.getElementById('inputWorker').value),
|
||||
vacation_type_id: parseInt(document.getElementById('inputVacationType').value),
|
||||
start_date: document.getElementById('inputStartDate').value,
|
||||
end_date: document.getElementById('inputEndDate').value,
|
||||
days_used: parseFloat(document.getElementById('inputDaysUsed').value),
|
||||
reason: document.getElementById('inputReason').value || null
|
||||
};
|
||||
|
||||
if (!confirm(`${document.getElementById('inputWorker').selectedOptions[0].text}의 휴가를 즉시 입력하시겠습니까?\n\n입력 즉시 승인 상태로 저장됩니다.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 휴가 신청 생성
|
||||
const response = await axios.post('/vacation-requests', data);
|
||||
if (response.data.success) {
|
||||
const requestId = response.data.data.request_id;
|
||||
|
||||
// 즉시 승인 처리
|
||||
try {
|
||||
const approveResponse = await axios.patch(`/vacation-requests/${requestId}/approve`);
|
||||
if (approveResponse.data.success) {
|
||||
alert('휴가 정보가 입력되고 자동 승인되었습니다.');
|
||||
document.getElementById('vacationInputForm').reset();
|
||||
document.getElementById('workerVacationBalance').innerHTML = '<span class="text-muted">작업자를 선택하세요</span>';
|
||||
window.dispatchEvent(new Event('vacation-updated'));
|
||||
loadRecentInputs();
|
||||
}
|
||||
} catch (approveError) {
|
||||
console.error('자동 승인 오류:', approveError);
|
||||
alert('휴가 정보는 입력되었으나 자동 승인에 실패했습니다. 승인 관리 탭에서 수동으로 승인해주세요.');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('휴가 입력 오류:', error);
|
||||
alert(error.response?.data?.message || '휴가 입력 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRecentInputs() {
|
||||
try {
|
||||
const response = await axios.get('/vacation-requests');
|
||||
if (response.data.success) {
|
||||
// 최근 30일 이내 승인된 항목만 표시
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
const thirtyDaysAgoStr = thirtyDaysAgo.toISOString().split('T')[0];
|
||||
|
||||
const recentApproved = response.data.data.filter(req =>
|
||||
req.status === 'approved' &&
|
||||
req.created_at >= thirtyDaysAgoStr
|
||||
).slice(0, 20); // 최근 20개만
|
||||
|
||||
renderVacationRequests(recentApproved, 'recentInputsList', false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('최근 입력 내역 로드 오류:', error);
|
||||
document.getElementById('recentInputsList').innerHTML = `
|
||||
<div class="empty-state">
|
||||
<p>최근 입력 내역이 없습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function filterAllRequests() {
|
||||
const startDate = document.getElementById('filterStartDate').value;
|
||||
const endDate = document.getElementById('filterEndDate').value;
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
alert('시작일과 종료일을 모두 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = allRequestsData.filter(req => {
|
||||
return req.start_date >= startDate && req.start_date <= endDate;
|
||||
});
|
||||
|
||||
renderVacationRequests(filtered, 'allRequestsList', false);
|
||||
}
|
||||
|
||||
function resetFilter() {
|
||||
renderVacationRequests(allRequestsData, 'allRequestsList', false);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,272 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>휴가 신청 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="main-content">
|
||||
<div class="dashboard-main">
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">
|
||||
<span class="title-icon">📝</span>
|
||||
휴가 신청
|
||||
</h1>
|
||||
<p class="page-description">휴가를 신청하고 신청 내역을 확인합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 휴가 잔여 현황 -->
|
||||
<div class="content-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">휴가 잔여 현황</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="vacationBalance" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem;">
|
||||
<!-- 휴가 잔여 정보가 여기에 표시됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 휴가 신청 -->
|
||||
<div class="content-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">휴가 신청</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="vacationRequestForm" onsubmit="submitVacationRequest(event)">
|
||||
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem;">
|
||||
<div class="form-group">
|
||||
<label for="vacationType">휴가 유형 *</label>
|
||||
<select id="vacationType" class="form-control" required>
|
||||
<option value="">선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="daysUsed">사용 일수 *</label>
|
||||
<input type="number" id="daysUsed" class="form-control" min="0.5" step="0.5" value="1.0" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="startDate">시작일 *</label>
|
||||
<input type="date" id="startDate" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="endDate">종료일 *</label>
|
||||
<input type="date" id="endDate" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="grid-column: 1 / -1;">
|
||||
<label for="reason">사유</label>
|
||||
<textarea id="reason" class="form-control" rows="3" placeholder="휴가 사유를 입력하세요 (선택)"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin-top: 2rem;">
|
||||
<button type="submit" class="btn btn-primary" style="padding: 1rem 3rem;">
|
||||
<span>📝 신청하기</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 내 신청 내역 -->
|
||||
<div class="content-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">내 신청 내역</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="myRequestsList" class="data-table-container">
|
||||
<!-- 내 신청 내역이 여기에 동적으로 렌더링됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script src="/js/vacation-common.js"></script>
|
||||
<script type="module" src="/js/load-navbar.js?v=5"></script>
|
||||
<script type="module">
|
||||
import '/js/api-config.js?v=3';
|
||||
</script>
|
||||
<script>
|
||||
// axios 기본 설정
|
||||
(function() {
|
||||
const checkApiConfig = setInterval(() => {
|
||||
if (window.API_BASE_URL) {
|
||||
clearInterval(checkApiConfig);
|
||||
axios.defaults.baseURL = window.API_BASE_URL;
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
axios.interceptors.request.use(
|
||||
config => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
error => Promise.reject(error)
|
||||
);
|
||||
|
||||
axios.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
if (error.response?.status === 401) {
|
||||
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
|
||||
window.location.href = '/pages/login.html';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
}, 50);
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await waitForAxiosConfig();
|
||||
initializePage();
|
||||
});
|
||||
|
||||
async function initializePage() {
|
||||
try {
|
||||
const currentUser = getCurrentUser();
|
||||
|
||||
if (!currentUser || !currentUser.worker_id) {
|
||||
alert('작업자 정보가 없습니다. 관리자에게 문의하세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
await loadVacationTypes();
|
||||
await loadVacationBalance();
|
||||
await loadMyRequests();
|
||||
|
||||
// 휴가 업데이트 이벤트 리스너
|
||||
window.addEventListener('vacation-updated', () => {
|
||||
loadVacationBalance();
|
||||
loadMyRequests();
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('초기화 오류:', error);
|
||||
alert('페이지 초기화 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVacationBalance() {
|
||||
const currentUser = getCurrentUser();
|
||||
|
||||
try {
|
||||
const response = await axios.get(`/attendance/vacation-balance/${currentUser.worker_id}`);
|
||||
if (response.data.success) {
|
||||
renderVacationBalance(response.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('휴가 잔여 조회 오류:', error);
|
||||
document.getElementById('vacationBalance').innerHTML = `
|
||||
<p class="text-muted">휴가 잔여 정보를 불러올 수 없습니다.</p>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderVacationBalance(balance) {
|
||||
const container = document.getElementById('vacationBalance');
|
||||
|
||||
if (!balance || Object.keys(balance).length === 0) {
|
||||
container.innerHTML = '<p class="text-muted">휴가 잔여 정보가 없습니다.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const balanceHTML = Object.keys(balance).map(key => {
|
||||
const info = balance[key];
|
||||
return `
|
||||
<div style="padding: 1.5rem; background-color: #f9fafb; border-radius: 0.5rem; border: 1px solid #e5e7eb;">
|
||||
<div style="font-size: 0.875rem; color: #6b7280; margin-bottom: 0.5rem;">${key}</div>
|
||||
<div style="font-size: 1.5rem; font-weight: 700; color: #111827;">
|
||||
${info.remaining || 0}일
|
||||
</div>
|
||||
<div style="font-size: 0.75rem; color: #9ca3af; margin-top: 0.25rem;">
|
||||
사용: ${info.used || 0}일 / 전체: ${info.total || 0}일
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = balanceHTML;
|
||||
}
|
||||
|
||||
async function submitVacationRequest(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const currentUser = getCurrentUser();
|
||||
const data = {
|
||||
worker_id: currentUser.worker_id,
|
||||
vacation_type_id: parseInt(document.getElementById('vacationType').value),
|
||||
start_date: document.getElementById('startDate').value,
|
||||
end_date: document.getElementById('endDate').value,
|
||||
days_used: parseFloat(document.getElementById('daysUsed').value),
|
||||
reason: document.getElementById('reason').value || null
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await axios.post('/vacation-requests', data);
|
||||
if (response.data.success) {
|
||||
alert('휴가 신청이 완료되었습니다.');
|
||||
document.getElementById('vacationRequestForm').reset();
|
||||
window.dispatchEvent(new Event('vacation-updated'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('휴가 신청 오류:', error);
|
||||
alert(error.response?.data?.message || '휴가 신청 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMyRequests() {
|
||||
const currentUser = getCurrentUser();
|
||||
|
||||
try {
|
||||
const response = await axios.get('/vacation-requests');
|
||||
if (response.data.success) {
|
||||
// 내 신청만 필터링
|
||||
const myRequests = response.data.data.filter(req =>
|
||||
req.requested_by === currentUser.user_id || req.worker_id === currentUser.worker_id
|
||||
);
|
||||
renderVacationRequests(myRequests, 'myRequestsList', true, 'delete');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('내 신청 내역 로드 오류:', error);
|
||||
document.getElementById('myRequestsList').innerHTML = `
|
||||
<div class="empty-state">
|
||||
<p>신청 내역이 없습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,277 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>작업 현황판 | 테크니컬코리아</title>
|
||||
|
||||
<!-- 모던 디자인 시스템 적용 -->
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/modern-dashboard.css?v=2">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
|
||||
<!-- 스크립트 (순서 중요: api-config.js가 먼저 로드되어야 함) -->
|
||||
<script type="module" src="/js/api-config.js"></script>
|
||||
<script type="module" src="/js/auth-check.js" defer></script>
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/modern-dashboard.js?v=10" defer></script>
|
||||
<script type="module" src="/js/group-leader-dashboard.js?v=1" defer></script>
|
||||
<script src="/js/workplace-status.js" defer></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- 메인 컨테이너 -->
|
||||
<div class="dashboard-container">
|
||||
|
||||
<!-- 네비게이션 헤더 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="dashboard-main">
|
||||
|
||||
<!-- 빠른 작업 섹션 -->
|
||||
<section class="quick-actions-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">빠른 작업</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="quick-actions-grid-full">
|
||||
<!-- TBM 관리 (페이지 권한 체크) -->
|
||||
<a href="/pages/work/tbm.html" class="quick-action-card" id="tbmQuickAction" style="display: none; background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); color: white;">
|
||||
<div class="action-content">
|
||||
<h3 style="color: white;">🛠️ TBM 관리</h3>
|
||||
<p style="color: rgba(255, 255, 255, 0.9);">아침 안전 회의 및 팀 구성을 관리합니다</p>
|
||||
</div>
|
||||
<div class="action-arrow" style="color: white;">→</div>
|
||||
</a>
|
||||
|
||||
<a href="/pages/work/visit-request.html" class="quick-action-card" style="background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); color: white;">
|
||||
<div class="action-content">
|
||||
<h3 style="color: white;">🚪 출입 신청</h3>
|
||||
<p style="color: rgba(255, 255, 255, 0.9);">작업장 출입 및 안전교육을 신청합니다</p>
|
||||
</div>
|
||||
<div class="action-arrow" style="color: white;">→</div>
|
||||
</a>
|
||||
|
||||
<a href="/pages/admin/safety-management.html" class="quick-action-card admin-only" style="background: linear-gradient(135deg, #ec4899 0%, #db2777 100%); color: white;">
|
||||
<div class="action-content">
|
||||
<h3 style="color: white;">🛡️ 안전관리</h3>
|
||||
<p style="color: rgba(255, 255, 255, 0.9);">출입 신청 승인 및 안전교육 관리</p>
|
||||
</div>
|
||||
<div class="action-arrow" style="color: white;">→</div>
|
||||
</a>
|
||||
|
||||
<a href="/pages/admin/safety-checklist-manage.html" class="quick-action-card admin-only" style="background: linear-gradient(135deg, #14b8a6 0%, #0d9488 100%); color: white;">
|
||||
<div class="action-content">
|
||||
<h3 style="color: white;">📋 안전 체크리스트 관리</h3>
|
||||
<p style="color: rgba(255, 255, 255, 0.9);">TBM 안전 체크 항목 관리 (기본/날씨/작업별)</p>
|
||||
</div>
|
||||
<div class="action-arrow" style="color: white;">→</div>
|
||||
</a>
|
||||
|
||||
<a href="/pages/work/issue-report.html" class="quick-action-card" style="background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); color: white;">
|
||||
<div class="action-content">
|
||||
<h3 style="color: white;">⚠️ 문제 신고</h3>
|
||||
<p style="color: rgba(255, 255, 255, 0.9);">작업 중 발생한 문제를 신고합니다</p>
|
||||
</div>
|
||||
<div class="action-arrow" style="color: white;">→</div>
|
||||
</a>
|
||||
|
||||
<a href="/pages/work/issue-list.html" class="quick-action-card" style="background: linear-gradient(135deg, #f97316 0%, #ea580c 100%); color: white;">
|
||||
<div class="action-content">
|
||||
<h3 style="color: white;">📋 신고 현황</h3>
|
||||
<p style="color: rgba(255, 255, 255, 0.9);">신고 목록 및 처리 현황을 확인합니다</p>
|
||||
</div>
|
||||
<div class="action-arrow" style="color: white;">→</div>
|
||||
</a>
|
||||
|
||||
<a href="/pages/work/report-create.html" class="quick-action-card">
|
||||
<div class="action-content">
|
||||
<h3>작업 보고서 작성</h3>
|
||||
<p>오늘의 작업 내용을 입력하고 관리합니다</p>
|
||||
</div>
|
||||
<div class="action-arrow">→</div>
|
||||
</a>
|
||||
|
||||
<a href="/pages/work/report-view.html" class="quick-action-card">
|
||||
<div class="action-content">
|
||||
<h3>작업 현황 확인</h3>
|
||||
<p>팀원들의 작업 현황을 실시간으로 조회합니다</p>
|
||||
</div>
|
||||
<div class="action-arrow">→</div>
|
||||
</a>
|
||||
|
||||
<a href="/pages/work/analysis.html" class="quick-action-card admin-only">
|
||||
<div class="action-content">
|
||||
<h3>작업 분석</h3>
|
||||
<p>작업 효율성 및 통계를 분석합니다</p>
|
||||
</div>
|
||||
<div class="action-arrow">→</div>
|
||||
</a>
|
||||
|
||||
<a href="/pages/admin/projects.html" class="quick-action-card admin-only">
|
||||
<div class="action-content">
|
||||
<h3>기본 정보 관리</h3>
|
||||
<p>프로젝트, 작업자, 코드를 관리합니다</p>
|
||||
</div>
|
||||
<div class="action-arrow">→</div>
|
||||
</a>
|
||||
|
||||
<a href="/pages/common/daily-attendance.html" class="quick-action-card" style="background: linear-gradient(135deg, #06b6d4 0%, #0891b2 100%); color: white;">
|
||||
<div class="action-content">
|
||||
<h3 style="color: white;">📅 일일 출퇴근 입력</h3>
|
||||
<p style="color: rgba(255, 255, 255, 0.9);">오늘의 출퇴근 기록을 입력합니다</p>
|
||||
</div>
|
||||
<div class="action-arrow" style="color: white;">→</div>
|
||||
</a>
|
||||
|
||||
<a href="/pages/common/monthly-attendance.html" class="quick-action-card">
|
||||
<div class="action-content">
|
||||
<h3>📆 월별 출퇴근 현황</h3>
|
||||
<p>이번 달 출퇴근 현황을 조회합니다</p>
|
||||
</div>
|
||||
<div class="action-arrow">→</div>
|
||||
</a>
|
||||
|
||||
<a href="/pages/common/vacation-request.html" class="quick-action-card">
|
||||
<div class="action-content">
|
||||
<h3>📝 휴가 신청</h3>
|
||||
<p>휴가를 신청하고 신청 내역을 확인합니다</p>
|
||||
</div>
|
||||
<div class="action-arrow">→</div>
|
||||
</a>
|
||||
|
||||
<a href="/pages/common/vacation-management.html" class="quick-action-card admin-only">
|
||||
<div class="action-content">
|
||||
<h3>🏖️ 휴가 관리</h3>
|
||||
<p>휴가 승인, 직접 입력, 전체 내역을 관리합니다</p>
|
||||
</div>
|
||||
<div class="action-arrow">→</div>
|
||||
</a>
|
||||
|
||||
<a href="/pages/common/annual-vacation-overview.html" class="quick-action-card admin-only">
|
||||
<div class="action-content">
|
||||
<h3>📊 연간 연차 현황</h3>
|
||||
<p>모든 작업자의 연간 휴가 현황을 차트로 확인합니다</p>
|
||||
</div>
|
||||
<div class="action-arrow">→</div>
|
||||
</a>
|
||||
|
||||
<a href="/pages/common/vacation-allocation.html" class="quick-action-card admin-only">
|
||||
<div class="action-content">
|
||||
<h3>➕ 휴가 발생 입력</h3>
|
||||
<p>작업자별 휴가를 입력하고 특별 휴가를 관리합니다</p>
|
||||
</div>
|
||||
<div class="action-arrow">→</div>
|
||||
</a>
|
||||
|
||||
<a href="/pages/admin/attendance-report-comparison.html" class="quick-action-card admin-only">
|
||||
<div class="action-content">
|
||||
<h3>🔍 출퇴근-작업보고서 대조</h3>
|
||||
<p>출퇴근 기록과 작업보고서를 비교 분석합니다</p>
|
||||
</div>
|
||||
<div class="action-arrow">→</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 작업장 현황 -->
|
||||
<section class="workplace-status-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="card-title">작업장 현황</h2>
|
||||
<div class="flex items-center" style="gap: 12px;">
|
||||
<select id="categorySelect" class="form-select" style="width: 200px;">
|
||||
<option value="">공장을 선택하세요</option>
|
||||
</select>
|
||||
<button class="btn btn-primary btn-sm" id="refreshMapBtn">
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- 지도 영역 -->
|
||||
<div id="workplaceMapContainer" style="position: relative; min-height: 500px; display: none;">
|
||||
<canvas id="workplaceMapCanvas" style="width: 100%; max-width: 100%; cursor: pointer; border: 2px solid var(--gray-300); border-radius: var(--radius-md);"></canvas>
|
||||
<div id="mapLegend" style="position: absolute; top: 16px; right: 16px; background: white; padding: 16px; border-radius: var(--radius-md); box-shadow: var(--shadow-md);">
|
||||
<h4 style="font-size: var(--text-sm); font-weight: 700; margin-bottom: 12px;">범례</h4>
|
||||
<div style="display: flex; flex-direction: column; gap: 8px;">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<div style="width: 16px; height: 16px; background: rgba(59, 130, 246, 0.3); border: 2px solid rgb(59, 130, 246); border-radius: 4px;"></div>
|
||||
<span style="font-size: var(--text-sm);">작업 중 (내부 작업자)</span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<div style="width: 16px; height: 16px; background: rgba(168, 85, 247, 0.3); border: 2px solid rgb(168, 85, 247); border-radius: 4px;"></div>
|
||||
<span style="font-size: var(--text-sm);">방문 예정 (외부 인원)</span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<div style="width: 16px; height: 16px; background: rgba(34, 197, 94, 0.3); border: 2px solid rgb(34, 197, 94); border-radius: 4px;"></div>
|
||||
<span style="font-size: var(--text-sm);">작업 + 방문</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 안내 메시지 -->
|
||||
<div id="mapPlaceholder" style="display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 400px; color: var(--gray-500);">
|
||||
<div style="font-size: 48px; margin-bottom: 16px;">🏭</div>
|
||||
<h3 style="margin-bottom: 8px;">공장을 선택하세요</h3>
|
||||
<p style="font-size: var(--text-sm);">위에서 공장을 선택하면 작업장 현황을 확인할 수 있습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
</main>
|
||||
|
||||
<!-- 푸터 -->
|
||||
<footer class="dashboard-footer">
|
||||
<div class="footer-content">
|
||||
<p class="footer-text">
|
||||
© 2025 (주)테크니컬코리아. 모든 권리 보유.
|
||||
</p>
|
||||
<div class="footer-links">
|
||||
<a href="#" class="footer-link">도움말</a>
|
||||
<a href="#" class="footer-link">문의하기</a>
|
||||
<a href="#" class="footer-link">개인정보처리방침</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 알림 토스트 -->
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
|
||||
<!-- 작업장 상세 정보 모달 -->
|
||||
<div id="workplaceDetailModal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.5); z-index: 1000; align-items: center; justify-content: center;">
|
||||
<div style="background: white; border-radius: var(--radius-lg); padding: 32px; max-width: 800px; width: 90%; max-height: 80vh; overflow-y: auto; box-shadow: var(--shadow-2xl);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px;">
|
||||
<h2 id="modalWorkplaceName" style="margin: 0; font-size: var(--text-2xl); font-weight: 700;"></h2>
|
||||
<button class="btn btn-secondary btn-sm" onclick="closeWorkplaceModal()">닫기</button>
|
||||
</div>
|
||||
|
||||
<!-- 내부 작업자 -->
|
||||
<div id="internalWorkersSection" style="margin-bottom: 24px;">
|
||||
<h3 style="font-size: var(--text-lg); font-weight: 600; margin-bottom: 16px; color: var(--primary-600);">👷 내부 작업자</h3>
|
||||
<div id="internalWorkersList"></div>
|
||||
</div>
|
||||
|
||||
<!-- 외부 방문자 -->
|
||||
<div id="externalVisitorsSection">
|
||||
<h3 style="font-size: var(--text-lg); font-weight: 600; margin-bottom: 16px; color: var(--purple-600);">🚪 외부 방문자</h3>
|
||||
<div id="externalVisitorsList"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,317 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>👤 내 프로필 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/main-layout.css">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
|
||||
<style>
|
||||
.profile-page {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 3rem;
|
||||
color: white;
|
||||
margin: 0 auto 20px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.profile-name {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.profile-role {
|
||||
font-size: 1.2rem;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.profile-cards {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 1.05rem;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #1976d2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #1565c0;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f5f5f5;
|
||||
color: #333;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: #ff9800;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background: #f57c00;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* 통계 카드 */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.stat-box {
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #1976d2;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.profile-page {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.profile-name {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-layout-no-sidebar">
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="profile-page">
|
||||
<div class="profile-header">
|
||||
<div class="profile-avatar" id="profileAvatar">👤</div>
|
||||
<h1 class="profile-name" id="profileName">사용자</h1>
|
||||
<p class="profile-role" id="profileRole">역할</p>
|
||||
</div>
|
||||
|
||||
<div class="profile-cards">
|
||||
<!-- 기본 정보 -->
|
||||
<div class="profile-card">
|
||||
<h2 class="card-title">
|
||||
<span>📋</span>
|
||||
<span>기본 정보</span>
|
||||
</h2>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="info-label">사용자 ID</span>
|
||||
<span class="info-value" id="userId">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">사용자명</span>
|
||||
<span class="info-value" id="username">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">이름</span>
|
||||
<span class="info-value" id="fullName">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">권한 레벨</span>
|
||||
<span class="info-value" id="accessLevel">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">작업자 ID</span>
|
||||
<span class="info-value" id="workerId">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">가입일</span>
|
||||
<span class="info-value" id="createdAt">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 활동 정보 -->
|
||||
<div class="profile-card">
|
||||
<h2 class="card-title">
|
||||
<span>📊</span>
|
||||
<span>활동 정보</span>
|
||||
</h2>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="info-label">마지막 로그인</span>
|
||||
<span class="info-value" id="lastLogin">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">이메일</span>
|
||||
<span class="info-value" id="email">-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 간단한 통계 (준비중) -->
|
||||
<div class="stats-grid" style="margin-top: 24px; opacity: 0.5;">
|
||||
<div class="stat-box">
|
||||
<span class="stat-number">-</span>
|
||||
<span class="stat-label">작업 보고서</span>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<span class="stat-number">-</span>
|
||||
<span class="stat-label">이번 달 활동</span>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<span class="stat-number">-</span>
|
||||
<span class="stat-label">팀 기여도</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 빠른 작업 -->
|
||||
<div class="profile-card">
|
||||
<h2 class="card-title">
|
||||
<span>⚡</span>
|
||||
<span>빠른 작업</span>
|
||||
</h2>
|
||||
<div class="action-buttons">
|
||||
<a href="/pages/profile/change-password.html" class="action-btn btn-warning">
|
||||
<span>🔐</span>
|
||||
<span>비밀번호 변경</span>
|
||||
</a>
|
||||
<button class="action-btn btn-secondary" disabled>
|
||||
<span>✏️</span>
|
||||
<span>프로필 수정 (준비중)</span>
|
||||
</button>
|
||||
<button class="action-btn btn-secondary" disabled>
|
||||
<span>⚙️</span>
|
||||
<span>설정 (준비중)</span>
|
||||
</button>
|
||||
<a href="javascript:history.back()" class="action-btn btn-secondary">
|
||||
<span>←</span>
|
||||
<span>돌아가기</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/my-profile.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,391 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>🔐 비밀번호 변경 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/main-layout.css">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
|
||||
<style>
|
||||
/* 페이지 전용 스타일 */
|
||||
.password-page {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.page-title h1 {
|
||||
font-size: 2rem;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.page-title p {
|
||||
color: #666;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* 카드 스타일 */
|
||||
.password-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background: linear-gradient(135deg, #ff9800 0%, #f57c00 100%);
|
||||
color: white;
|
||||
padding: 24px 32px;
|
||||
}
|
||||
|
||||
.card-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
/* 알림 박스 */
|
||||
.info-box {
|
||||
background: #e3f2fd;
|
||||
border: 1px solid #90caf9;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.info-box h4 {
|
||||
margin: 0 0 12px 0;
|
||||
color: #1565c0;
|
||||
font-size: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.info-box ul {
|
||||
margin: 0;
|
||||
padding-left: 24px;
|
||||
color: #0d47a1;
|
||||
}
|
||||
|
||||
.info-box li {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* 폼 스타일 */
|
||||
.password-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-size: 0.95rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 14px 48px 14px 16px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: #ff9800;
|
||||
box-shadow: 0 0 0 3px rgba(255, 152, 0, 0.1);
|
||||
}
|
||||
|
||||
.password-toggle {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
font-size: 1.2rem;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.password-toggle:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 버튼 */
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 14px 28px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #ff9800;
|
||||
color: white;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #f57c00;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(255, 152, 0, 0.3);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f5f5f5;
|
||||
color: #666;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
/* 메시지 */
|
||||
.message-box {
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
animation: slideDown 0.3s ease-out;
|
||||
}
|
||||
|
||||
.message-box.error {
|
||||
background: #ffebee;
|
||||
border: 1px solid #ffcdd2;
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.message-box.success {
|
||||
background: #e8f5e9;
|
||||
border: 1px solid #c8e6c9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 하단 링크 */
|
||||
.back-link {
|
||||
text-align: center;
|
||||
margin-top: 32px;
|
||||
padding-top: 32px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.back-link a {
|
||||
color: #1976d2;
|
||||
text-decoration: none;
|
||||
font-size: 0.95rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.back-link a:hover {
|
||||
color: #1565c0;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 768px) {
|
||||
.password-page {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-layout-no-sidebar">
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="password-page">
|
||||
<div class="page-title">
|
||||
<h1>🔐 비밀번호 변경</h1>
|
||||
<p>계정 보안을 위해 정기적으로 비밀번호를 변경해주세요</p>
|
||||
</div>
|
||||
|
||||
<div class="password-card">
|
||||
<div class="card-header">
|
||||
<h2>
|
||||
<span>🔑</span>
|
||||
<span>새 비밀번호 설정</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<!-- 메시지 영역 -->
|
||||
<div id="message-area"></div>
|
||||
|
||||
<!-- 안내 정보 -->
|
||||
<div class="info-box">
|
||||
<h4>
|
||||
<span>ℹ️</span>
|
||||
<span>비밀번호 요구사항</span>
|
||||
</h4>
|
||||
<ul>
|
||||
<li>최소 6자 이상 입력해주세요</li>
|
||||
<li>영문 대/소문자, 숫자, 특수문자를 조합하면 더 안전합니다</li>
|
||||
<li>개인정보나 쉬운 단어는 피해주세요</li>
|
||||
<li>이전 비밀번호와 다르게 설정해주세요</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 비밀번호 변경 폼 -->
|
||||
<form id="changePasswordForm" class="password-form">
|
||||
<div class="form-group">
|
||||
<label for="currentPassword">
|
||||
<span>🔓</span>
|
||||
<span>현재 비밀번호</span>
|
||||
</label>
|
||||
<div class="input-wrapper">
|
||||
<input
|
||||
type="password"
|
||||
id="currentPassword"
|
||||
class="form-control"
|
||||
placeholder="현재 비밀번호를 입력하세요"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
<button type="button" class="password-toggle" data-target="currentPassword">👁️</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="newPassword">
|
||||
<span>🔐</span>
|
||||
<span>새 비밀번호</span>
|
||||
</label>
|
||||
<div class="input-wrapper">
|
||||
<input
|
||||
type="password"
|
||||
id="newPassword"
|
||||
class="form-control"
|
||||
placeholder="새 비밀번호를 입력하세요"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<button type="button" class="password-toggle" data-target="newPassword">👁️</button>
|
||||
</div>
|
||||
<div id="passwordStrength"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="confirmPassword">
|
||||
<span>✅</span>
|
||||
<span>새 비밀번호 확인</span>
|
||||
</label>
|
||||
<div class="input-wrapper">
|
||||
<input
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
class="form-control"
|
||||
placeholder="새 비밀번호를 다시 입력하세요"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<button type="button" class="password-toggle" data-target="confirmPassword">👁️</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary" id="submitBtn">
|
||||
<span>🔑</span>
|
||||
<span>비밀번호 변경</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" id="resetBtn">
|
||||
초기화
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="back-link">
|
||||
<a href="javascript:history.back()">
|
||||
<span>←</span>
|
||||
<span>이전 페이지로 돌아가기</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/change-password.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
# Placeholder file to create work directory
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,946 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>신고 상세 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/common.css?v=2">
|
||||
<link rel="stylesheet" href="/css/project-management.css?v=3">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<style>
|
||||
.detail-container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detail-id {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-500);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 8px 20px;
|
||||
border-radius: 9999px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-badge.reported { background: var(--blue-100); color: var(--blue-700); }
|
||||
.status-badge.received { background: var(--orange-100); color: var(--orange-700); }
|
||||
.status-badge.in_progress { background: var(--purple-100); color: var(--purple-700); }
|
||||
.status-badge.completed { background: var(--green-100); color: var(--green-700); }
|
||||
.status-badge.closed { background: var(--gray-100); color: var(--gray-700); }
|
||||
|
||||
.detail-section {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 24px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
padding: 12px;
|
||||
background: var(--gray-50);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--gray-500);
|
||||
margin-bottom: 4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.type-badge.nonconformity {
|
||||
background: var(--orange-100);
|
||||
color: var(--orange-700);
|
||||
}
|
||||
|
||||
.type-badge.safety {
|
||||
background: var(--red-100);
|
||||
color: var(--red-700);
|
||||
}
|
||||
|
||||
.severity-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.severity-badge.critical { background: var(--red-100); color: var(--red-700); }
|
||||
.severity-badge.high { background: var(--orange-100); color: var(--orange-700); }
|
||||
.severity-badge.medium { background: var(--yellow-100); color: var(--yellow-700); }
|
||||
.severity-badge.low { background: var(--gray-100); color: var(--gray-700); }
|
||||
|
||||
.photo-gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.photo-item {
|
||||
aspect-ratio: 1;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.photo-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.description-text {
|
||||
padding: 16px;
|
||||
background: var(--gray-50);
|
||||
border-radius: var(--radius-md);
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 12px 24px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--gray-300);
|
||||
background: white;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--gray-50);
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
background: var(--primary-500);
|
||||
color: white;
|
||||
border-color: var(--primary-500);
|
||||
}
|
||||
|
||||
.action-btn.primary:hover {
|
||||
background: var(--primary-600);
|
||||
}
|
||||
|
||||
.action-btn.success {
|
||||
background: var(--green-500);
|
||||
color: white;
|
||||
border-color: var(--green-500);
|
||||
}
|
||||
|
||||
.action-btn.success:hover {
|
||||
background: var(--green-600);
|
||||
}
|
||||
|
||||
.action-btn.danger {
|
||||
background: var(--red-500);
|
||||
color: white;
|
||||
border-color: var(--red-500);
|
||||
}
|
||||
|
||||
.action-btn.danger:hover {
|
||||
background: var(--red-600);
|
||||
}
|
||||
|
||||
/* 상태 변경 이력 */
|
||||
.status-timeline {
|
||||
position: relative;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.status-timeline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--gray-200);
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
position: relative;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.timeline-item:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.timeline-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -18px;
|
||||
top: 4px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-500);
|
||||
}
|
||||
|
||||
.timeline-status {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.timeline-meta {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
/* 담당자 배정 모달 */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-overlay.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: var(--radius-lg);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.modal-form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.modal-form-group label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.modal-form-group input,
|
||||
.modal-form-group select,
|
||||
.modal-form-group textarea {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--gray-300);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* 사진 확대 모달 */
|
||||
.photo-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
z-index: 1001;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.photo-modal.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.photo-modal img {
|
||||
max-width: 90%;
|
||||
max-height: 90%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.photo-modal-close {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
color: white;
|
||||
font-size: 32px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.info-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<main class="main-content">
|
||||
<div class="detail-container">
|
||||
<a href="/pages/work/issue-list.html" style="color: var(--primary-600); text-decoration: none; margin-bottom: 16px; display: inline-block;">
|
||||
← 목록으로
|
||||
</a>
|
||||
|
||||
<div class="detail-header">
|
||||
<div>
|
||||
<div class="detail-id" id="reportId"></div>
|
||||
<h1 class="detail-title" id="reportTitle">로딩 중...</h1>
|
||||
</div>
|
||||
<span class="status-badge" id="statusBadge"></span>
|
||||
</div>
|
||||
|
||||
<!-- 기본 정보 -->
|
||||
<div class="detail-section">
|
||||
<h2 class="section-title">신고 정보</h2>
|
||||
<div class="info-grid" id="basicInfo"></div>
|
||||
</div>
|
||||
|
||||
<!-- 신고 내용 -->
|
||||
<div class="detail-section">
|
||||
<h2 class="section-title">신고 내용</h2>
|
||||
<div id="issueContent"></div>
|
||||
</div>
|
||||
|
||||
<!-- 사진 -->
|
||||
<div class="detail-section" id="photoSection" style="display: none;">
|
||||
<h2 class="section-title">첨부 사진</h2>
|
||||
<div class="photo-gallery" id="photoGallery"></div>
|
||||
</div>
|
||||
|
||||
<!-- 처리 정보 (담당자 배정 시) -->
|
||||
<div class="detail-section" id="processSection" style="display: none;">
|
||||
<h2 class="section-title">처리 정보</h2>
|
||||
<div id="processInfo"></div>
|
||||
</div>
|
||||
|
||||
<!-- 상태 이력 -->
|
||||
<div class="detail-section">
|
||||
<h2 class="section-title">상태 변경 이력</h2>
|
||||
<div class="status-timeline" id="statusTimeline"></div>
|
||||
</div>
|
||||
|
||||
<!-- 액션 버튼 -->
|
||||
<div class="action-buttons" id="actionButtons"></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 담당자 배정 모달 -->
|
||||
<div class="modal-overlay" id="assignModal">
|
||||
<div class="modal-content">
|
||||
<h3 class="modal-title">담당자 배정</h3>
|
||||
<div class="modal-form-group">
|
||||
<label>담당 부서</label>
|
||||
<input type="text" id="assignDepartment" placeholder="담당 부서 입력">
|
||||
</div>
|
||||
<div class="modal-form-group">
|
||||
<label>담당자</label>
|
||||
<select id="assignUser">
|
||||
<option value="">담당자 선택</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="action-btn" onclick="closeAssignModal()">취소</button>
|
||||
<button class="action-btn primary" onclick="submitAssign()">배정</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 처리 완료 모달 -->
|
||||
<div class="modal-overlay" id="completeModal">
|
||||
<div class="modal-content">
|
||||
<h3 class="modal-title">처리 완료</h3>
|
||||
<div class="modal-form-group">
|
||||
<label>처리 내용</label>
|
||||
<textarea id="resolutionNotes" rows="4" placeholder="처리 내용을 입력하세요"></textarea>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="action-btn" onclick="closeCompleteModal()">취소</button>
|
||||
<button class="action-btn success" onclick="submitComplete()">완료 처리</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 사진 확대 모달 -->
|
||||
<div class="photo-modal" id="photoModal" onclick="closePhotoModal()">
|
||||
<span class="photo-modal-close">×</span>
|
||||
<img id="photoModalImg" src="" alt="">
|
||||
</div>
|
||||
|
||||
<script src="/js/load-navbar.js?v=1"></script>
|
||||
<script>
|
||||
const API_BASE = window.API_BASE_URL || 'http://localhost:20005/api';
|
||||
let currentReport = null;
|
||||
let currentUserLevel = null;
|
||||
|
||||
const STATUS_LABELS = {
|
||||
reported: '신고',
|
||||
received: '접수',
|
||||
in_progress: '처리중',
|
||||
completed: '완료',
|
||||
closed: '종료'
|
||||
};
|
||||
|
||||
const TYPE_LABELS = {
|
||||
nonconformity: '부적합',
|
||||
safety: '안전'
|
||||
};
|
||||
|
||||
const SEVERITY_LABELS = {
|
||||
critical: '심각',
|
||||
high: '높음',
|
||||
medium: '보통',
|
||||
low: '낮음'
|
||||
};
|
||||
|
||||
// 초기화
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const reportId = urlParams.get('id');
|
||||
|
||||
if (!reportId) {
|
||||
alert('잘못된 접근입니다.');
|
||||
window.location.href = '/pages/work/issue-list.html';
|
||||
return;
|
||||
}
|
||||
|
||||
// 사용자 정보 확인
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
currentUserLevel = payload.access_level;
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
await loadReport(reportId);
|
||||
await loadStatusLogs(reportId);
|
||||
await loadUsers();
|
||||
});
|
||||
|
||||
// 신고 상세 로드
|
||||
async function loadReport(reportId) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/work-issues/${reportId}`, {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
alert('신고를 찾을 수 없습니다.');
|
||||
} else if (response.status === 403) {
|
||||
alert('권한이 없습니다.');
|
||||
}
|
||||
window.location.href = '/pages/work/issue-list.html';
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
currentReport = data.data;
|
||||
renderReport(currentReport);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('신고 로드 실패:', error);
|
||||
alert('데이터를 불러올 수 없습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
// 신고 렌더링
|
||||
function renderReport(report) {
|
||||
const baseUrl = (window.API_BASE_URL || 'http://localhost:20005').replace('/api', '');
|
||||
|
||||
// 헤더
|
||||
document.getElementById('reportId').textContent = `#${report.report_id}`;
|
||||
document.getElementById('reportTitle').textContent = report.issue_item_name || report.issue_category_name || '신고';
|
||||
|
||||
const statusBadge = document.getElementById('statusBadge');
|
||||
statusBadge.textContent = STATUS_LABELS[report.status] || report.status;
|
||||
statusBadge.className = `status-badge ${report.status}`;
|
||||
|
||||
// 기본 정보
|
||||
const reportDate = new Date(report.report_date).toLocaleString('ko-KR');
|
||||
let location = report.custom_location || '';
|
||||
if (report.factory_name) {
|
||||
location = report.factory_name;
|
||||
if (report.workplace_name) {
|
||||
location += ` - ${report.workplace_name}`;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('basicInfo').innerHTML = `
|
||||
<div class="info-item">
|
||||
<div class="info-label">신고자</div>
|
||||
<div class="info-value">${report.reporter_full_name || report.reporter_name}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">신고 일시</div>
|
||||
<div class="info-value">${reportDate}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">발생 위치</div>
|
||||
<div class="info-value">${location || '-'}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">문제 유형</div>
|
||||
<div class="info-value">
|
||||
<span class="type-badge ${report.category_type}">${TYPE_LABELS[report.category_type]}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 신고 내용
|
||||
let contentHtml = `
|
||||
<div class="info-grid" style="margin-bottom: 16px;">
|
||||
<div class="info-item">
|
||||
<div class="info-label">카테고리</div>
|
||||
<div class="info-value">${report.issue_category_name}</div>
|
||||
</div>
|
||||
${report.issue_item_name ? `
|
||||
<div class="info-item">
|
||||
<div class="info-label">신고 항목</div>
|
||||
<div class="info-value">
|
||||
${report.issue_item_name}
|
||||
${report.severity ? `<span class="severity-badge ${report.severity}">${SEVERITY_LABELS[report.severity]}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (report.additional_description) {
|
||||
contentHtml += `
|
||||
<div class="info-label" style="margin-bottom: 8px;">추가 설명</div>
|
||||
<div class="description-text">${report.additional_description}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
document.getElementById('issueContent').innerHTML = contentHtml;
|
||||
|
||||
// 사진
|
||||
const photos = [
|
||||
report.photo_path1,
|
||||
report.photo_path2,
|
||||
report.photo_path3,
|
||||
report.photo_path4,
|
||||
report.photo_path5
|
||||
].filter(Boolean);
|
||||
|
||||
if (photos.length > 0) {
|
||||
document.getElementById('photoSection').style.display = 'block';
|
||||
document.getElementById('photoGallery').innerHTML = photos.map(p => `
|
||||
<div class="photo-item" onclick="showPhoto('${baseUrl}${p}')">
|
||||
<img src="${baseUrl}${p}" alt="신고 사진" loading="lazy">
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 처리 정보
|
||||
if (report.assigned_user_id || report.resolution_notes) {
|
||||
document.getElementById('processSection').style.display = 'block';
|
||||
|
||||
let processHtml = '<div class="info-grid">';
|
||||
|
||||
if (report.assigned_full_name) {
|
||||
processHtml += `
|
||||
<div class="info-item">
|
||||
<div class="info-label">담당자</div>
|
||||
<div class="info-value">${report.assigned_full_name}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (report.assigned_department) {
|
||||
processHtml += `
|
||||
<div class="info-item">
|
||||
<div class="info-label">담당 부서</div>
|
||||
<div class="info-value">${report.assigned_department}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
processHtml += '</div>';
|
||||
|
||||
if (report.resolution_notes) {
|
||||
processHtml += `
|
||||
<div class="info-label" style="margin-top: 16px; margin-bottom: 8px;">처리 내용</div>
|
||||
<div class="description-text">${report.resolution_notes}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 처리 완료 사진
|
||||
const resolutionPhotos = [
|
||||
report.resolution_photo_path1,
|
||||
report.resolution_photo_path2
|
||||
].filter(Boolean);
|
||||
|
||||
if (resolutionPhotos.length > 0) {
|
||||
processHtml += `
|
||||
<div class="info-label" style="margin-top: 16px; margin-bottom: 8px;">처리 완료 사진</div>
|
||||
<div class="photo-gallery">
|
||||
${resolutionPhotos.map(p => `
|
||||
<div class="photo-item" onclick="showPhoto('${baseUrl}${p}')">
|
||||
<img src="${baseUrl}${p}" alt="처리 완료 사진" loading="lazy">
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
document.getElementById('processInfo').innerHTML = processHtml;
|
||||
}
|
||||
|
||||
// 액션 버튼
|
||||
renderActionButtons(report);
|
||||
}
|
||||
|
||||
// 액션 버튼 렌더링
|
||||
function renderActionButtons(report) {
|
||||
const buttons = [];
|
||||
const isManager = ['admin', 'system', 'support_team'].includes(currentUserLevel);
|
||||
|
||||
switch (report.status) {
|
||||
case 'reported':
|
||||
if (isManager) {
|
||||
buttons.push(`<button class="action-btn primary" onclick="receiveReport()">접수</button>`);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'received':
|
||||
if (isManager) {
|
||||
buttons.push(`<button class="action-btn" onclick="showAssignModal()">담당자 배정</button>`);
|
||||
buttons.push(`<button class="action-btn primary" onclick="startProcessing()">처리 시작</button>`);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'in_progress':
|
||||
buttons.push(`<button class="action-btn success" onclick="showCompleteModal()">처리 완료</button>`);
|
||||
if (isManager) {
|
||||
buttons.push(`<button class="action-btn" onclick="showAssignModal()">담당자 변경</button>`);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'completed':
|
||||
if (['admin', 'system'].includes(currentUserLevel)) {
|
||||
buttons.push(`<button class="action-btn" onclick="closeReport()">종료</button>`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
document.getElementById('actionButtons').innerHTML = buttons.join('');
|
||||
}
|
||||
|
||||
// 상태 이력 로드
|
||||
async function loadStatusLogs(reportId) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/work-issues/${reportId}/logs`, {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
|
||||
if (!response.ok) return;
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success && data.data) {
|
||||
renderStatusLogs(data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('상태 이력 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 상태 이력 렌더링
|
||||
function renderStatusLogs(logs) {
|
||||
const timeline = document.getElementById('statusTimeline');
|
||||
|
||||
if (logs.length === 0) {
|
||||
timeline.innerHTML = '<p style="color: var(--gray-500);">이력이 없습니다.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
timeline.innerHTML = logs.map(log => {
|
||||
const date = new Date(log.changed_at).toLocaleString('ko-KR');
|
||||
const prevStatus = log.previous_status ? STATUS_LABELS[log.previous_status] : '(없음)';
|
||||
const newStatus = STATUS_LABELS[log.new_status] || log.new_status;
|
||||
|
||||
return `
|
||||
<div class="timeline-item">
|
||||
<div class="timeline-status">${prevStatus} → ${newStatus}</div>
|
||||
<div class="timeline-meta">
|
||||
${log.changed_by_full_name || log.changed_by_name} | ${date}
|
||||
${log.change_reason ? `<br><span style="color: var(--gray-600);">${log.change_reason}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// 사용자 목록 로드 (담당자 배정용)
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/users`, {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
|
||||
if (!response.ok) return;
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success && data.data) {
|
||||
const select = document.getElementById('assignUser');
|
||||
data.data.forEach(user => {
|
||||
const option = document.createElement('option');
|
||||
option.value = user.user_id;
|
||||
option.textContent = `${user.name} (${user.username})`;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('사용자 목록 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 접수
|
||||
async function receiveReport() {
|
||||
if (!confirm('이 신고를 접수하시겠습니까?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/work-issues/${currentReport.report_id}/receive`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
alert('접수되었습니다.');
|
||||
location.reload();
|
||||
} else {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('접수 실패: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 처리 시작
|
||||
async function startProcessing() {
|
||||
if (!confirm('처리를 시작하시겠습니까?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/work-issues/${currentReport.report_id}/start`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
alert('처리가 시작되었습니다.');
|
||||
location.reload();
|
||||
} else {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('처리 시작 실패: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 담당자 배정 모달
|
||||
function showAssignModal() {
|
||||
document.getElementById('assignModal').classList.add('visible');
|
||||
}
|
||||
|
||||
function closeAssignModal() {
|
||||
document.getElementById('assignModal').classList.remove('visible');
|
||||
}
|
||||
|
||||
async function submitAssign() {
|
||||
const department = document.getElementById('assignDepartment').value;
|
||||
const userId = document.getElementById('assignUser').value;
|
||||
|
||||
if (!userId) {
|
||||
alert('담당자를 선택하세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/work-issues/${currentReport.report_id}/assign`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
assigned_department: department,
|
||||
assigned_user_id: parseInt(userId)
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
alert('담당자가 배정되었습니다.');
|
||||
location.reload();
|
||||
} else {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('담당자 배정 실패: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 처리 완료 모달
|
||||
function showCompleteModal() {
|
||||
document.getElementById('completeModal').classList.add('visible');
|
||||
}
|
||||
|
||||
function closeCompleteModal() {
|
||||
document.getElementById('completeModal').classList.remove('visible');
|
||||
}
|
||||
|
||||
async function submitComplete() {
|
||||
const resolutionNotes = document.getElementById('resolutionNotes').value;
|
||||
|
||||
if (!resolutionNotes.trim()) {
|
||||
alert('처리 내용을 입력하세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/work-issues/${currentReport.report_id}/complete`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
resolution_notes: resolutionNotes
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
alert('처리가 완료되었습니다.');
|
||||
location.reload();
|
||||
} else {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('처리 완료 실패: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 종료
|
||||
async function closeReport() {
|
||||
if (!confirm('이 신고를 종료하시겠습니까?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/work-issues/${currentReport.report_id}/close`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
alert('종료되었습니다.');
|
||||
location.reload();
|
||||
} else {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('종료 실패: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 사진 확대
|
||||
function showPhoto(src) {
|
||||
document.getElementById('photoModalImg').src = src;
|
||||
document.getElementById('photoModal').classList.add('visible');
|
||||
}
|
||||
|
||||
function closePhotoModal() {
|
||||
document.getElementById('photoModal').classList.remove('visible');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,301 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>신고 목록 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/common.css?v=2">
|
||||
<link rel="stylesheet" href="/css/project-management.css?v=3">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<style>
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
text-align: center;
|
||||
border: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
.stat-card.reported .stat-number { color: var(--blue-600); }
|
||||
.stat-card.received .stat-number { color: var(--orange-600); }
|
||||
.stat-card.in_progress .stat-number { color: var(--purple-600); }
|
||||
.stat-card.completed .stat-number { color: var(--green-600); }
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
padding: 16px;
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.filter-bar select,
|
||||
.filter-bar input {
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--gray-300);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.filter-bar select:focus,
|
||||
.filter-bar input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-500);
|
||||
}
|
||||
|
||||
.btn-new-report {
|
||||
margin-left: auto;
|
||||
padding: 10px 20px;
|
||||
background: var(--primary-500);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-new-report:hover {
|
||||
background: var(--primary-600);
|
||||
}
|
||||
|
||||
.issue-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.issue-card {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--gray-200);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.issue-card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
border-color: var(--primary-300);
|
||||
}
|
||||
|
||||
.issue-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.issue-id {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
.issue-status {
|
||||
padding: 4px 12px;
|
||||
border-radius: 9999px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.issue-status.reported {
|
||||
background: var(--blue-100);
|
||||
color: var(--blue-700);
|
||||
}
|
||||
|
||||
.issue-status.received {
|
||||
background: var(--orange-100);
|
||||
color: var(--orange-700);
|
||||
}
|
||||
|
||||
.issue-status.in_progress {
|
||||
background: var(--purple-100);
|
||||
color: var(--purple-700);
|
||||
}
|
||||
|
||||
.issue-status.completed {
|
||||
background: var(--green-100);
|
||||
color: var(--green-700);
|
||||
}
|
||||
|
||||
.issue-status.closed {
|
||||
background: var(--gray-100);
|
||||
color: var(--gray-700);
|
||||
}
|
||||
|
||||
.issue-title {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.issue-type-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.issue-type-badge.nonconformity {
|
||||
background: var(--orange-100);
|
||||
color: var(--orange-700);
|
||||
}
|
||||
|
||||
.issue-type-badge.safety {
|
||||
background: var(--red-100);
|
||||
color: var(--red-700);
|
||||
}
|
||||
|
||||
.issue-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
.issue-meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.issue-photos {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.issue-photos img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
.empty-state-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.filter-bar {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn-new-report {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<main class="main-content">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">문제 신고 목록</h1>
|
||||
</div>
|
||||
|
||||
<!-- 통계 카드 -->
|
||||
<div class="stats-grid" id="statsGrid">
|
||||
<div class="stat-card reported">
|
||||
<div class="stat-number" id="statReported">-</div>
|
||||
<div class="stat-label">신고</div>
|
||||
</div>
|
||||
<div class="stat-card received">
|
||||
<div class="stat-number" id="statReceived">-</div>
|
||||
<div class="stat-label">접수</div>
|
||||
</div>
|
||||
<div class="stat-card in_progress">
|
||||
<div class="stat-number" id="statProgress">-</div>
|
||||
<div class="stat-label">처리중</div>
|
||||
</div>
|
||||
<div class="stat-card completed">
|
||||
<div class="stat-number" id="statCompleted">-</div>
|
||||
<div class="stat-label">완료</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 필터 바 -->
|
||||
<div class="filter-bar">
|
||||
<select id="filterStatus">
|
||||
<option value="">전체 상태</option>
|
||||
<option value="reported">신고</option>
|
||||
<option value="received">접수</option>
|
||||
<option value="in_progress">처리중</option>
|
||||
<option value="completed">완료</option>
|
||||
<option value="closed">종료</option>
|
||||
</select>
|
||||
|
||||
<select id="filterType">
|
||||
<option value="">전체 유형</option>
|
||||
<option value="nonconformity">부적합</option>
|
||||
<option value="safety">안전</option>
|
||||
</select>
|
||||
|
||||
<input type="date" id="filterStartDate" title="시작일">
|
||||
<input type="date" id="filterEndDate" title="종료일">
|
||||
|
||||
<a href="/pages/work/issue-report.html" class="btn-new-report">
|
||||
+ 새 신고
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 신고 목록 -->
|
||||
<div class="issue-list" id="issueList">
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-title">로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="/js/load-navbar.js?v=1"></script>
|
||||
<script src="/js/work-issue-list.js?v=1"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,618 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>문제 신고 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/common.css?v=2">
|
||||
<link rel="stylesheet" href="/css/project-management.css?v=3">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<style>
|
||||
.issue-form-container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.step-indicator {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 32px;
|
||||
padding: 16px;
|
||||
background: var(--gray-50);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--gray-400);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.step.active {
|
||||
color: var(--primary-600);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.step.completed {
|
||||
color: var(--green-600);
|
||||
}
|
||||
|
||||
.step-number {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--gray-200);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.step.active .step-number {
|
||||
background: var(--primary-500);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.step.completed .step-number {
|
||||
background: var(--green-500);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.form-section-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
/* 지도 선택 영역 */
|
||||
.map-container {
|
||||
position: relative;
|
||||
min-height: 400px;
|
||||
background: var(--gray-100);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#issueMapCanvas {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.selected-location-info {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
background: var(--primary-50);
|
||||
border-radius: var(--radius-md);
|
||||
border-left: 4px solid var(--primary-500);
|
||||
}
|
||||
|
||||
.selected-location-info.empty {
|
||||
background: var(--gray-50);
|
||||
border-left-color: var(--gray-300);
|
||||
color: var(--gray-500);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.custom-location-toggle {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.custom-location-toggle input[type="checkbox"] {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.custom-location-input {
|
||||
margin-top: 12px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.custom-location-input.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 유형 선택 버튼 */
|
||||
.type-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.type-btn {
|
||||
padding: 24px;
|
||||
border: 2px solid var(--gray-200);
|
||||
border-radius: var(--radius-lg);
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.type-btn:hover {
|
||||
border-color: var(--primary-300);
|
||||
background: var(--primary-50);
|
||||
}
|
||||
|
||||
.type-btn.selected {
|
||||
border-color: var(--primary-500);
|
||||
background: var(--primary-50);
|
||||
}
|
||||
|
||||
.type-btn.nonconformity.selected {
|
||||
border-color: var(--orange-500);
|
||||
background: var(--orange-50);
|
||||
}
|
||||
|
||||
.type-btn.safety.selected {
|
||||
border-color: var(--red-500);
|
||||
background: var(--red-50);
|
||||
}
|
||||
|
||||
.type-btn-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.type-btn-desc {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
/* 카테고리 선택 */
|
||||
.category-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.category-btn {
|
||||
padding: 16px;
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: var(--radius-md);
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
text-align: center;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.category-btn:hover {
|
||||
border-color: var(--primary-300);
|
||||
background: var(--gray-50);
|
||||
}
|
||||
|
||||
.category-btn.selected {
|
||||
border-color: var(--primary-500);
|
||||
background: var(--primary-50);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 사전 정의 항목 선택 */
|
||||
.item-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.item-btn {
|
||||
padding: 12px 20px;
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: 9999px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.item-btn:hover {
|
||||
border-color: var(--primary-300);
|
||||
background: var(--gray-50);
|
||||
}
|
||||
|
||||
.item-btn.selected {
|
||||
border-color: var(--primary-500);
|
||||
background: var(--primary-500);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.item-btn[data-severity="critical"] {
|
||||
border-color: var(--red-300);
|
||||
}
|
||||
|
||||
.item-btn[data-severity="critical"].selected {
|
||||
background: var(--red-500);
|
||||
border-color: var(--red-500);
|
||||
}
|
||||
|
||||
.item-btn[data-severity="high"] {
|
||||
border-color: var(--orange-300);
|
||||
}
|
||||
|
||||
.item-btn[data-severity="high"].selected {
|
||||
background: var(--orange-500);
|
||||
border-color: var(--orange-500);
|
||||
}
|
||||
|
||||
/* 사진 업로드 */
|
||||
.photo-upload-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.photo-slot {
|
||||
aspect-ratio: 1;
|
||||
border: 2px dashed var(--gray-300);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--gray-50);
|
||||
}
|
||||
|
||||
.photo-slot:hover {
|
||||
border-color: var(--primary-500);
|
||||
background: var(--primary-50);
|
||||
}
|
||||
|
||||
.photo-slot.has-photo {
|
||||
border-style: solid;
|
||||
border-color: var(--green-500);
|
||||
}
|
||||
|
||||
.photo-slot img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.photo-slot .add-icon {
|
||||
font-size: 24px;
|
||||
color: var(--gray-400);
|
||||
}
|
||||
|
||||
.photo-slot .remove-btn {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: var(--red-500);
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.photo-slot.has-photo .remove-btn {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.photo-slot .add-icon {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.photo-slot.has-photo .add-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 추가 설명 */
|
||||
.additional-textarea {
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--gray-300);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-base);
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.additional-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-500);
|
||||
box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.1);
|
||||
}
|
||||
|
||||
/* 제출 버튼 */
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
padding: 16px 48px;
|
||||
background: var(--primary-500);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-submit:hover {
|
||||
background: var(--primary-600);
|
||||
}
|
||||
|
||||
.btn-submit:disabled {
|
||||
background: var(--gray-300);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
padding: 16px 32px;
|
||||
background: white;
|
||||
color: var(--gray-600);
|
||||
border: 1px solid var(--gray-300);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-base);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-cancel:hover {
|
||||
background: var(--gray-50);
|
||||
}
|
||||
|
||||
/* 작업 선택 모달 */
|
||||
.work-selection-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.work-selection-modal.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.work-selection-content {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: var(--radius-lg);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.work-selection-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.work-option {
|
||||
padding: 16px;
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 12px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.work-option:hover {
|
||||
border-color: var(--primary-500);
|
||||
background: var(--primary-50);
|
||||
}
|
||||
|
||||
.work-option-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.work-option-desc {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 768px) {
|
||||
.type-buttons {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.photo-upload-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.step-indicator {
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.step-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<main class="main-content">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">문제 신고</h1>
|
||||
<p class="page-description">작업 중 발견된 부적합 사항 또는 안전 문제를 신고합니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="issue-form-container">
|
||||
<!-- 단계 표시 -->
|
||||
<div class="step-indicator">
|
||||
<div class="step active" data-step="1">
|
||||
<span class="step-number">1</span>
|
||||
<span class="step-text">위치 선택</span>
|
||||
</div>
|
||||
<div class="step" data-step="2">
|
||||
<span class="step-number">2</span>
|
||||
<span class="step-text">유형 선택</span>
|
||||
</div>
|
||||
<div class="step" data-step="3">
|
||||
<span class="step-number">3</span>
|
||||
<span class="step-text">항목 선택</span>
|
||||
</div>
|
||||
<div class="step" data-step="4">
|
||||
<span class="step-number">4</span>
|
||||
<span class="step-text">사진/설명</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 1: 위치 선택 -->
|
||||
<div class="form-section" id="step1Section">
|
||||
<h2 class="form-section-title">1. 발생 위치 선택</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="factorySelect">공장 선택</label>
|
||||
<select id="factorySelect">
|
||||
<option value="">공장을 선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="map-container">
|
||||
<canvas id="issueMapCanvas"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="selected-location-info empty" id="selectedLocationInfo">
|
||||
지도에서 작업장을 클릭하여 위치를 선택하세요
|
||||
</div>
|
||||
|
||||
<div class="custom-location-toggle">
|
||||
<input type="checkbox" id="useCustomLocation">
|
||||
<label for="useCustomLocation">지도에 없는 위치 직접 입력</label>
|
||||
</div>
|
||||
|
||||
<div class="custom-location-input" id="customLocationInput">
|
||||
<input type="text" id="customLocation" placeholder="위치를 입력하세요 (예: 야적장 입구, 주차장 등)">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: 문제 유형 선택 -->
|
||||
<div class="form-section" id="step2Section">
|
||||
<h2 class="form-section-title">2. 문제 유형 선택</h2>
|
||||
|
||||
<div class="type-buttons">
|
||||
<div class="type-btn nonconformity" data-type="nonconformity">
|
||||
<div class="type-btn-title">부적합 사항</div>
|
||||
<div class="type-btn-desc">자재, 설계, 검사 관련 문제</div>
|
||||
</div>
|
||||
<div class="type-btn safety" data-type="safety">
|
||||
<div class="type-btn-title">안전 관련</div>
|
||||
<div class="type-btn-desc">보호구, 위험구역, 안전수칙 관련</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="categoryContainer" style="display: none;">
|
||||
<label style="font-weight: 600; margin-bottom: 12px; display: block;">세부 카테고리</label>
|
||||
<div class="category-grid" id="categoryGrid"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: 신고 항목 선택 -->
|
||||
<div class="form-section" id="step3Section">
|
||||
<h2 class="form-section-title">3. 신고 항목 선택</h2>
|
||||
<p style="color: var(--gray-500); margin-bottom: 16px;">해당하는 항목을 선택하세요. 여러 개 선택 가능합니다.</p>
|
||||
|
||||
<div class="item-grid" id="itemGrid">
|
||||
<p style="color: var(--gray-400);">먼저 카테고리를 선택하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: 사진 및 추가 설명 -->
|
||||
<div class="form-section" id="step4Section">
|
||||
<h2 class="form-section-title">4. 사진 및 추가 설명</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label>사진 첨부 (최대 5장)</label>
|
||||
<div class="photo-upload-grid">
|
||||
<div class="photo-slot" data-index="0">
|
||||
<span class="add-icon">+</span>
|
||||
<button class="remove-btn" type="button">×</button>
|
||||
</div>
|
||||
<div class="photo-slot" data-index="1">
|
||||
<span class="add-icon">+</span>
|
||||
<button class="remove-btn" type="button">×</button>
|
||||
</div>
|
||||
<div class="photo-slot" data-index="2">
|
||||
<span class="add-icon">+</span>
|
||||
<button class="remove-btn" type="button">×</button>
|
||||
</div>
|
||||
<div class="photo-slot" data-index="3">
|
||||
<span class="add-icon">+</span>
|
||||
<button class="remove-btn" type="button">×</button>
|
||||
</div>
|
||||
<div class="photo-slot" data-index="4">
|
||||
<span class="add-icon">+</span>
|
||||
<button class="remove-btn" type="button">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" id="photoInput" accept="image/*" capture="environment" style="display: none;">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="additionalDescription">추가 설명 (선택)</label>
|
||||
<textarea id="additionalDescription" class="additional-textarea" placeholder="추가로 설명이 필요한 내용을 입력하세요..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 제출 버튼 -->
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn-cancel" onclick="history.back()">취소</button>
|
||||
<button type="button" class="btn-submit" id="submitBtn" onclick="submitReport()">신고 제출</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 작업 선택 모달 -->
|
||||
<div class="work-selection-modal" id="workSelectionModal">
|
||||
<div class="work-selection-content">
|
||||
<h3 class="work-selection-title">작업 선택</h3>
|
||||
<p style="margin-bottom: 16px; color: var(--gray-600);">이 위치에 등록된 작업이 있습니다. 연결할 작업을 선택하세요.</p>
|
||||
<div id="workOptionsList"></div>
|
||||
<button type="button" onclick="closeWorkModal()" style="width: 100%; padding: 12px; margin-top: 8px; background: var(--gray-100); border: none; border-radius: var(--radius-md); cursor: pointer;">
|
||||
작업 연결 없이 진행
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/load-navbar.js?v=1"></script>
|
||||
<script src="/js/work-issue-report.js?v=1"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,180 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>일일 작업보고서 작성 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/daily-work-report.css?v=11">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="work-report-container">
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="work-report-main">
|
||||
<!-- 탭 메뉴 -->
|
||||
<div class="tab-menu" style="margin-bottom: var(--space-6);">
|
||||
<button class="tab-btn active" id="tbmReportTab" onclick="switchTab('tbm')">
|
||||
작업보고서 작성
|
||||
</button>
|
||||
<button class="tab-btn" id="completedReportTab" onclick="switchTab('completed')">
|
||||
작성 완료 보고서
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 메시지 영역 -->
|
||||
<div id="message-container"></div>
|
||||
|
||||
<!-- TBM 작업보고 섹션 -->
|
||||
<div id="tbmReportSection" class="step-section active">
|
||||
<!-- TBM 작업 목록 -->
|
||||
<div id="tbmWorkList">
|
||||
<!-- TBM 작업 항목들이 여기에 동적으로 추가됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작성 완료 보고서 섹션 -->
|
||||
<div id="completedReportSection" class="step-section" style="display: none;">
|
||||
<!-- 날짜 선택 필터 -->
|
||||
<div class="form-group" style="max-width: 300px; margin-bottom: var(--space-5);">
|
||||
<label for="completedReportDate" class="form-label">조회 날짜</label>
|
||||
<input type="date" id="completedReportDate" class="form-input" onchange="loadCompletedReports()">
|
||||
</div>
|
||||
|
||||
<!-- 완료된 보고서 목록 -->
|
||||
<div id="completedReportsList">
|
||||
<!-- 완료된 보고서들이 여기에 동적으로 추가됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 저장 결과 모달 -->
|
||||
<div id="saveResultModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container result-modal">
|
||||
<div class="modal-header">
|
||||
<h2 id="resultModalTitle">저장 결과</h2>
|
||||
<button class="modal-close-btn" onclick="closeSaveResultModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="resultModalContent" class="result-content">
|
||||
<!-- 결과 내용이 여기에 동적으로 추가됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary" onclick="closeSaveResultModal()">
|
||||
확인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업장소 선택 모달 (지도 기반) -->
|
||||
<div id="workplaceModal" class="modal-overlay" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); z-index: 1002; align-items: center; justify-content: center; overflow-y: auto; padding: 2rem 0;">
|
||||
<div class="modal-container" style="background: white; border-radius: 8px; max-width: 1000px; width: 90%; max-height: none; margin: auto; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); display: flex; flex-direction: column;">
|
||||
<div class="modal-header" style="padding: 1.5rem; border-bottom: 1px solid #e5e7eb; display: flex; justify-content: space-between; align-items: center;">
|
||||
<h2 style="font-size: 1.25rem; font-weight: 600; color: #111827; margin: 0;">
|
||||
<span style="margin-right: 0.5rem;">🗺️</span>작업장소 선택
|
||||
</h2>
|
||||
<button class="modal-close" onclick="closeWorkplaceModal()" style="background: none; border: none; font-size: 1.5rem; cursor: pointer; color: #6b7280; padding: 0; width: 2rem; height: 2rem; display: flex; align-items: center; justify-content: center; border-radius: 4px;">×</button>
|
||||
</div>
|
||||
<div class="modal-body" style="padding: 1.5rem; flex: 1; overflow-y: visible;">
|
||||
<!-- 1단계: 카테고리 선택 -->
|
||||
<div id="categorySelectionArea">
|
||||
<h3 style="font-size: 1rem; font-weight: 600; margin-bottom: 0.75rem; color: #374151;">
|
||||
<span style="margin-right: 0.5rem;">🏭</span>공장 선택
|
||||
</h3>
|
||||
<div id="workplaceCategoryList" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.75rem;">
|
||||
<!-- 카테고리 버튼들 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2단계: 작업장 선택 (지도 + 리스트) -->
|
||||
<div id="workplaceSelectionArea" style="display: none; margin-top: 1.5rem;">
|
||||
<h3 style="font-size: 1rem; font-weight: 600; margin-bottom: 0.75rem; color: #374151;">
|
||||
<span style="margin-right: 0.5rem;">📍</span>
|
||||
<span id="selectedCategoryTitle">작업장 선택</span>
|
||||
</h3>
|
||||
|
||||
<!-- 지도 기반 선택 영역 -->
|
||||
<div id="layoutMapArea" style="display: none; margin-bottom: 1.5rem; padding: 1rem; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 0.5rem;">
|
||||
<div style="font-size: 0.875rem; color: #6b7280; margin-bottom: 0.75rem;">
|
||||
<span style="margin-right: 0.25rem;">🗺️</span>
|
||||
지도에서 작업장을 클릭하여 선택하세요
|
||||
</div>
|
||||
<div style="text-align: center; position: relative; display: inline-block; max-width: 100%;">
|
||||
<canvas id="workplaceMapCanvas" style="max-width: 100%; border-radius: 0.5rem; cursor: pointer; box-shadow: 0 2px 4px rgba(0,0,0,0.1);"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 리스트 선택 영역 -->
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem;">
|
||||
<span style="font-size: 0.875rem; color: #6b7280;">
|
||||
<span style="margin-right: 0.25rem;">📋</span>
|
||||
리스트에서 선택
|
||||
</span>
|
||||
</div>
|
||||
<div id="workplaceListArea" style="display: flex; flex-direction: column; gap: 0.5rem; max-height: 200px; overflow-y: auto; padding: 0.75rem; border: 1px solid #e5e7eb; border-radius: 0.5rem; background: white;">
|
||||
<!-- 작업장소 목록 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 0.75rem; justify-content: flex-end; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid #e5e7eb;">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeWorkplaceModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" id="confirmWorkplaceBtn" onclick="confirmWorkplaceSelection()" disabled>선택 완료</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 시간 선택 팝오버 -->
|
||||
<div id="timePickerOverlay" class="time-picker-overlay" style="display: none;" onclick="closeTimePicker()">
|
||||
<div class="time-picker-popup" onclick="event.stopPropagation()">
|
||||
<div class="time-picker-header">
|
||||
<h3 id="timePickerTitle">작업시간 선택</h3>
|
||||
<button class="time-picker-close" onclick="closeTimePicker()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="quick-time-grid">
|
||||
<button type="button" class="time-btn" onclick="setTimeValue(0.5)">
|
||||
<span class="time-value">30분</span>
|
||||
</button>
|
||||
<button type="button" class="time-btn" onclick="setTimeValue(1)">
|
||||
<span class="time-value">1시간</span>
|
||||
</button>
|
||||
<button type="button" class="time-btn" onclick="setTimeValue(2)">
|
||||
<span class="time-value">2시간</span>
|
||||
</button>
|
||||
<button type="button" class="time-btn" onclick="setTimeValue(4)">
|
||||
<span class="time-value">4시간</span>
|
||||
</button>
|
||||
<button type="button" class="time-btn" onclick="setTimeValue(8)">
|
||||
<span class="time-value">8시간</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="time-adjust-area">
|
||||
<span class="current-time-label">현재:</span>
|
||||
<strong id="currentTimeDisplay" class="current-time-value">0시간</strong>
|
||||
<div class="adjust-buttons">
|
||||
<button type="button" class="adjust-btn" onclick="adjustTime(-0.5)">-30분</button>
|
||||
<button type="button" class="adjust-btn" onclick="adjustTime(0.5)">+30분</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="confirm-btn" onclick="confirmTimeSelection()">확인</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 스크립트 -->
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<script type="module" src="/js/load-navbar.js?v=5"></script>
|
||||
<script type="module" src="/js/daily-work-report.js?v=25"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,294 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>작업 현황 확인 - TK 건설</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/common.css?v=13">
|
||||
<link rel="stylesheet" href="/css/modern-dashboard.css?v=14">
|
||||
<link rel="stylesheet" href="/css/work-report-calendar.css?v=29">
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 헤더 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="dashboard-main">
|
||||
<div class="calendar-page-container">
|
||||
<!-- 페이지 제목 -->
|
||||
<div class="page-title-section">
|
||||
<h2 class="page-title">📅 작업 현황 확인</h2>
|
||||
<p class="page-subtitle">월별 작업자 현황을 한눈에 확인하세요</p>
|
||||
</div>
|
||||
|
||||
<!-- 캘린더 카드 -->
|
||||
<div class="calendar-card">
|
||||
<!-- 월 네비게이션 -->
|
||||
<div class="calendar-nav">
|
||||
<button id="prevMonthBtn" class="nav-btn prev-btn">
|
||||
<span class="nav-icon">‹</span>
|
||||
<span class="nav-text">이전</span>
|
||||
</button>
|
||||
|
||||
<div class="calendar-title">
|
||||
<h3 id="monthYearTitle">2025년 11월</h3>
|
||||
<button id="todayBtn" class="today-btn">오늘</button>
|
||||
</div>
|
||||
|
||||
<button id="nextMonthBtn" class="nav-btn next-btn">
|
||||
<span class="nav-text">다음</span>
|
||||
<span class="nav-icon">›</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 범례 -->
|
||||
<div class="calendar-legend">
|
||||
<div class="legend-item">
|
||||
<div class="legend-dot has-overtime-warning"></div>
|
||||
<span>확인필요</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-dot has-errors"></div>
|
||||
<span>미입력</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-dot has-issues"></div>
|
||||
<span>부분입력</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-dot has-normal"></div>
|
||||
<span>이상 없음</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 캘린더 -->
|
||||
<div class="calendar-grid">
|
||||
<div class="calendar-header">
|
||||
<div class="day-header sunday">일</div>
|
||||
<div class="day-header">월</div>
|
||||
<div class="day-header">화</div>
|
||||
<div class="day-header">수</div>
|
||||
<div class="day-header">목</div>
|
||||
<div class="day-header">금</div>
|
||||
<div class="day-header saturday">토</div>
|
||||
</div>
|
||||
<div class="calendar-days" id="calendarDays">
|
||||
<!-- 캘린더 날짜들이 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 로딩 스피너 -->
|
||||
<div id="loadingSpinner" class="loading-overlay" style="display: none;">
|
||||
<div class="loading-content">
|
||||
<div class="spinner"></div>
|
||||
<p>데이터를 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 일일 작업 현황 모달 -->
|
||||
<div id="dailyWorkModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container large-modal">
|
||||
<div class="modal-header">
|
||||
<h2 id="modalTitle">2025년 11월 3일 작업 현황</h2>
|
||||
<button class="modal-close-btn" onclick="closeDailyWorkModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<!-- 요약 정보 -->
|
||||
<div class="daily-summary">
|
||||
<div class="summary-card">
|
||||
<div class="summary-icon success">👥</div>
|
||||
<div class="summary-content">
|
||||
<div class="summary-label">총 작업자</div>
|
||||
<div class="summary-value" id="modalTotalWorkers">0명</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="summary-icon primary">⏰</div>
|
||||
<div class="summary-content">
|
||||
<div class="summary-label">총 작업시간</div>
|
||||
<div class="summary-value" id="modalTotalHours">0.0h</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="summary-icon warning">📝</div>
|
||||
<div class="summary-content">
|
||||
<div class="summary-label">작업 건수</div>
|
||||
<div class="summary-value" id="modalTotalTasks">0건</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="summary-icon error">⚠️</div>
|
||||
<div class="summary-content">
|
||||
<div class="summary-label">오류 건수</div>
|
||||
<div class="summary-value" id="modalErrorCount">0건</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업자 현황 리스트 -->
|
||||
<div class="modal-work-status">
|
||||
<div class="work-status-header">
|
||||
<h3>작업자별 현황</h3>
|
||||
<div class="status-filter">
|
||||
<select id="statusFilter">
|
||||
<option value="all">전체</option>
|
||||
<option value="incomplete">미입력</option>
|
||||
<option value="partial">부분입력</option>
|
||||
<option value="complete">완료</option>
|
||||
<option value="overtime">연장근로</option>
|
||||
<option value="error">오류</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="modalWorkersList" class="worker-status-list">
|
||||
<!-- 작업자 리스트가 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
|
||||
<div id="modalNoData" class="empty-state" style="display: none;">
|
||||
<div class="empty-icon">📭</div>
|
||||
<h3>해당 날짜의 작업 보고서가 없습니다</h3>
|
||||
<p>다른 날짜를 선택해 주세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업 입력/수정 모달 -->
|
||||
<div id="workEntryModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container large-modal">
|
||||
<div class="modal-header">
|
||||
<h2 id="workEntryModalTitle">작업 관리</h2>
|
||||
<button class="modal-close-btn" onclick="closeWorkEntryModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<!-- 탭 네비게이션 -->
|
||||
<div class="modal-tabs">
|
||||
<button class="tab-btn active" data-tab="existing" onclick="switchTab('existing')">
|
||||
📋 기존 작업 (0건)
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="new" onclick="switchTab('new')">
|
||||
➕ 새 작업 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 기존 작업 목록 탭 -->
|
||||
<div id="existingWorkTab" class="tab-content active">
|
||||
<div class="existing-work-header">
|
||||
<h3>등록된 작업 목록</h3>
|
||||
<div class="work-summary" id="workSummary">
|
||||
총 <span id="totalWorkCount">0</span>건 | 총 <span id="totalWorkHours">0</span>시간
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="existingWorkList" class="existing-work-list">
|
||||
<!-- 기존 작업들이 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
|
||||
<div id="noExistingWork" class="empty-state" style="display: none;">
|
||||
<div class="empty-icon">📝</div>
|
||||
<h3>등록된 작업이 없습니다</h3>
|
||||
<p>"새 작업 추가" 탭에서 작업을 등록해보세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 새 작업 추가 탭 -->
|
||||
<div id="newWorkTab" class="tab-content">
|
||||
<form id="workEntryForm">
|
||||
<!-- 작업자 정보 -->
|
||||
<div class="form-section">
|
||||
<h3>작업자 정보</h3>
|
||||
<div class="form-group">
|
||||
<label class="form-label">작업자</label>
|
||||
<input type="text" id="workerNameDisplay" class="form-control" readonly>
|
||||
<input type="hidden" id="workerId">
|
||||
<input type="hidden" id="editingWorkId">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">작업 날짜</label>
|
||||
<input type="date" id="workDate" class="form-control" readonly>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업 내용 -->
|
||||
<div class="form-section">
|
||||
<h3 id="workContentTitle">작업 내용</h3>
|
||||
<div class="form-group">
|
||||
<label class="form-label">프로젝트 *</label>
|
||||
<select id="projectSelect" class="form-control" required>
|
||||
<option value="">프로젝트를 선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">작업 유형 *</label>
|
||||
<select id="workTypeSelect" class="form-control" required>
|
||||
<option value="">작업 유형을 선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">작업 시간 (시간) *</label>
|
||||
<input type="number" id="workHours" class="form-control" min="0" max="24" step="0.5" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">작업 상태 *</label>
|
||||
<select id="workStatusSelect" class="form-control" required>
|
||||
<option value="">상태를 선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">오류 유형</label>
|
||||
<select id="errorTypeSelect" class="form-control">
|
||||
<option value="">오류 유형 (선택사항)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">작업 설명</label>
|
||||
<textarea id="workDescription" class="form-control" rows="3" placeholder="작업 내용을 상세히 입력하세요"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 휴가 처리 -->
|
||||
<div class="form-section" id="vacationSection">
|
||||
<h3>휴가 처리</h3>
|
||||
<div class="vacation-buttons">
|
||||
<button type="button" class="btn-vacation" onclick="handleVacation('full')">연차 (8시간)</button>
|
||||
<button type="button" class="btn-vacation" onclick="handleVacation('half')">반차 (4시간)</button>
|
||||
<button type="button" class="btn-vacation" onclick="handleVacation('quarter')">반반차 (2시간)</button>
|
||||
<button type="button" class="btn-vacation" onclick="handleVacation('early')">조퇴 (6시간)</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeWorkEntryModal()">취소</button>
|
||||
<div class="footer-actions">
|
||||
<button type="button" class="btn btn-danger" id="deleteWorkBtn" onclick="deleteWork()" style="display: none;">
|
||||
🗑️ 삭제
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" id="saveWorkBtn" onclick="saveWorkEntry()">
|
||||
💾 저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script type="module" src="/js/api-config.js?v=13"></script>
|
||||
<script type="module" src="/js/auth-check.js?v=13"></script>
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script src="/js/modules/calendar/CalendarState.js?v=1"></script>
|
||||
<script src="/js/modules/calendar/CalendarAPI.js?v=1"></script>
|
||||
<script src="/js/modules/calendar/CalendarView.js?v=1"></script>
|
||||
<script src="/js/work-report-calendar.js?v=41"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,650 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>TBM 관리 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/common.css?v=2">
|
||||
<link rel="stylesheet" href="/css/project-management.css?v=3">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<style>
|
||||
.date-group {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.date-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
border-left: 4px solid var(--primary-500);
|
||||
}
|
||||
.date-group-header.today {
|
||||
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
|
||||
border-left-color: #3b82f6;
|
||||
}
|
||||
.date-group-date {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
.date-group-day {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: white;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.date-group-count {
|
||||
margin-left: auto;
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
.date-group-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.date-group-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="work-report-container">
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="work-report-main">
|
||||
<div class="dashboard-main">
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">
|
||||
<span class="title-icon">🛠️</span>
|
||||
TBM (Tool Box Meeting)
|
||||
</h1>
|
||||
<p class="page-description">아침 안전 회의 및 팀 구성 관리</p>
|
||||
</div>
|
||||
|
||||
<div class="page-actions" id="headerActions">
|
||||
<!-- 탭에 따라 동적으로 변경됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TBM 탭 -->
|
||||
<div class="code-tabs">
|
||||
<button class="tab-btn active" data-tab="tbm-input" onclick="switchTbmTab('tbm-input')">
|
||||
<span class="tab-icon">➕</span>
|
||||
TBM 입력
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="tbm-manage" onclick="switchTbmTab('tbm-manage')">
|
||||
<span class="tab-icon">📋</span>
|
||||
TBM 관리
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- TBM 입력 탭 -->
|
||||
<div id="tbm-input-tab" class="code-tab-content active">
|
||||
<div class="code-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">
|
||||
<span class="section-icon">🌅</span>
|
||||
오늘의 TBM
|
||||
</h2>
|
||||
<div class="section-actions">
|
||||
<button class="btn btn-primary" onclick="openNewTbmModal()">
|
||||
<span class="btn-icon">➕</span>
|
||||
새 TBM 시작
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="code-stats">
|
||||
<span class="stat-item">
|
||||
<span class="stat-icon">📋</span>
|
||||
오늘 등록 <span id="todayTotalSessions">0</span>개
|
||||
</span>
|
||||
<span class="stat-item">
|
||||
<span class="stat-icon">✅</span>
|
||||
완료 <span id="todayCompletedSessions">0</span>개
|
||||
</span>
|
||||
<span class="stat-item">
|
||||
<span class="stat-icon">⏳</span>
|
||||
진행중 <span id="todayActiveSessions">0</span>개
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="code-grid" id="todayTbmGrid">
|
||||
<!-- 오늘의 TBM 세션 카드들이 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div class="empty-state" id="todayEmptyState" style="display: none;">
|
||||
<div class="empty-icon">🛠️</div>
|
||||
<h3>오늘 등록된 TBM이 없습니다</h3>
|
||||
<p>"새 TBM 시작" 버튼을 눌러 오늘의 TBM을 시작해보세요.</p>
|
||||
<button class="btn btn-primary" onclick="openNewTbmModal()">
|
||||
➕ 첫 TBM 시작하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TBM 관리 탭 -->
|
||||
<div id="tbm-manage-tab" class="code-tab-content">
|
||||
<div class="code-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">
|
||||
<span class="section-icon">📚</span>
|
||||
TBM 기록
|
||||
</h2>
|
||||
<div class="section-actions">
|
||||
<button class="btn btn-secondary" onclick="loadMoreTbmDays()" id="loadMoreBtn">
|
||||
<span class="btn-icon">📅</span>
|
||||
더 보기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="code-stats">
|
||||
<span class="stat-item">
|
||||
<span class="stat-icon">📋</span>
|
||||
총 <span id="totalSessions">0</span>개
|
||||
</span>
|
||||
<span class="stat-item">
|
||||
<span class="stat-icon">✅</span>
|
||||
완료 <span id="completedSessions">0</span>개
|
||||
</span>
|
||||
<span class="stat-item" id="viewModeIndicator" style="display: none;">
|
||||
<span class="stat-icon">👤</span>
|
||||
<span id="viewModeText">내 TBM만</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 날짜별 그룹 컨테이너 -->
|
||||
<div id="tbmDateGroupsContainer">
|
||||
<!-- 날짜별 TBM 그룹이 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div class="empty-state" id="emptyState" style="display: none;">
|
||||
<div class="empty-icon">🛠️</div>
|
||||
<h3>등록된 TBM 세션이 없습니다</h3>
|
||||
<p>TBM 입력 탭에서 새로운 TBM을 시작해보세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- TBM 생성/수정 모달 -->
|
||||
<div id="tbmModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 1000px;">
|
||||
<div class="modal-header">
|
||||
<h2 id="modalTitle">새 TBM 시작</h2>
|
||||
<button class="modal-close-btn" onclick="closeTbmModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<form id="tbmForm" onsubmit="event.preventDefault(); saveTbmSession();">
|
||||
<input type="hidden" id="sessionId">
|
||||
|
||||
<!-- 고정 정보 섹션 -->
|
||||
<div class="form-section" style="background: #f9fafb; padding: 1rem; border-radius: 0.5rem; margin-bottom: 1.5rem;">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">TBM 날짜 *</label>
|
||||
<input type="date" id="sessionDate" class="form-control" required readonly style="background: #e5e7eb;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">입력자 *</label>
|
||||
<input type="text" id="leaderName" class="form-control" readonly style="background: #e5e7eb;">
|
||||
<input type="hidden" id="leaderId">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업자 및 작업 정보 섹션 -->
|
||||
<div class="form-section">
|
||||
<div class="section-header" style="margin-bottom: 1rem;">
|
||||
<h3 style="font-size: 1.1rem; font-weight: 600; color: #1f2937;">
|
||||
<span style="margin-right: 0.5rem;">👷</span>
|
||||
작업자 및 작업 정보
|
||||
</h3>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<button type="button" class="btn btn-sm btn-secondary" onclick="openBulkSettingModal()" style="display: flex; align-items: center; gap: 0.25rem;">
|
||||
<span>📋</span>
|
||||
일괄 설정
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-primary" onclick="openWorkerSelectionModal()">
|
||||
<span style="font-size: 1.2rem; margin-right: 0.25rem;">+</span>
|
||||
작업자 선택
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업자 카드 리스트 -->
|
||||
<div id="workerTaskList" style="display: flex; flex-direction: column; gap: 0.75rem; min-height: 100px;">
|
||||
<!-- 작업자 카드들이 여기에 동적으로 추가됩니다 -->
|
||||
<div class="empty-state-small" id="workerListEmpty" style="display: flex; align-items: center; justify-content: center; padding: 2rem; border: 2px dashed #d1d5db; border-radius: 0.5rem; color: #6b7280;">
|
||||
<div style="text-align: center;">
|
||||
<div style="font-size: 2rem; margin-bottom: 0.5rem;">👷♂️</div>
|
||||
<p>작업자를 선택해주세요</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeTbmModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveTbmSession()">
|
||||
💾 저장하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 일괄 설정 모달 -->
|
||||
<div id="bulkSettingModal" class="modal-overlay" style="display: none; z-index: 1001;">
|
||||
<div class="modal-container" style="max-width: 700px;">
|
||||
<div class="modal-header">
|
||||
<h2>일괄 설정</h2>
|
||||
<button class="modal-close-btn" onclick="closeBulkSettingModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div style="background: #dbeafe; border: 1px solid #3b82f6; padding: 1rem; border-radius: 0.5rem; margin-bottom: 1.5rem;">
|
||||
<div style="font-weight: 600; color: #1e40af; margin-bottom: 0.25rem;">💡 일괄 설정</div>
|
||||
<div style="color: #1e40af; font-size: 0.9rem;">
|
||||
선택한 작업자들의 <strong>첫 번째 작업 라인</strong>에 동일한 정보가 적용됩니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업자 선택 -->
|
||||
<div class="form-group">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem;">
|
||||
<label class="form-label" style="margin-bottom: 0;">적용할 작업자 선택 *</label>
|
||||
<div style="display: flex; gap: 0.25rem;">
|
||||
<button type="button" class="btn btn-sm btn-secondary" onclick="selectAllForBulk()" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;">전체</button>
|
||||
<button type="button" class="btn btn-sm btn-secondary" onclick="deselectAllForBulk()" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;">해제</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="bulkWorkerSelection" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 0.5rem; padding: 0.75rem; border: 1px solid #e5e7eb; border-radius: 0.5rem; max-height: 200px; overflow-y: auto; background: #f9fafb;">
|
||||
<!-- 작업자 체크박스들이 여기에 생성됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="border-top: 1px solid #e5e7eb; margin: 1.5rem 0; padding-top: 1.5rem;">
|
||||
<h4 style="font-size: 0.95rem; font-weight: 600; margin-bottom: 1rem; color: #374151;">적용할 작업 정보</h4>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">프로젝트</label>
|
||||
<button type="button" id="bulkProjectBtn" onclick="openBulkItemSelect('project')" class="btn btn-secondary" style="width: 100%; text-align: left; justify-content: flex-start;">
|
||||
📁 프로젝트 선택
|
||||
</button>
|
||||
<input type="hidden" id="bulkProjectId">
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">공정 *</label>
|
||||
<button type="button" id="bulkWorkTypeBtn" onclick="openBulkItemSelect('workType')" class="btn btn-secondary" style="width: 100%; text-align: left; justify-content: flex-start;">
|
||||
⚙️ 공정 선택
|
||||
</button>
|
||||
<input type="hidden" id="bulkWorkTypeId">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">작업 *</label>
|
||||
<button type="button" id="bulkTaskBtn" onclick="openBulkItemSelect('task')" class="btn btn-secondary" style="width: 100%; text-align: left; justify-content: flex-start;" disabled>
|
||||
🔧 작업 선택
|
||||
</button>
|
||||
<input type="hidden" id="bulkTaskId">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">작업장 *</label>
|
||||
<button type="button" id="bulkWorkplaceBtn" onclick="openBulkWorkplaceSelect()" class="btn btn-secondary" style="width: 100%; text-align: left; justify-content: flex-start;">
|
||||
📍 작업장 선택
|
||||
</button>
|
||||
<input type="hidden" id="bulkWorkplaceCategoryId">
|
||||
<input type="hidden" id="bulkWorkplaceId">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeBulkSettingModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" onclick="applyBulkSettings()">
|
||||
✓ 선택한 작업자에 적용
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업자 선택 모달 -->
|
||||
<div id="workerSelectionModal" class="modal-overlay" style="display: none; z-index: 1001;">
|
||||
<div class="modal-container" style="max-width: 800px;">
|
||||
<div class="modal-header">
|
||||
<h2>작업자 선택</h2>
|
||||
<button class="modal-close-btn" onclick="closeWorkerSelectionModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div style="margin-bottom: 1rem; display: flex; gap: 0.5rem;">
|
||||
<button type="button" class="btn btn-sm btn-secondary" onclick="selectAllWorkersInModal()">전체 선택</button>
|
||||
<button type="button" class="btn btn-sm btn-secondary" onclick="deselectAllWorkersInModal()">전체 해제</button>
|
||||
</div>
|
||||
|
||||
<div id="workerCardGrid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.75rem; max-height: 500px; overflow-y: auto; padding: 0.5rem;">
|
||||
<!-- 작업자 카드들이 여기에 생성됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeWorkerSelectionModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" onclick="confirmWorkerSelection()">
|
||||
✓ 선택 완료
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 항목 선택 모달 (프로젝트/공정/작업 선택용) -->
|
||||
<div id="itemSelectModal" class="modal-overlay" style="display: none; z-index: 1002;">
|
||||
<div class="modal-container" style="max-width: 600px;">
|
||||
<div class="modal-header">
|
||||
<h2 id="itemSelectModalTitle">항목 선택</h2>
|
||||
<button class="modal-close-btn" onclick="closeItemSelectModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div id="itemSelectList" style="display: flex; flex-direction: column; gap: 0.5rem; max-height: 400px; overflow-y: auto; padding: 0.5rem;">
|
||||
<!-- 선택 항목들이 여기에 생성됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeItemSelectModal()">취소</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업장 선택 모달 (2단계: 공장 → 작업장) -->
|
||||
<div id="workplaceSelectModal" class="modal-overlay" style="display: none; z-index: 1002;">
|
||||
<div class="modal-container" style="max-width: 1000px; max-height: 90vh;">
|
||||
<div class="modal-header">
|
||||
<h2>작업장 선택</h2>
|
||||
<button class="modal-close-btn" onclick="closeWorkplaceSelectModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body" style="overflow-y: auto;">
|
||||
<!-- 1단계: 공장 선택 -->
|
||||
<div style="margin-bottom: 1.5rem;">
|
||||
<h3 style="font-size: 1rem; font-weight: 600; margin-bottom: 0.75rem; color: #374151;">
|
||||
<span style="margin-right: 0.5rem;">🏭</span>
|
||||
1. 공장 선택
|
||||
</h3>
|
||||
<div id="categoryList" style="display: flex; flex-wrap: wrap; gap: 0.5rem; padding: 0.75rem; border: 1px solid #e5e7eb; border-radius: 0.5rem; background: #f9fafb;">
|
||||
<!-- 공장 카테고리 버튼들이 여기에 생성됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2단계: 작업장 선택 (지도 + 리스트) -->
|
||||
<div id="workplaceSelectionArea" style="display: none;">
|
||||
<h3 style="font-size: 1rem; font-weight: 600; margin-bottom: 0.75rem; color: #374151;">
|
||||
<span style="margin-right: 0.5rem;">📍</span>
|
||||
2. 작업장 선택
|
||||
</h3>
|
||||
|
||||
<!-- 지도 기반 선택 영역 -->
|
||||
<div id="layoutMapArea" style="display: none; margin-bottom: 1.5rem; padding: 1rem; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 0.5rem;">
|
||||
<div style="font-size: 0.875rem; color: #6b7280; margin-bottom: 0.75rem;">
|
||||
<span style="margin-right: 0.25rem;">🗺️</span>
|
||||
지도에서 작업장을 클릭하여 선택하세요
|
||||
</div>
|
||||
<div style="text-align: center; position: relative; display: inline-block; max-width: 100%;">
|
||||
<canvas id="workplaceMapCanvas" style="max-width: 100%; border-radius: 0.5rem; cursor: pointer; box-shadow: 0 2px 4px rgba(0,0,0,0.1);"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 리스트 기반 선택 (오류 대비용) -->
|
||||
<div>
|
||||
<div style="font-size: 0.875rem; color: #6b7280; margin-bottom: 0.75rem; display: flex; align-items: center; justify-content: space-between;">
|
||||
<span>
|
||||
<span style="margin-right: 0.25rem;">📋</span>
|
||||
리스트에서 선택 (지도 오류 시)
|
||||
</span>
|
||||
<button type="button" class="btn btn-sm btn-secondary" onclick="toggleWorkplaceList()" id="toggleListBtn">
|
||||
<span id="toggleListIcon">▼</span>
|
||||
리스트 보기
|
||||
</button>
|
||||
</div>
|
||||
<div id="workplaceList" style="display: none; flex-direction: column; gap: 0.5rem; max-height: 300px; overflow-y: auto; padding: 0.75rem; border: 1px solid #e5e7eb; border-radius: 0.5rem; background: white;">
|
||||
<div style="color: #9ca3af; text-align: center; padding: 2rem;">
|
||||
공장을 먼저 선택해주세요
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeWorkplaceSelectModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" onclick="confirmWorkplaceSelection()" id="confirmWorkplaceBtn" disabled>
|
||||
✓ 선택 완료
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 팀 구성 모달 -->
|
||||
<div id="teamModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 900px;">
|
||||
<div class="modal-header">
|
||||
<h2>팀 구성</h2>
|
||||
<button class="modal-close-btn" onclick="closeTeamModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="section-header" style="margin-bottom: 1rem;">
|
||||
<h3 style="font-size: 1rem; font-weight: 600;">작업자 선택</h3>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-secondary" onclick="selectAllWorkers()">전체 선택</button>
|
||||
<button class="btn btn-sm btn-secondary" onclick="deselectAllWorkers()">전체 해제</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="workerSelectionGrid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.75rem; max-height: 400px; overflow-y: auto; padding: 0.5rem; border: 1px solid #e5e7eb; border-radius: 0.5rem;">
|
||||
<!-- 작업자 체크박스 목록이 여기에 생성됩니다 -->
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 1.5rem;">
|
||||
<h3 style="font-size: 1rem; font-weight: 600; margin-bottom: 0.75rem;">선택된 팀원 <span id="selectedCount">0</span>명</h3>
|
||||
<div id="selectedWorkersList" style="display: flex; flex-wrap: wrap; gap: 0.5rem; min-height: 50px; padding: 0.75rem; background: #f9fafb; border-radius: 0.5rem;">
|
||||
<p style="margin: 0; color: #9ca3af; font-size: 0.875rem;">작업자를 선택해주세요</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeTeamModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveTeamComposition()">
|
||||
👥 팀 구성 완료
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 안전 체크리스트 모달 -->
|
||||
<div id="safetyModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 700px;">
|
||||
<div class="modal-header">
|
||||
<h2>안전 체크리스트</h2>
|
||||
<button class="modal-close-btn" onclick="closeSafetyModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div id="safetyChecklistContainer" style="max-height: 500px; overflow-y: auto;">
|
||||
<!-- 안전 체크리스트가 여기에 생성됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeSafetyModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveSafetyChecklist()">
|
||||
✅ 안전 체크 완료
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TBM 완료 모달 -->
|
||||
<div id="completeModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 500px;">
|
||||
<div class="modal-header">
|
||||
<h2>TBM 완료</h2>
|
||||
<button class="modal-close-btn" onclick="closeCompleteModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<p style="margin-bottom: 1rem;">이 TBM 세션을 완료 처리하시겠습니까?</p>
|
||||
<p style="color: #6b7280; font-size: 0.875rem;">완료 후에는 수정할 수 없습니다.</p>
|
||||
|
||||
<div class="form-group" style="margin-top: 1.5rem;">
|
||||
<label class="form-label">종료 시간</label>
|
||||
<input type="time" id="endTime" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeCompleteModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" onclick="completeTbmSession()">
|
||||
✅ 완료
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업 인계 모달 -->
|
||||
<div id="handoverModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 600px;">
|
||||
<div class="modal-header">
|
||||
<h2>작업 인계</h2>
|
||||
<button class="modal-close-btn" onclick="closeHandoverModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<form id="handoverForm">
|
||||
<input type="hidden" id="handoverSessionId">
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">인계 사유 *</label>
|
||||
<select id="handoverReason" class="form-control" required>
|
||||
<option value="">사유 선택...</option>
|
||||
<option value="half_day">반차</option>
|
||||
<option value="early_leave">조퇴</option>
|
||||
<option value="emergency">긴급 상황</option>
|
||||
<option value="other">기타</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">인수자 (다음 팀장) *</label>
|
||||
<select id="toLeaderId" class="form-control" required>
|
||||
<option value="">인수자 선택...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">인계 날짜 *</label>
|
||||
<input type="date" id="handoverDate" class="form-control" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">인계 시간</label>
|
||||
<input type="time" id="handoverTime" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">인계 내용</label>
|
||||
<textarea id="handoverNotes" class="form-control" rows="4" placeholder="인수자에게 전달할 내용을 입력하세요"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" style="margin-bottom: 0.75rem; display: block;">인계할 팀원 선택</label>
|
||||
<div id="handoverTeamList" style="max-height: 200px; overflow-y: auto; border: 1px solid #e5e7eb; border-radius: 0.5rem; padding: 0.5rem;">
|
||||
<!-- 팀원 체크박스 목록 -->
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeHandoverModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveHandover()">
|
||||
📤 인계 요청
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TBM 상세보기 모달 -->
|
||||
<div id="detailModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 900px;">
|
||||
<div class="modal-header">
|
||||
<h2>TBM 상세 정보</h2>
|
||||
<button class="modal-close-btn" onclick="closeDetailModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body" style="max-height: 70vh; overflow-y: auto;">
|
||||
<!-- 세션 기본 정보 -->
|
||||
<div class="section" style="margin-bottom: 1.5rem;">
|
||||
<h3 style="font-size: 1rem; font-weight: 600; margin-bottom: 1rem; color: #374151;">기본 정보</h3>
|
||||
<div id="detailBasicInfo" style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.75rem;">
|
||||
<!-- 동적 생성 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 팀 구성 -->
|
||||
<div class="section" style="margin-bottom: 1.5rem;">
|
||||
<h3 style="font-size: 1rem; font-weight: 600; margin-bottom: 1rem; color: #374151;">팀 구성</h3>
|
||||
<div id="detailTeamMembers" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.75rem;">
|
||||
<!-- 동적 생성 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 안전 체크 -->
|
||||
<div class="section">
|
||||
<h3 style="font-size: 1rem; font-weight: 600; margin-bottom: 1rem; color: #374151;">안전 체크리스트</h3>
|
||||
<div id="detailSafetyChecks">
|
||||
<!-- 동적 생성 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeDetailModal()">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 토스트 알림 -->
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js?v=5"></script>
|
||||
<script type="module" src="/js/tbm.js?v=3"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,371 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>출입 신청 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/common.css?v=2">
|
||||
<link rel="stylesheet" href="/css/project-management.css?v=3">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<style>
|
||||
.visit-form-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: var(--gray-700);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--gray-300);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-500);
|
||||
box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.1);
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
min-height: 100px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.workplace-selection {
|
||||
padding: 20px;
|
||||
background: var(--gray-50);
|
||||
border-radius: var(--radius-md);
|
||||
border: 2px dashed var(--gray-300);
|
||||
min-height: 150px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.workplace-selection:hover {
|
||||
border-color: var(--primary-500);
|
||||
background: var(--primary-50);
|
||||
}
|
||||
|
||||
.workplace-selection.selected {
|
||||
border-color: var(--primary-500);
|
||||
border-style: solid;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.workplace-selection .icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.workplace-selection .text {
|
||||
font-size: var(--text-base);
|
||||
color: var(--gray-600);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.workplace-selection.selected .text {
|
||||
color: var(--primary-600);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
/* 지도 모달 스타일 */
|
||||
.map-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.map-modal-content {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
overflow: auto;
|
||||
padding: 32px;
|
||||
box-shadow: var(--shadow-2xl);
|
||||
}
|
||||
|
||||
.map-canvas-container {
|
||||
position: relative;
|
||||
margin-top: 20px;
|
||||
border: 2px solid var(--gray-300);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.map-canvas {
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.workplace-info-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: var(--primary-50);
|
||||
border: 2px solid var(--primary-200);
|
||||
border-radius: var(--radius-md);
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.workplace-info-card .icon {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.workplace-info-card .details {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.workplace-info-card .name {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
color: var(--primary-700);
|
||||
}
|
||||
|
||||
.workplace-info-card .category {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-600);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.my-requests-section {
|
||||
margin-top: 48px;
|
||||
}
|
||||
|
||||
.request-card {
|
||||
padding: 20px;
|
||||
background: white;
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 16px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.request-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.request-status {
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.request-status.pending {
|
||||
background: var(--yellow-100);
|
||||
color: var(--yellow-700);
|
||||
}
|
||||
|
||||
.request-status.approved {
|
||||
background: var(--green-100);
|
||||
color: var(--green-700);
|
||||
}
|
||||
|
||||
.request-status.rejected {
|
||||
background: var(--red-100);
|
||||
color: var(--red-700);
|
||||
}
|
||||
|
||||
.request-status.training_completed {
|
||||
background: var(--blue-100);
|
||||
color: var(--blue-700);
|
||||
}
|
||||
|
||||
.request-info {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
color: var(--gray-700);
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-500);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="work-report-container">
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="work-report-main">
|
||||
<div class="dashboard-main">
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">출입 신청</h1>
|
||||
<p class="page-description">작업장 출입 및 안전교육 신청</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 출입 신청 폼 -->
|
||||
<div class="visit-form-container">
|
||||
<div class="code-section">
|
||||
<h2 class="section-title">출입 정보 입력</h2>
|
||||
|
||||
<form id="visitRequestForm">
|
||||
<!-- 방문자 정보 -->
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="visitorCompany">방문자 소속 *</label>
|
||||
<input type="text" id="visitorCompany" placeholder="예: (주)협력업체, 일용직 등" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="visitorCount">방문 인원 *</label>
|
||||
<input type="number" id="visitorCount" value="1" min="1" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업장 선택 -->
|
||||
<div class="form-group">
|
||||
<label>방문 작업장 *</label>
|
||||
<div id="workplaceSelection" class="workplace-selection" onclick="openMapModal()">
|
||||
<div class="icon">📍</div>
|
||||
<div class="text">지도에서 작업장을 선택하세요</div>
|
||||
</div>
|
||||
<div id="selectedWorkplaceInfo" style="display: none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- 방문 일시 -->
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="visitDate">방문 날짜 *</label>
|
||||
<input type="date" id="visitDate" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="visitTime">방문 시간 *</label>
|
||||
<input type="time" id="visitTime" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 방문 목적 -->
|
||||
<div class="form-group">
|
||||
<label for="visitPurpose">방문 목적 *</label>
|
||||
<select id="visitPurpose" required>
|
||||
<option value="">선택하세요</option>
|
||||
<!-- 동적으로 로드됨 -->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 비고 -->
|
||||
<div class="form-group">
|
||||
<label for="notes">비고 (선택)</label>
|
||||
<textarea id="notes" placeholder="추가 전달 사항이 있다면 입력하세요"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- 버튼 -->
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="resetForm()">초기화</button>
|
||||
<button type="submit" class="btn btn-primary">출입 신청 및 안전교육 신청</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 내 신청 목록 -->
|
||||
<div class="my-requests-section">
|
||||
<div class="code-section">
|
||||
<h2 class="section-title">내 출입 신청 현황</h2>
|
||||
<div id="myRequestsList">
|
||||
<!-- 동적으로 로드됨 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 작업장 지도 모달 -->
|
||||
<div id="mapModal" class="map-modal">
|
||||
<div class="map-modal-content">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px;">
|
||||
<h2 style="margin: 0;">작업장 선택</h2>
|
||||
<button class="btn btn-secondary" onclick="closeMapModal()">닫기</button>
|
||||
</div>
|
||||
|
||||
<!-- 구역(공장) 선택 -->
|
||||
<div class="form-group">
|
||||
<label for="categorySelect">구역(공장) 선택</label>
|
||||
<select id="categorySelect" onchange="loadWorkplaceMap()">
|
||||
<option value="">구역을 선택하세요</option>
|
||||
<!-- 동적으로 로드됨 -->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 지도 캔버스 -->
|
||||
<div id="mapCanvasContainer" style="display: none;">
|
||||
<div class="map-canvas-container">
|
||||
<canvas id="workplaceMapCanvas" class="map-canvas"></canvas>
|
||||
</div>
|
||||
<p style="margin-top: 12px; color: var(--gray-600); font-size: var(--text-sm);">
|
||||
지도에서 방문할 작업장을 클릭하세요
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script type="module" src="/js/load-navbar.js?v=5"></script>
|
||||
<script src="/js/visit-request.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user