feat: 안전 코드 tksafety 이관 + 사용자 관리 정리 + UI Tailwind 전환
Phase 1: tksafety에 출입신청/체크리스트 API·웹 추가, tkfb 안전 코드 삭제
Phase 2: 사용자 관리 페이지 삭제, API 축소, 알림 수신자 tkuser 이관
Phase 3: tkuser 권한 페이지 정의 업데이트
Phase 4: 전체 34개 페이지 Tailwind CSS + tkfb-core.js 전환,
미사용 CSS 20개·인프라 JS 10개·템플릿·컴포넌트 삭제
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
239
tksafety/web/static/js/tksafety-training.js
Normal file
239
tksafety/web/static/js/tksafety-training.js
Normal file
@@ -0,0 +1,239 @@
|
||||
/* ===== Training Management (안전교육 실시 - 관리자) ===== */
|
||||
let pendingRequests = [];
|
||||
let completedTrainings = [];
|
||||
let trainingRequestId = null;
|
||||
|
||||
/* Signature canvas state */
|
||||
let sigCanvas, sigCtx;
|
||||
let isDrawing = false;
|
||||
let hasSignature = false;
|
||||
|
||||
/* ===== Load approved requests needing training ===== */
|
||||
async function loadPendingTraining() {
|
||||
try {
|
||||
const res = await api('/visit-requests/requests?status=approved');
|
||||
pendingRequests = res.data || [];
|
||||
renderPendingTraining();
|
||||
} catch (e) {
|
||||
showToast('대기 목록 로드 실패: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderPendingTraining() {
|
||||
const tbody = document.getElementById('pendingTrainingBody');
|
||||
if (!pendingRequests.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-gray-400 py-8">교육 대기 중인 신청이 없습니다</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = pendingRequests.map(r => `<tr>
|
||||
<td>${escapeHtml(r.visitor_company)}</td>
|
||||
<td class="text-center">${r.visitor_count}</td>
|
||||
<td>${escapeHtml(r.workplace_name || '-')}</td>
|
||||
<td>${formatDate(r.visit_date)}</td>
|
||||
<td class="hide-mobile">${r.visit_time ? String(r.visit_time).substring(0, 5) : '-'}</td>
|
||||
<td>${escapeHtml(r.purpose_name || '-')}</td>
|
||||
<td class="text-right">
|
||||
<button onclick="openTrainingModal(${r.request_id})" class="text-orange-600 hover:text-orange-800 text-xs px-2 py-1 border border-orange-200 rounded hover:bg-orange-50">
|
||||
<i class="fas fa-chalkboard-teacher mr-1"></i>교육실시
|
||||
</button>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
/* ===== Load completed training records ===== */
|
||||
async function loadCompletedTraining() {
|
||||
try {
|
||||
const res = await api('/visit-requests/training');
|
||||
completedTrainings = res.data || [];
|
||||
renderCompletedTraining();
|
||||
} catch (e) {
|
||||
showToast('이력 로드 실패: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderCompletedTraining() {
|
||||
const tbody = document.getElementById('completedTrainingBody');
|
||||
if (!completedTrainings.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-gray-400 py-8">교육 완료 이력이 없습니다</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = completedTrainings.map(t => {
|
||||
const timeRange = t.training_start_time
|
||||
? String(t.training_start_time).substring(0, 5) + (t.training_end_time ? ' ~ ' + String(t.training_end_time).substring(0, 5) : '')
|
||||
: '-';
|
||||
return `<tr>
|
||||
<td>${formatDate(t.training_date)}</td>
|
||||
<td>${escapeHtml(t.visitor_company || '-')}</td>
|
||||
<td class="text-center">${t.visitor_count || '-'}</td>
|
||||
<td>${timeRange}</td>
|
||||
<td class="hide-mobile">${escapeHtml(t.training_topics || '-')}</td>
|
||||
<td>${escapeHtml(t.trainer_full_name || t.trainer_name || '-')}</td>
|
||||
<td class="text-right">
|
||||
${t.completed_at ? '<span class="badge badge-green">완료</span>' : '<span class="badge badge-amber">진행중</span>'}
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/* ===== Training Modal ===== */
|
||||
function openTrainingModal(requestId) {
|
||||
const r = pendingRequests.find(x => x.request_id === requestId);
|
||||
if (!r) return;
|
||||
trainingRequestId = requestId;
|
||||
|
||||
document.getElementById('trainingRequestInfo').innerHTML = `
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div><span class="text-gray-500">업체:</span> <strong>${escapeHtml(r.visitor_company)}</strong></div>
|
||||
<div><span class="text-gray-500">인원:</span> <strong>${r.visitor_count}명</strong></div>
|
||||
<div><span class="text-gray-500">작업장:</span> <strong>${escapeHtml(r.workplace_name || '-')}</strong></div>
|
||||
<div><span class="text-gray-500">방문일:</span> <strong>${formatDate(r.visit_date)}</strong></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Set defaults
|
||||
const today = new Date().toISOString().substring(0, 10);
|
||||
const now = new Date().toTimeString().substring(0, 5);
|
||||
document.getElementById('trainingDate').value = today;
|
||||
document.getElementById('trainingStartTime').value = now;
|
||||
document.getElementById('trainingEndTime').value = '';
|
||||
document.getElementById('trainingTopics').value = '';
|
||||
|
||||
// Reset signature
|
||||
clearSignature();
|
||||
|
||||
document.getElementById('trainingModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeTrainingModal() {
|
||||
document.getElementById('trainingModal').classList.add('hidden');
|
||||
trainingRequestId = null;
|
||||
}
|
||||
|
||||
/* ===== Submit Training ===== */
|
||||
async function submitTraining(e) {
|
||||
e.preventDefault();
|
||||
if (!trainingRequestId) return;
|
||||
|
||||
const data = {
|
||||
request_id: trainingRequestId,
|
||||
training_date: document.getElementById('trainingDate').value,
|
||||
training_start_time: document.getElementById('trainingStartTime').value,
|
||||
training_end_time: document.getElementById('trainingEndTime').value || null,
|
||||
training_topics: document.getElementById('trainingTopics').value.trim() || null
|
||||
};
|
||||
|
||||
if (!data.training_date) { showToast('교육일을 선택해주세요', 'error'); return; }
|
||||
if (!data.training_start_time) { showToast('시작시간을 입력해주세요', 'error'); return; }
|
||||
|
||||
try {
|
||||
// 1. Create training record
|
||||
const createRes = await api('/visit-requests/training', {
|
||||
method: 'POST', body: JSON.stringify(data)
|
||||
});
|
||||
const trainingId = createRes.data?.training_id || createRes.data?.insertId;
|
||||
|
||||
// 2. Complete with signature if exists
|
||||
if (trainingId && hasSignature) {
|
||||
const signatureData = sigCanvas.toDataURL('image/png');
|
||||
await api('/visit-requests/training/' + trainingId + '/complete', {
|
||||
method: 'PUT', body: JSON.stringify({ signature_data: signatureData })
|
||||
});
|
||||
}
|
||||
|
||||
showToast('안전교육이 완료되었습니다');
|
||||
closeTrainingModal();
|
||||
await Promise.all([loadPendingTraining(), loadCompletedTraining()]);
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Signature Pad ===== */
|
||||
function initSignaturePad() {
|
||||
sigCanvas = document.getElementById('signatureCanvas');
|
||||
if (!sigCanvas) return;
|
||||
sigCtx = sigCanvas.getContext('2d');
|
||||
|
||||
// Adjust canvas resolution for retina displays
|
||||
const rect = sigCanvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
sigCanvas.width = rect.width * dpr;
|
||||
sigCanvas.height = rect.height * dpr;
|
||||
sigCtx.scale(dpr, dpr);
|
||||
sigCtx.lineCap = 'round';
|
||||
sigCtx.lineJoin = 'round';
|
||||
sigCtx.lineWidth = 2;
|
||||
sigCtx.strokeStyle = '#1f2937';
|
||||
|
||||
// Mouse events
|
||||
sigCanvas.addEventListener('mousedown', startDraw);
|
||||
sigCanvas.addEventListener('mousemove', draw);
|
||||
sigCanvas.addEventListener('mouseup', stopDraw);
|
||||
sigCanvas.addEventListener('mouseleave', stopDraw);
|
||||
|
||||
// Touch events
|
||||
sigCanvas.addEventListener('touchstart', function(e) {
|
||||
e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
startDraw(touchToMouse(touch));
|
||||
});
|
||||
sigCanvas.addEventListener('touchmove', function(e) {
|
||||
e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
draw(touchToMouse(touch));
|
||||
});
|
||||
sigCanvas.addEventListener('touchend', function(e) {
|
||||
e.preventDefault();
|
||||
stopDraw();
|
||||
});
|
||||
}
|
||||
|
||||
function touchToMouse(touch) {
|
||||
const rect = sigCanvas.getBoundingClientRect();
|
||||
return { offsetX: touch.clientX - rect.left, offsetY: touch.clientY - rect.top };
|
||||
}
|
||||
|
||||
function startDraw(e) {
|
||||
isDrawing = true;
|
||||
sigCtx.beginPath();
|
||||
sigCtx.moveTo(e.offsetX, e.offsetY);
|
||||
}
|
||||
|
||||
function draw(e) {
|
||||
if (!isDrawing) return;
|
||||
hasSignature = true;
|
||||
sigCtx.lineTo(e.offsetX, e.offsetY);
|
||||
sigCtx.stroke();
|
||||
}
|
||||
|
||||
function stopDraw() {
|
||||
isDrawing = false;
|
||||
}
|
||||
|
||||
function clearSignature() {
|
||||
if (!sigCanvas || !sigCtx) return;
|
||||
const rect = sigCanvas.getBoundingClientRect();
|
||||
sigCtx.clearRect(0, 0, rect.width, rect.height);
|
||||
hasSignature = false;
|
||||
}
|
||||
|
||||
/* ===== Init ===== */
|
||||
function initTrainingPage() {
|
||||
if (!initAuth()) return;
|
||||
|
||||
// Check admin
|
||||
const isAdmin = currentUser && ['admin', 'system'].includes(currentUser.role);
|
||||
if (!isAdmin) {
|
||||
document.querySelector('.flex-1.min-w-0').innerHTML = `
|
||||
<div class="bg-white rounded-xl shadow-sm p-10 text-center">
|
||||
<i class="fas fa-lock text-4xl text-gray-300 mb-4"></i>
|
||||
<p class="text-gray-500">관리자 권한이 필요합니다</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('trainingForm').addEventListener('submit', submitTraining);
|
||||
initSignaturePad();
|
||||
loadPendingTraining();
|
||||
loadCompletedTraining();
|
||||
}
|
||||
Reference in New Issue
Block a user