feat: 대시보드 작업장 현황 지도 구현

- 실시간 작업장 현황을 지도로 시각화
- 작업장 관리 페이지에서 정의한 구역 정보 활용
- TBM 작업자 및 방문자 현황 표시

주요 변경사항:
- dashboard.html: 작업장 현황 섹션 추가 (기존 작업 현황 테이블 제거)
- workplace-status.js: 지도 렌더링 및 데이터 통합 로직 구현
- modern-dashboard.js: 삭제된 DOM 요소 조건부 체크 추가

시각화 방식:
- 인원 없음: 회색 테두리 + 작업장 이름
- 내부 작업자: 파란색 영역 + 인원 수
- 외부 방문자: 보라색 영역 + 인원 수
- 둘 다: 초록색 영역 + 총 인원 수

기술 구현:
- Canvas API 기반 사각형 영역 렌더링
- map-regions API를 통한 데이터 일관성 보장
- 클릭 이벤트로 상세 정보 모달 표시

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-01-29 15:46:47 +09:00
parent e1227a69fe
commit b6485e3140
87 changed files with 17509 additions and 698 deletions

View File

@@ -80,7 +80,18 @@ async function loadIncompleteTbms() {
throw new Error(response.message || '미완료 TBM 조회 실패');
}
incompleteTbms = response.data || [];
let data = response.data || [];
// 사용자 권한 확인 및 필터링
const user = getUser();
if (user && user.role !== 'Admin' && user.access_level !== 'system') {
// 일반 사용자: 자신이 생성한 세션만 표시
const userId = user.user_id;
data = data.filter(tbm => tbm.created_by === userId);
}
// 관리자는 모든 데이터 표시
incompleteTbms = data;
renderTbmWorkList();
} catch (error) {
console.error('미완료 TBM 로드 오류:', error);
@@ -88,6 +99,14 @@ async function loadIncompleteTbms() {
}
}
/**
* 사용자 정보 가져오기 (auth-check.js와 동일한 로직)
*/
function getUser() {
const user = localStorage.getItem('user');
return user ? JSON.parse(user) : null;
}
/**
* TBM 작업 목록 렌더링 (세션별 그룹화)
*/
@@ -120,11 +139,42 @@ function renderTbmWorkList() {
</div>
`;
// 수동 입력 섹션 먼저 추가 (맨 위)
html += `
<div class="tbm-session-group manual-input-section">
<div class="tbm-session-header" style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);">
<span class="tbm-session-badge" style="background-color: #92400e; color: white;">수동 입력</span>
<span class="tbm-session-info" style="color: white; font-weight: 500;">TBM에 없는 작업을 추가로 입력할 수 있습니다</span>
</div>
<div class="tbm-table-container">
<table class="tbm-work-table">
<thead>
<tr>
<th>작업자</th>
<th>날짜</th>
<th>프로젝트</th>
<th>공정</th>
<th>작업</th>
<th>작업장소</th>
<th>작업시간<br>(시간)</th>
<th>부적합<br>(시간)</th>
<th>부적합 원인</th>
<th>제출</th>
</tr>
</thead>
<tbody id="manualWorkTableBody">
<!-- 수동 입력 행들이 여기에 추가됩니다 -->
</tbody>
</table>
</div>
</div>
`;
// 각 TBM 세션별로 테이블 생성
Object.keys(groupedTbms).forEach(key => {
const group = groupedTbms[key];
html += `
<div class="tbm-session-group">
<div class="tbm-session-group" data-session-key="${key}">
<div class="tbm-session-header">
<span class="tbm-session-badge">TBM 세션</span>
<span class="tbm-session-date">${formatDate(group.session_date)}</span>
@@ -150,7 +200,7 @@ function renderTbmWorkList() {
${group.items.map(tbm => {
const index = tbm.originalIndex;
return `
<tr data-index="${index}" data-type="tbm">
<tr data-index="${index}" data-type="tbm" data-session-key="${key}">
<td>
<div class="worker-cell">
<strong>${tbm.worker_name || '작업자'}</strong>
@@ -202,41 +252,17 @@ function renderTbmWorkList() {
</tbody>
</table>
</div>
<div class="batch-submit-container">
<button type="button"
class="btn-batch-submit"
onclick="batchSubmitTbmSession('${key}')">
📤 이 세션 일괄제출 (${group.items.length}건)
</button>
</div>
</div>
`;
});
// 수동 입력 섹션 추가
html += `
<div class="tbm-session-group" style="margin-top: 2rem;">
<div class="tbm-session-header" style="background-color: #fef3c7;">
<span class="tbm-session-badge" style="background-color: #f59e0b;">수동 입력</span>
<span class="tbm-session-info">TBM에 없는 작업을 추가로 입력할 수 있습니다</span>
</div>
<div class="tbm-table-container">
<table class="tbm-work-table">
<thead>
<tr>
<th>작업자</th>
<th>날짜</th>
<th>프로젝트</th>
<th>공정</th>
<th>작업</th>
<th>작업장소</th>
<th>작업시간<br>(시간)</th>
<th>부적합<br>(시간)</th>
<th>부적합 원인</th>
<th>제출</th>
</tr>
</thead>
<tbody id="manualWorkTableBody">
<!-- 수동 입력 행들이 여기에 추가됩니다 -->
</tbody>
</table>
</div>
</div>
`;
container.innerHTML = html;
}
@@ -286,13 +312,20 @@ window.submitTbmWorkReport = async function(index) {
return;
}
// 날짜를 YYYY-MM-DD 형식으로 변환
const reportDate = tbm.session_date instanceof Date
? tbm.session_date.toISOString().split('T')[0]
: (typeof tbm.session_date === 'string' && tbm.session_date.includes('T')
? tbm.session_date.split('T')[0]
: tbm.session_date);
const reportData = {
tbm_assignment_id: tbm.assignment_id,
tbm_session_id: tbm.session_id,
worker_id: tbm.worker_id,
project_id: tbm.project_id,
work_type_id: tbm.work_type_id,
report_date: tbm.session_date,
report_date: reportDate,
start_time: null,
end_time: null,
total_hours: totalHours,
@@ -301,6 +334,9 @@ window.submitTbmWorkReport = async function(index) {
work_status_id: errorHours > 0 ? 2 : 1
};
console.log('🔍 TBM 제출 데이터:', JSON.stringify(reportData, null, 2));
console.log('🔍 tbm 객체:', tbm);
try {
const response = await window.apiCall('/daily-work-reports/from-tbm', 'POST', reportData);
@@ -325,6 +361,155 @@ window.submitTbmWorkReport = async function(index) {
}
};
/**
* TBM 세션 일괄제출
*/
window.batchSubmitTbmSession = async function(sessionKey) {
// 해당 세션의 모든 항목 가져오기
const sessionRows = document.querySelectorAll(`tr[data-session-key="${sessionKey}"]`);
if (sessionRows.length === 0) {
showMessage('제출할 항목이 없습니다.', 'error');
return;
}
// 1단계: 모든 항목 검증
const validationErrors = [];
const itemsToSubmit = [];
sessionRows.forEach((row, rowIndex) => {
const index = parseInt(row.getAttribute('data-index'));
const tbm = incompleteTbms[index];
const totalHours = parseFloat(document.getElementById(`totalHours_${index}`)?.value);
const errorHours = parseFloat(document.getElementById(`errorHours_${index}`)?.value) || 0;
const errorTypeId = document.getElementById(`errorType_${index}`)?.value;
// 검증
if (!totalHours || totalHours <= 0) {
validationErrors.push(`${tbm.worker_name}: 작업시간 미입력`);
return;
}
if (errorHours > totalHours) {
validationErrors.push(`${tbm.worker_name}: 부적합 시간이 총 작업시간 초과`);
return;
}
if (errorHours > 0 && !errorTypeId) {
validationErrors.push(`${tbm.worker_name}: 부적합 원인 미선택`);
return;
}
// 검증 통과한 항목 저장
const reportDate = tbm.session_date instanceof Date
? tbm.session_date.toISOString().split('T')[0]
: (typeof tbm.session_date === 'string' && tbm.session_date.includes('T')
? tbm.session_date.split('T')[0]
: tbm.session_date);
itemsToSubmit.push({
index,
tbm,
data: {
tbm_assignment_id: tbm.assignment_id,
tbm_session_id: tbm.session_id,
worker_id: tbm.worker_id,
project_id: tbm.project_id,
work_type_id: tbm.work_type_id,
report_date: reportDate,
start_time: null,
end_time: null,
total_hours: totalHours,
error_hours: errorHours,
error_type_id: errorTypeId || null,
work_status_id: errorHours > 0 ? 2 : 1
}
});
});
// 검증 실패가 하나라도 있으면 전체 중단
if (validationErrors.length > 0) {
showSaveResultModal(
'error',
'일괄제출 검증 실패',
'모든 항목이 유효해야 제출할 수 있습니다.',
validationErrors
);
return;
}
// 2단계: 모든 항목 제출
const submitBtn = event.target;
submitBtn.disabled = true;
submitBtn.textContent = '제출 중...';
const results = {
success: [],
failed: []
};
try {
for (const item of itemsToSubmit) {
try {
const response = await window.apiCall('/daily-work-reports/from-tbm', 'POST', item.data);
if (response.success) {
results.success.push(item.tbm.worker_name);
} else {
results.failed.push(`${item.tbm.worker_name}: ${response.message}`);
}
} catch (error) {
results.failed.push(`${item.tbm.worker_name}: ${error.message}`);
}
}
// 결과 표시
const totalCount = itemsToSubmit.length;
const successCount = results.success.length;
const failedCount = results.failed.length;
if (failedCount === 0) {
// 모두 성공
showSaveResultModal(
'success',
'일괄제출 완료',
`${totalCount}건의 작업보고서가 모두 성공적으로 제출되었습니다.`,
results.success.map(name => `${name}`)
);
} else if (successCount === 0) {
// 모두 실패
showSaveResultModal(
'error',
'일괄제출 실패',
`${totalCount}건의 작업보고서가 모두 실패했습니다.`,
results.failed.map(msg => `${msg}`)
);
} else {
// 일부 성공, 일부 실패
const details = [
...results.success.map(name => `${name} - 성공`),
...results.failed.map(msg => `${msg}`)
];
showSaveResultModal(
'warning',
'일괄제출 부분 완료',
`성공: ${successCount}건 / 실패: ${failedCount}`,
details
);
}
// 목록 새로고침
await loadIncompleteTbms();
} catch (error) {
console.error('일괄제출 오류:', error);
showSaveResultModal('error', '일괄제출 오류', error.message);
} finally {
submitBtn.disabled = false;
submitBtn.textContent = `📤 이 세션 일괄제출 (${sessionRows.length}건)`;
}
};
/**
* 수동 작업 추가
*/
@@ -1075,15 +1260,23 @@ function showSaveResultModal(type, title, message, details = null) {
`;
// 상세 정보가 있으면 추가
if (details && details.length > 0) {
content += `
<div class="result-details">
<h4>상세 정보:</h4>
<ul>
${details.map(detail => `<li>${detail}</li>`).join('')}
</ul>
</div>
`;
if (details) {
if (Array.isArray(details) && details.length > 0) {
content += `
<div class="result-details">
<h4>상세 정보:</h4>
<ul>
${details.map(detail => `<li>${detail}</li>`).join('')}
</ul>
</div>
`;
} else if (typeof details === 'string') {
content += `
<div class="result-details">
<p>${details}</p>
</div>
`;
}
}
titleElement.textContent = '저장 결과';
@@ -1114,6 +1307,9 @@ function closeSaveResultModal() {
document.removeEventListener('keydown', closeSaveResultModal);
}
// 전역에서 접근 가능하도록 window에 할당
window.closeSaveResultModal = closeSaveResultModal;
// 단계 이동
function goToStep(stepNumber) {
for (let i = 1; i <= 3; i++) {