feat: TBM 시스템 완성 - 작업 인계, 상세보기, 작업보고서 연동
## 주요 기능 추가 ### 1. 작업 인계 시스템 (반차/조퇴 시) - **인계 모달** (`handoverModal`) - 인계 사유 선택 (반차/조퇴/긴급/기타) - 인수자 (다른 팀장) 선택 - 인계 날짜/시간 입력 - 인계할 팀원 선택 (체크박스) - 인계 내용 메모 - **API 연동** - POST /api/tbm/handovers (인계 요청 생성) - 세션 정보와 팀 구성 자동 조회 - from_leader_id 자동 설정 - **UI 개선** - TBM 카드에 "📤 인계" 버튼 추가 - 인계할 팀원 목록 자동 로드 - 현재 팀장 제외한 리더만 표시 ### 2. TBM 상세보기 모달 - **상세 정보 표시** (`detailModal`) - 기본 정보 (팀장, 날짜, 프로젝트, 작업 장소, 작업 내용) - 안전 특이사항 (노란색 강조) - 팀 구성 (그리드 레이아웃) - 안전 체크리스트 (카테고리별 그룹화) - **안전 체크 시각화** - ✅/❌ 아이콘으로 체크 상태 표시 - 체크됨: 초록색 배경 - 미체크: 빨간색 배경 - 카테고리별 구분 (PPE/EQUIPMENT/ENVIRONMENT/EMERGENCY) - **병렬 API 호출** - Promise.all로 세션/팀/안전체크 동시 조회 - 로딩 성능 최적화 ### 3. 작업 보고서와 TBM 연동 - **TBM 팀 구성 자동 불러오기** - `loadTbmTeamForDate()` 함수 추가 - 선택한 날짜의 TBM 세션 자동 조회 - 진행중(draft) 세션 우선 선택 - 팀 구성 정보 자동 로드 - **작업자 자동 선택** - TBM에서 구성한 팀원 자동 선택 - 선택된 작업자 시각적 표시 (.selected 클래스) - 다음 단계 버튼 자동 활성화 - **안내 메시지** - "🛠️ TBM 팀 구성 자동 적용" 알림 - 자동 선택된 팀원 수 표시 - 파란색 강조 스타일 ### 4. UI/UX 개선 - TBM 카드 버튼 레이아웃 개선 (flex-wrap) - 인계 버튼 오렌지색 (#f59e0b) - 모달 스크롤 가능 (max-height: 70vh) - 반응형 그리드 (auto-fill, minmax) ## 기술 구현 ### 함수 추가 - `viewTbmSession()`: 상세보기 (병렬 API 호출) - `openHandoverModal()`: 인계 모달 (팀 구성 자동 로드) - `saveHandover()`: 인계 저장 (worker_ids JSON array) - `loadTbmTeamForDate()`: TBM 팀 구성 조회 - `closeDetailModal()`, `closeHandoverModal()`: 모달 닫기 ### 수정 함수 - `populateWorkerGrid()`: TBM 연동 추가 (async/await) - `displayTbmSessions()`: 인계 버튼 추가 ## 파일 변경사항 - web-ui/pages/work/tbm.html (모달 2개 추가, 약 110줄) - web-ui/js/tbm.js (함수 추가, 약 250줄 증가) - web-ui/js/daily-work-report.js (TBM 연동, 약 60줄 추가) ## 사용 시나리오 ### 시나리오 1: TBM → 작업보고서 1. 아침 TBM에서 팀 구성 (예: 5명 선택) 2. 작업 보고서 작성 시 날짜 선택 3. **자동으로 5명 선택됨** ✨ 4. 바로 작업 내역 입력 가능 ### 시나리오 2: 조퇴 시 인계 1. TBM 카드에서 "📤 인계" 클릭 2. 사유 선택 (조퇴), 인수자 선택 3. 인계할 팀원 선택 (기본 전체 선택) 4. 인계 요청 → DB 저장 ### 시나리오 3: TBM 상세 확인 1. TBM 카드 클릭 2. 기본 정보, 팀 구성, 안전 체크 한눈에 확인 3. 안전 체크 완료 여부 시각적 확인 ## 데이터 흐름 ``` TBM 시작 ↓ 팀 구성 저장 (tbm_team_assignments) ↓ 작업 보고서 작성 시 ↓ GET /api/tbm/sessions/date/:date ↓ GET /api/tbm/sessions/:id/team ↓ 팀원 자동 선택 ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -294,11 +294,68 @@ async function loadErrorTypes() {
|
||||
}
|
||||
}
|
||||
|
||||
// TBM 팀 구성 자동 불러오기
|
||||
async function loadTbmTeamForDate(date) {
|
||||
try {
|
||||
console.log('🛠️ TBM 팀 구성 조회 중:', date);
|
||||
const response = await window.apiCall(`/tbm/sessions/date/${date}`);
|
||||
|
||||
if (response && response.success && response.data && response.data.length > 0) {
|
||||
// 가장 최근 세션 선택 (진행중인 세션 우선)
|
||||
const draftSessions = response.data.filter(s => s.status === 'draft');
|
||||
const targetSession = draftSessions.length > 0 ? draftSessions[0] : response.data[0];
|
||||
|
||||
if (targetSession) {
|
||||
// 팀 구성 조회
|
||||
const teamRes = await window.apiCall(`/tbm/sessions/${targetSession.session_id}/team`);
|
||||
if (teamRes && teamRes.success && teamRes.data) {
|
||||
const teamWorkerIds = teamRes.data.map(m => m.worker_id);
|
||||
console.log(`✅ TBM 팀 구성 로드 성공: ${teamWorkerIds.length}명`);
|
||||
return teamWorkerIds;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('ℹ️ 해당 날짜의 TBM 팀 구성이 없습니다.');
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('❌ TBM 팀 구성 조회 오류:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 작업자 그리드 생성
|
||||
function populateWorkerGrid() {
|
||||
async function populateWorkerGrid() {
|
||||
const grid = document.getElementById('workerGrid');
|
||||
grid.innerHTML = '';
|
||||
|
||||
// 선택된 날짜의 TBM 팀 구성 불러오기
|
||||
const reportDate = document.getElementById('reportDate').value;
|
||||
let tbmWorkerIds = [];
|
||||
|
||||
if (reportDate) {
|
||||
tbmWorkerIds = await loadTbmTeamForDate(reportDate);
|
||||
}
|
||||
|
||||
// TBM 팀 구성이 있으면 안내 메시지 표시
|
||||
if (tbmWorkerIds.length > 0) {
|
||||
const infoDiv = document.createElement('div');
|
||||
infoDiv.style.cssText = `
|
||||
padding: 1rem;
|
||||
background: #eff6ff;
|
||||
border: 1px solid #3b82f6;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #1e40af;
|
||||
font-size: 0.875rem;
|
||||
`;
|
||||
infoDiv.innerHTML = `
|
||||
<strong>🛠️ TBM 팀 구성 자동 적용</strong><br>
|
||||
오늘 TBM에서 구성된 팀원 ${tbmWorkerIds.length}명이 자동으로 선택되었습니다.
|
||||
`;
|
||||
grid.appendChild(infoDiv);
|
||||
}
|
||||
|
||||
workers.forEach(worker => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
@@ -306,12 +363,24 @@ function populateWorkerGrid() {
|
||||
btn.textContent = worker.worker_name;
|
||||
btn.dataset.id = worker.worker_id;
|
||||
|
||||
// TBM 팀 구성에 포함된 작업자는 자동 선택
|
||||
if (tbmWorkerIds.includes(worker.worker_id)) {
|
||||
btn.classList.add('selected');
|
||||
selectedWorkers.add(worker.worker_id);
|
||||
}
|
||||
|
||||
btn.addEventListener('click', () => {
|
||||
toggleWorkerSelection(worker.worker_id, btn);
|
||||
});
|
||||
|
||||
grid.appendChild(btn);
|
||||
});
|
||||
|
||||
// 자동 선택된 작업자가 있으면 다음 단계 버튼 활성화
|
||||
const nextBtn = document.getElementById('nextStep2');
|
||||
if (nextBtn) {
|
||||
nextBtn.disabled = selectedWorkers.size === 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 작업자 선택 토글
|
||||
|
||||
264
web-ui/js/tbm.js
264
web-ui/js/tbm.js
@@ -176,14 +176,17 @@ function displayTbmSessions() {
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div style="margin-top: 1rem; display: flex; gap: 0.5rem;">
|
||||
<div style="margin-top: 1rem; display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
||||
${session.status === 'draft' ? `
|
||||
<button class="btn btn-sm btn-primary" onclick="event.stopPropagation(); openTeamCompositionModal(${session.session_id})" style="flex: 1;">
|
||||
<button class="btn btn-sm btn-primary" onclick="event.stopPropagation(); openTeamCompositionModal(${session.session_id})" style="flex: 1; min-width: 100px;">
|
||||
👥 팀 구성
|
||||
</button>
|
||||
<button class="btn btn-sm btn-secondary" onclick="event.stopPropagation(); openSafetyCheckModal(${session.session_id})" style="flex: 1;">
|
||||
<button class="btn btn-sm btn-secondary" onclick="event.stopPropagation(); openSafetyCheckModal(${session.session_id})" style="flex: 1; min-width: 100px;">
|
||||
✅ 안전 체크
|
||||
</button>
|
||||
<button class="btn btn-sm" style="background: #f59e0b; color: white; border: none;" onclick="event.stopPropagation(); openHandoverModal(${session.session_id})">
|
||||
📤 인계
|
||||
</button>
|
||||
<button class="btn btn-sm btn-success" onclick="event.stopPropagation(); openCompleteTbmModal(${session.session_id})">
|
||||
완료
|
||||
</button>
|
||||
@@ -587,12 +590,261 @@ async function completeTbmSession() {
|
||||
window.completeTbmSession = completeTbmSession;
|
||||
|
||||
// TBM 세션 상세 보기
|
||||
function viewTbmSession(sessionId) {
|
||||
// TODO: 상세 보기 페이지 또는 모달 구현
|
||||
console.log('TBM 세션 상세 보기:', sessionId);
|
||||
async function viewTbmSession(sessionId) {
|
||||
try {
|
||||
// 세션 정보, 팀 구성, 안전 체크 동시 조회
|
||||
const [sessionRes, teamRes, safetyRes] = await Promise.all([
|
||||
window.apiCall(`/tbm/sessions/${sessionId}`),
|
||||
window.apiCall(`/tbm/sessions/${sessionId}/team`),
|
||||
window.apiCall(`/tbm/sessions/${sessionId}/safety`)
|
||||
]);
|
||||
|
||||
const session = sessionRes?.data;
|
||||
const team = teamRes?.data || [];
|
||||
const safety = safetyRes?.data || [];
|
||||
|
||||
if (!session) {
|
||||
showToast('세션 정보를 불러올 수 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 기본 정보 표시
|
||||
const basicInfo = document.getElementById('detailBasicInfo');
|
||||
basicInfo.innerHTML = `
|
||||
<div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem;">
|
||||
<div style="font-size: 0.75rem; color: #6b7280; margin-bottom: 0.25rem;">팀장</div>
|
||||
<div style="font-weight: 600; color: #111827;">${session.leader_name}</div>
|
||||
</div>
|
||||
<div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem;">
|
||||
<div style="font-size: 0.75rem; color: #6b7280; margin-bottom: 0.25rem;">날짜</div>
|
||||
<div style="font-weight: 600; color: #111827;">${session.session_date}</div>
|
||||
</div>
|
||||
<div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem;">
|
||||
<div style="font-size: 0.75rem; color: #6b7280; margin-bottom: 0.25rem;">프로젝트</div>
|
||||
<div style="font-weight: 600; color: #111827;">${session.project_name || '-'}</div>
|
||||
</div>
|
||||
<div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem;">
|
||||
<div style="font-size: 0.75rem; color: #6b7280; margin-bottom: 0.25rem;">작업 장소</div>
|
||||
<div style="font-weight: 600; color: #111827;">${session.work_location || '-'}</div>
|
||||
</div>
|
||||
<div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem; grid-column: span 2;">
|
||||
<div style="font-size: 0.75rem; color: #6b7280; margin-bottom: 0.25rem;">작업 내용</div>
|
||||
<div style="color: #111827;">${session.work_description || '-'}</div>
|
||||
</div>
|
||||
${session.safety_notes ? `
|
||||
<div style="padding: 0.75rem; background: #fef3c7; border-radius: 0.375rem; grid-column: span 2;">
|
||||
<div style="font-size: 0.75rem; color: #92400e; margin-bottom: 0.25rem;">⚠️ 안전 특이사항</div>
|
||||
<div style="color: #78350f;">${session.safety_notes}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
|
||||
// 팀 구성 표시
|
||||
const teamMembers = document.getElementById('detailTeamMembers');
|
||||
if (team.length === 0) {
|
||||
teamMembers.innerHTML = '<p style="color: #6b7280; font-size: 0.875rem;">등록된 팀원이 없습니다.</p>';
|
||||
} else {
|
||||
teamMembers.innerHTML = team.map(member => `
|
||||
<div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem; border: 1px solid #e5e7eb;">
|
||||
<div style="font-weight: 600; color: #111827; margin-bottom: 0.25rem;">${member.worker_name}</div>
|
||||
<div style="font-size: 0.75rem; color: #6b7280;">${member.job_type || ''}</div>
|
||||
${member.is_present ? '' : '<div style="font-size: 0.75rem; color: #ef4444; margin-top: 0.25rem;">결석</div>'}
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 안전 체크 표시
|
||||
const safetyChecks = document.getElementById('detailSafetyChecks');
|
||||
if (safety.length === 0) {
|
||||
safetyChecks.innerHTML = '<p style="color: #6b7280; font-size: 0.875rem;">안전 체크 기록이 없습니다.</p>';
|
||||
} else {
|
||||
// 카테고리별 그룹화
|
||||
const grouped = {};
|
||||
safety.forEach(check => {
|
||||
if (!grouped[check.check_category]) {
|
||||
grouped[check.check_category] = [];
|
||||
}
|
||||
grouped[check.check_category].push(check);
|
||||
});
|
||||
|
||||
const categoryNames = {
|
||||
'PPE': '개인 보호 장비',
|
||||
'EQUIPMENT': '장비 점검',
|
||||
'ENVIRONMENT': '작업 환경',
|
||||
'EMERGENCY': '비상 대응'
|
||||
};
|
||||
|
||||
safetyChecks.innerHTML = Object.keys(grouped).map(category => `
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<div style="font-weight: 600; font-size: 0.875rem; color: #374151; margin-bottom: 0.5rem; padding: 0.5rem; background: #f3f4f6; border-radius: 0.25rem;">
|
||||
${categoryNames[category] || category}
|
||||
</div>
|
||||
<div style="display: grid; gap: 0.5rem;">
|
||||
${grouped[category].map(check => `
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem; border-radius: 0.25rem; ${check.is_checked ? 'background: #f0fdf4;' : 'background: #fef2f2;'}">
|
||||
<span style="font-size: 1.25rem;">${check.is_checked ? '✅' : '❌'}</span>
|
||||
<span style="flex: 1; font-size: 0.875rem; color: #374151;">${check.check_item}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
document.getElementById('detailModal').style.display = 'flex';
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ TBM 상세 조회 오류:', error);
|
||||
showToast('상세 정보를 불러오는 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
window.viewTbmSession = viewTbmSession;
|
||||
|
||||
// 상세보기 모달 닫기
|
||||
function closeDetailModal() {
|
||||
document.getElementById('detailModal').style.display = 'none';
|
||||
document.body.style.overflow = 'auto';
|
||||
}
|
||||
window.closeDetailModal = closeDetailModal;
|
||||
|
||||
// 작업 인계 모달 열기
|
||||
async function openHandoverModal(sessionId) {
|
||||
currentSessionId = sessionId;
|
||||
|
||||
// 세션 정보와 팀 구성 조회
|
||||
try {
|
||||
const [sessionRes, teamRes] = await Promise.all([
|
||||
window.apiCall(`/tbm/sessions/${sessionId}`),
|
||||
window.apiCall(`/tbm/sessions/${sessionId}/team`)
|
||||
]);
|
||||
|
||||
const session = sessionRes?.data;
|
||||
const team = teamRes?.data || [];
|
||||
|
||||
if (!session) {
|
||||
showToast('세션 정보를 불러올 수 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 현재 세션의 팀장을 제외한 리더 목록
|
||||
const toLeaderSelect = document.getElementById('toLeaderId');
|
||||
const otherLeaders = allWorkers.filter(w =>
|
||||
(w.job_type === 'leader' || w.job_type === '그룹장' || w.job_type === 'admin') &&
|
||||
w.worker_id !== session.leader_id
|
||||
);
|
||||
|
||||
toLeaderSelect.innerHTML = '<option value="">인수자 선택...</option>' +
|
||||
otherLeaders.map(w => `
|
||||
<option value="${w.worker_id}">${w.worker_name} (${w.job_type || ''})</option>
|
||||
`).join('');
|
||||
|
||||
// 인계할 팀원 목록
|
||||
const handoverTeamList = document.getElementById('handoverTeamList');
|
||||
if (team.length === 0) {
|
||||
handoverTeamList.innerHTML = '<p style="padding: 1rem; color: #6b7280; text-align: center;">팀 구성이 없습니다.</p>';
|
||||
} else {
|
||||
handoverTeamList.innerHTML = team.map(member => `
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem; cursor: pointer; border-radius: 0.25rem; transition: background 0.2s;"
|
||||
onmouseover="this.style.background='#f9fafb'" onmouseout="this.style.background='white'">
|
||||
<input type="checkbox"
|
||||
class="handover-worker-checkbox"
|
||||
value="${member.worker_id}"
|
||||
checked
|
||||
style="width: 16px; height: 16px; cursor: pointer;">
|
||||
<span style="font-weight: 500; font-size: 0.875rem;">${member.worker_name}</span>
|
||||
<span style="font-size: 0.75rem; color: #6b7280; margin-left: auto;">${member.job_type || ''}</span>
|
||||
</label>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 기본값 설정
|
||||
document.getElementById('handoverSessionId').value = sessionId;
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const now = new Date().toTimeString().slice(0, 5);
|
||||
document.getElementById('handoverDate').value = today;
|
||||
document.getElementById('handoverTime').value = now;
|
||||
document.getElementById('handoverReason').value = '';
|
||||
document.getElementById('handoverNotes').value = '';
|
||||
|
||||
document.getElementById('handoverModal').style.display = 'flex';
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 인계 모달 열기 오류:', error);
|
||||
showToast('인계 정보를 불러오는 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
window.openHandoverModal = openHandoverModal;
|
||||
|
||||
// 인계 모달 닫기
|
||||
function closeHandoverModal() {
|
||||
document.getElementById('handoverModal').style.display = 'none';
|
||||
document.body.style.overflow = 'auto';
|
||||
}
|
||||
window.closeHandoverModal = closeHandoverModal;
|
||||
|
||||
// 작업 인계 저장
|
||||
async function saveHandover() {
|
||||
const sessionId = currentSessionId;
|
||||
const toLeaderId = parseInt(document.getElementById('toLeaderId').value);
|
||||
const reason = document.getElementById('handoverReason').value;
|
||||
const handoverDate = document.getElementById('handoverDate').value;
|
||||
const handoverTime = document.getElementById('handoverTime').value;
|
||||
const handoverNotes = document.getElementById('handoverNotes').value;
|
||||
|
||||
if (!toLeaderId || !reason || !handoverDate) {
|
||||
showToast('필수 항목을 입력해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 인계할 작업자 목록
|
||||
const workerIds = [];
|
||||
document.querySelectorAll('.handover-worker-checkbox:checked').forEach(cb => {
|
||||
workerIds.push(parseInt(cb.value));
|
||||
});
|
||||
|
||||
if (workerIds.length === 0) {
|
||||
showToast('인계할 팀원을 최소 1명 이상 선택해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 세션 정보 조회 (from_leader_id 가져오기)
|
||||
const sessionRes = await window.apiCall(`/tbm/sessions/${sessionId}`);
|
||||
const fromLeaderId = sessionRes?.data?.leader_id;
|
||||
|
||||
if (!fromLeaderId) {
|
||||
showToast('세션 정보를 찾을 수 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const handoverData = {
|
||||
session_id: sessionId,
|
||||
from_leader_id: fromLeaderId,
|
||||
to_leader_id: toLeaderId,
|
||||
handover_date: handoverDate,
|
||||
handover_time: handoverTime,
|
||||
reason: reason,
|
||||
handover_notes: handoverNotes,
|
||||
worker_ids: workerIds
|
||||
};
|
||||
|
||||
const response = await window.apiCall('/tbm/handovers', 'POST', handoverData);
|
||||
|
||||
if (response && response.success) {
|
||||
showToast('작업 인계가 요청되었습니다.', 'success');
|
||||
closeHandoverModal();
|
||||
} else {
|
||||
throw new Error(response.message || '인계 요청에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 작업 인계 저장 오류:', error);
|
||||
showToast('작업 인계 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
window.saveHandover = saveHandover;
|
||||
|
||||
// 토스트 알림
|
||||
function showToast(message, type = 'info', duration = 3000) {
|
||||
const container = document.getElementById('toastContainer');
|
||||
|
||||
@@ -226,11 +226,115 @@
|
||||
</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=1"></script>
|
||||
<script type="module" src="/js/tbm.js?v=2"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user