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:
Hyungi Ahn
2026-03-13 10:46:22 +09:00
parent 8373fe9e75
commit 9fda89a374
133 changed files with 5255 additions and 26181 deletions

View File

@@ -0,0 +1,286 @@
/* ===== Checklist Management (체크리스트 관리 - 관리자) ===== */
let checklistItems = [];
let weatherConditions = [];
let workTypes = [];
let editingItemId = null;
let currentTab = 'basic';
/* ===== Tab switching ===== */
function switchTab(tab) {
currentTab = tab;
['basic', 'weather', 'worktype'].forEach(t => {
document.getElementById('panel' + t.charAt(0).toUpperCase() + t.slice(1)).classList.toggle('hidden', t !== tab);
const tabBtn = document.getElementById('tab' + t.charAt(0).toUpperCase() + t.slice(1));
if (t === tab) {
tabBtn.classList.add('active');
} else {
tabBtn.classList.remove('active');
}
});
}
/* ===== Load checklist items ===== */
async function loadChecklistItems() {
try {
const res = await api('/checklist');
checklistItems = res.data || [];
renderBasicItems();
renderWeatherItems();
renderWorktypeItems();
} catch (e) {
showToast('체크리스트 로드 실패: ' + e.message, 'error');
}
}
/* ===== Load lookup data ===== */
async function loadLookupData() {
try {
const [wcRes, wtRes] = await Promise.all([
api('/checklist/weather-conditions'),
api('/checklist/work-types')
]);
weatherConditions = wcRes.data || [];
workTypes = wtRes.data || [];
// Populate selects
document.getElementById('itemWeatherCondition').innerHTML = '<option value="">선택</option>' +
weatherConditions.map(w => `<option value="${w.condition_id}">${escapeHtml(w.condition_name)}</option>`).join('');
document.getElementById('itemWorkType').innerHTML = '<option value="">선택</option>' +
workTypes.map(w => `<option value="${w.work_type_id}">${escapeHtml(w.work_type_name)}</option>`).join('');
} catch (e) {
console.error('Lookup data load error:', e);
}
}
/* ===== Render basic items ===== */
function renderBasicItems() {
const items = checklistItems.filter(i => i.item_type === 'basic');
const container = document.getElementById('basicItemsList');
if (!items.length) {
container.innerHTML = '<div class="text-center text-gray-400 py-8">기본 항목이 없습니다</div>';
return;
}
// Group by category
const groups = {};
items.forEach(i => {
const cat = i.category || '미분류';
if (!groups[cat]) groups[cat] = [];
groups[cat].push(i);
});
container.innerHTML = Object.entries(groups).map(([cat, items]) => `
<div class="border rounded-lg overflow-hidden">
<div class="bg-gray-50 px-4 py-2 font-medium text-sm text-gray-700">${escapeHtml(cat)}</div>
<div class="divide-y">
${items.map(i => renderItemRow(i)).join('')}
</div>
</div>
`).join('');
}
/* ===== Render weather items ===== */
function renderWeatherItems() {
const items = checklistItems.filter(i => i.item_type === 'weather');
const container = document.getElementById('weatherItemsList');
if (!items.length) {
container.innerHTML = '<div class="text-center text-gray-400 py-8">날씨별 항목이 없습니다</div>';
return;
}
// Group by weather condition
const groups = {};
items.forEach(i => {
const cond = i.weather_condition_name || '미지정';
if (!groups[cond]) groups[cond] = [];
groups[cond].push(i);
});
container.innerHTML = Object.entries(groups).map(([cond, items]) => `
<div class="border rounded-lg overflow-hidden">
<div class="bg-blue-50 px-4 py-2 font-medium text-sm text-blue-700">
<i class="fas fa-cloud mr-1"></i>${escapeHtml(cond)}
</div>
<div class="divide-y">
${items.map(i => renderItemRow(i)).join('')}
</div>
</div>
`).join('');
}
/* ===== Render worktype items ===== */
function renderWorktypeItems() {
const items = checklistItems.filter(i => i.item_type === 'work_type');
const container = document.getElementById('worktypeItemsList');
if (!items.length) {
container.innerHTML = '<div class="text-center text-gray-400 py-8">작업별 항목이 없습니다</div>';
return;
}
// Group by work type
const groups = {};
items.forEach(i => {
const wt = i.work_type_name || '미지정';
if (!groups[wt]) groups[wt] = [];
groups[wt].push(i);
});
container.innerHTML = Object.entries(groups).map(([wt, items]) => `
<div class="border rounded-lg overflow-hidden">
<div class="bg-amber-50 px-4 py-2 font-medium text-sm text-amber-700">
<i class="fas fa-hard-hat mr-1"></i>${escapeHtml(wt)}
</div>
<div class="divide-y">
${items.map(i => renderItemRow(i)).join('')}
</div>
</div>
`).join('');
}
/* ===== Render single item row ===== */
function renderItemRow(item) {
const activeClass = item.is_active ? '' : 'opacity-50';
const activeIcon = item.is_active
? '<i class="fas fa-check-circle text-green-500 text-xs"></i>'
: '<i class="fas fa-times-circle text-gray-400 text-xs"></i>';
return `
<div class="flex items-center justify-between px-4 py-2.5 hover:bg-gray-50 ${activeClass}">
<div class="flex items-center gap-2 flex-1 min-w-0">
${activeIcon}
<span class="text-sm text-gray-700 truncate">${escapeHtml(item.item_content)}</span>
${item.category ? `<span class="badge badge-gray text-xs">${escapeHtml(item.category)}</span>` : ''}
</div>
<div class="flex items-center gap-1 flex-shrink-0 ml-2">
<span class="text-xs text-gray-400 mr-2">#${item.display_order}</span>
<button onclick="openEditItem(${item.item_id})" class="text-gray-400 hover:text-gray-600 text-xs p-1" title="수정">
<i class="fas fa-pen"></i>
</button>
<button onclick="doDeleteItem(${item.item_id})" class="text-gray-400 hover:text-red-500 text-xs p-1" title="삭제">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`;
}
/* ===== Add/Edit Modal ===== */
function openAddItem(tab) {
editingItemId = null;
document.getElementById('itemModalTitle').textContent = '체크리스트 항목 추가';
document.getElementById('itemForm').reset();
document.getElementById('itemIsActive').checked = true;
document.getElementById('itemDisplayOrder').value = '0';
// Set type based on tab
const typeMap = { basic: 'basic', weather: 'weather', worktype: 'work_type' };
document.getElementById('itemType').value = typeMap[tab] || 'basic';
toggleTypeFields();
document.getElementById('itemModal').classList.remove('hidden');
}
function openEditItem(id) {
const item = checklistItems.find(i => i.item_id === id);
if (!item) return;
editingItemId = id;
document.getElementById('itemModalTitle').textContent = '체크리스트 항목 수정';
document.getElementById('itemType').value = item.item_type;
document.getElementById('itemCategory').value = item.category || '';
document.getElementById('itemContent').value = item.item_content || '';
document.getElementById('itemDisplayOrder').value = item.display_order || 0;
document.getElementById('itemIsActive').checked = item.is_active !== false && item.is_active !== 0;
if (item.weather_condition_id) {
document.getElementById('itemWeatherCondition').value = item.weather_condition_id;
}
if (item.work_type_id) {
document.getElementById('itemWorkType').value = item.work_type_id;
}
toggleTypeFields();
document.getElementById('itemModal').classList.remove('hidden');
}
function closeItemModal() {
document.getElementById('itemModal').classList.add('hidden');
editingItemId = null;
}
function toggleTypeFields() {
const type = document.getElementById('itemType').value;
document.getElementById('weatherConditionField').classList.toggle('hidden', type !== 'weather');
document.getElementById('workTypeField').classList.toggle('hidden', type !== 'work_type');
}
/* ===== Submit item ===== */
async function submitItem(e) {
e.preventDefault();
const data = {
item_type: document.getElementById('itemType').value,
category: document.getElementById('itemCategory').value.trim() || null,
item_content: document.getElementById('itemContent').value.trim(),
display_order: parseInt(document.getElementById('itemDisplayOrder').value) || 0,
is_active: document.getElementById('itemIsActive').checked
};
if (!data.item_content) { showToast('점검 항목을 입력해주세요', 'error'); return; }
if (data.item_type === 'weather') {
data.weather_condition_id = parseInt(document.getElementById('itemWeatherCondition').value) || null;
if (!data.weather_condition_id) { showToast('날씨 조건을 선택해주세요', 'error'); return; }
}
if (data.item_type === 'work_type') {
data.work_type_id = parseInt(document.getElementById('itemWorkType').value) || null;
if (!data.work_type_id) { showToast('작업 유형을 선택해주세요', 'error'); return; }
}
try {
if (editingItemId) {
await api('/checklist/' + editingItemId, { method: 'PUT', body: JSON.stringify(data) });
showToast('수정되었습니다');
} else {
await api('/checklist', { method: 'POST', body: JSON.stringify(data) });
showToast('추가되었습니다');
}
closeItemModal();
await loadChecklistItems();
} catch (e) {
showToast(e.message, 'error');
}
}
/* ===== Delete item ===== */
async function doDeleteItem(id) {
if (!confirm('이 항목을 삭제하시겠습니까?')) return;
try {
await api('/checklist/' + id, { method: 'DELETE' });
showToast('삭제되었습니다');
await loadChecklistItems();
} catch (e) {
showToast(e.message, 'error');
}
}
/* ===== Init ===== */
function initChecklistPage() {
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;
}
// Type change handler
document.getElementById('itemType').addEventListener('change', toggleTypeFields);
document.getElementById('itemForm').addEventListener('submit', submitItem);
loadLookupData();
loadChecklistItems();
}

View File

@@ -82,13 +82,18 @@ function doLogout() {
/* ===== Navbar ===== */
function renderNavbar() {
const currentPage = location.pathname.replace(/\//g, '') || 'index.html';
const isAdmin = currentUser && ['admin','system'].includes(currentUser.role);
const links = [
{ href: '/', icon: 'fa-door-open', label: '방문 관리', match: ['index.html'] },
{ href: '/visit-request.html', icon: 'fa-file-signature', label: '출입 신청', match: ['visit-request.html'] },
{ href: '/visit-management.html', icon: 'fa-clipboard-check', label: '출입 관리', match: ['visit-management.html'], admin: true },
{ href: '/education.html', icon: 'fa-graduation-cap', label: '안전교육', match: ['education.html'] },
{ href: '/training.html', icon: 'fa-chalkboard-teacher', label: '안전교육 실시', match: ['training.html'], admin: true },
{ href: '/checklist.html', icon: 'fa-tasks', label: '체크리스트 관리', match: ['checklist.html'], admin: true },
];
const nav = document.getElementById('sideNav');
if (!nav) return;
nav.innerHTML = links.map(l => {
nav.innerHTML = links.filter(l => !l.admin || isAdmin).map(l => {
const active = l.match.some(m => currentPage === m || currentPage.endsWith(m));
return `<a href="${l.href}" class="nav-link flex items-center gap-3 px-4 py-2.5 rounded-lg text-sm transition-colors ${active ? 'active' : 'text-gray-600 hover:bg-gray-100'}">
<i class="fas ${l.icon} w-5 text-center"></i><span>${l.label}</span></a>`;

View 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();
}

View File

@@ -0,0 +1,216 @@
/* ===== Visit Management (출입 관리 - 관리자) ===== */
let allRequests = [];
let actionRequestId = null;
/* ===== Status badge for visit requests ===== */
function vrStatusBadge(s) {
const m = {
pending: ['badge-amber', '대기중'],
approved: ['badge-green', '승인됨'],
rejected: ['badge-red', '반려됨'],
training_completed: ['badge-blue', '교육완료']
};
const [cls, label] = m[s] || ['badge-gray', s];
return `<span class="badge ${cls}">${label}</span>`;
}
/* ===== Load requests ===== */
async function loadRequests() {
try {
const params = new URLSearchParams();
const status = document.getElementById('filterStatus').value;
const dateFrom = document.getElementById('filterDateFrom').value;
const dateTo = document.getElementById('filterDateTo').value;
if (status) params.set('status', status);
if (dateFrom) params.set('start_date', dateFrom);
if (dateTo) params.set('end_date', dateTo);
const res = await api('/visit-requests/requests?' + params.toString());
allRequests = res.data || [];
renderStats();
renderRequestsTable();
} catch (e) {
showToast('데이터 로드 실패: ' + e.message, 'error');
}
}
function renderStats() {
const counts = { pending: 0, approved: 0, rejected: 0, training_completed: 0 };
allRequests.forEach(r => { if (counts[r.status] !== undefined) counts[r.status]++; });
document.getElementById('statPending').textContent = counts.pending;
document.getElementById('statApproved').textContent = counts.approved;
document.getElementById('statRejected').textContent = counts.rejected;
document.getElementById('statTrainingDone').textContent = counts.training_completed;
}
function renderRequestsTable() {
const tbody = document.getElementById('requestsTableBody');
if (!allRequests.length) {
tbody.innerHTML = '<tr><td colspan="10" class="text-center text-gray-400 py-8">신청 내역이 없습니다</td></tr>';
return;
}
tbody.innerHTML = allRequests.map(r => {
let actions = '';
if (r.status === 'pending') {
actions = `
<button onclick="openApproveModal(${r.request_id})" class="text-green-600 hover:text-green-800 text-xs px-2 py-1 border border-green-200 rounded hover:bg-green-50" title="승인">
<i class="fas fa-check"></i>
</button>
<button onclick="openRejectModal(${r.request_id})" class="text-red-600 hover:text-red-800 text-xs px-2 py-1 border border-red-200 rounded hover:bg-red-50 ml-1" title="반려">
<i class="fas fa-times"></i>
</button>`;
}
actions += ` <button onclick="openDetailModal(${r.request_id})" class="text-gray-400 hover:text-gray-600 text-xs ml-1" title="상세"><i class="fas fa-eye"></i></button>`;
if (r.status === 'pending') {
actions += ` <button onclick="doDeleteRequest(${r.request_id})" class="text-gray-400 hover:text-red-500 text-xs ml-1" title="삭제"><i class="fas fa-trash"></i></button>`;
}
return `<tr>
<td>${formatDate(r.created_at)}</td>
<td>${escapeHtml(r.requester_full_name || r.requester_name || '-')}</td>
<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>${vrStatusBadge(r.status)}</td>
<td class="text-right whitespace-nowrap">${actions}</td>
</tr>`;
}).join('');
}
/* ===== Approve Modal ===== */
function openApproveModal(id) {
const r = allRequests.find(x => x.request_id === id);
if (!r) return;
actionRequestId = id;
document.getElementById('approveDetail').innerHTML = `
<p><strong>업체:</strong> ${escapeHtml(r.visitor_company)}</p>
<p><strong>방문일:</strong> ${formatDate(r.visit_date)} ${r.visit_time ? String(r.visit_time).substring(0, 5) : ''}</p>
<p><strong>작업장:</strong> ${escapeHtml(r.workplace_name || '-')}</p>
<p><strong>인원:</strong> ${r.visitor_count}명</p>
<p class="mt-2">이 출입 신청을 승인하시겠습니까?</p>
`;
document.getElementById('approveModal').classList.remove('hidden');
}
function closeApproveModal() {
document.getElementById('approveModal').classList.add('hidden');
actionRequestId = null;
}
async function confirmApprove() {
if (!actionRequestId) return;
try {
await api('/visit-requests/requests/' + actionRequestId + '/approve', {
method: 'PUT', body: JSON.stringify({})
});
showToast('승인되었습니다');
closeApproveModal();
await loadRequests();
} catch (e) {
showToast(e.message, 'error');
}
}
/* ===== Reject Modal ===== */
function openRejectModal(id) {
const r = allRequests.find(x => x.request_id === id);
if (!r) return;
actionRequestId = id;
document.getElementById('rejectDetail').innerHTML = `
<p><strong>업체:</strong> ${escapeHtml(r.visitor_company)}</p>
<p><strong>방문일:</strong> ${formatDate(r.visit_date)} ${r.visit_time ? String(r.visit_time).substring(0, 5) : ''}</p>
<p><strong>작업장:</strong> ${escapeHtml(r.workplace_name || '-')}</p>
`;
document.getElementById('rejectionReason').value = '';
document.getElementById('rejectModal').classList.remove('hidden');
}
function closeRejectModal() {
document.getElementById('rejectModal').classList.add('hidden');
actionRequestId = null;
}
async function confirmReject() {
if (!actionRequestId) return;
const reason = document.getElementById('rejectionReason').value.trim();
if (!reason) {
showToast('반려 사유를 입력해주세요', 'error');
return;
}
try {
await api('/visit-requests/requests/' + actionRequestId + '/reject', {
method: 'PUT', body: JSON.stringify({ rejection_reason: reason })
});
showToast('반려되었습니다');
closeRejectModal();
await loadRequests();
} catch (e) {
showToast(e.message, 'error');
}
}
/* ===== Detail Modal ===== */
function openDetailModal(id) {
const r = allRequests.find(x => x.request_id === id);
if (!r) return;
document.getElementById('detailContent').innerHTML = `
<div class="grid grid-cols-2 gap-3">
<div><span class="text-gray-500">신청자:</span> <span class="font-medium">${escapeHtml(r.requester_full_name || r.requester_name || '-')}</span></div>
<div><span class="text-gray-500">업체:</span> <span class="font-medium">${escapeHtml(r.visitor_company)}</span></div>
<div><span class="text-gray-500">인원:</span> <span class="font-medium">${r.visitor_count}명</span></div>
<div><span class="text-gray-500">분류:</span> <span class="font-medium">${escapeHtml(r.category_name || '-')}</span></div>
<div><span class="text-gray-500">작업장:</span> <span class="font-medium">${escapeHtml(r.workplace_name || '-')}</span></div>
<div><span class="text-gray-500">방문일:</span> <span class="font-medium">${formatDate(r.visit_date)}</span></div>
<div><span class="text-gray-500">방문시간:</span> <span class="font-medium">${r.visit_time ? String(r.visit_time).substring(0, 5) : '-'}</span></div>
<div><span class="text-gray-500">목적:</span> <span class="font-medium">${escapeHtml(r.purpose_name || '-')}</span></div>
<div><span class="text-gray-500">상태:</span> ${vrStatusBadge(r.status)}</div>
<div><span class="text-gray-500">신청일:</span> <span class="font-medium">${formatDateTime(r.created_at)}</span></div>
${r.approver_name ? `<div><span class="text-gray-500">처리자:</span> <span class="font-medium">${escapeHtml(r.approver_name)}</span></div>` : ''}
${r.approved_at ? `<div><span class="text-gray-500">처리일:</span> <span class="font-medium">${formatDateTime(r.approved_at)}</span></div>` : ''}
${r.rejection_reason ? `<div class="col-span-2"><span class="text-gray-500">반려사유:</span> <span class="font-medium text-red-600">${escapeHtml(r.rejection_reason)}</span></div>` : ''}
${r.notes ? `<div class="col-span-2"><span class="text-gray-500">비고:</span> <span class="font-medium">${escapeHtml(r.notes)}</span></div>` : ''}
</div>
`;
document.getElementById('detailModal').classList.remove('hidden');
}
function closeDetailModal() {
document.getElementById('detailModal').classList.add('hidden');
}
/* ===== Delete request ===== */
async function doDeleteRequest(id) {
if (!confirm('이 신청을 삭제하시겠습니까?')) return;
try {
await api('/visit-requests/requests/' + id, { method: 'DELETE' });
showToast('삭제되었습니다');
await loadRequests();
} catch (e) {
showToast(e.message, 'error');
}
}
/* ===== Init ===== */
function initVisitManagementPage() {
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('filterStatus').addEventListener('change', loadRequests);
document.getElementById('filterDateFrom').addEventListener('change', loadRequests);
document.getElementById('filterDateTo').addEventListener('change', loadRequests);
loadRequests();
}

View File

@@ -0,0 +1,158 @@
/* ===== Visit Request (출입 신청) ===== */
let myRequests = [];
let categories = [];
let workplaces = [];
let purposes = [];
/* ===== Status badge for visit requests ===== */
function vrStatusBadge(s) {
const m = {
pending: ['badge-amber', '대기중'],
approved: ['badge-green', '승인됨'],
rejected: ['badge-red', '반려됨'],
training_completed: ['badge-blue', '교육완료']
};
const [cls, label] = m[s] || ['badge-gray', s];
return `<span class="badge ${cls}">${label}</span>`;
}
/* ===== Load form data (purposes, categories) ===== */
async function loadFormData() {
try {
const [purposeRes, categoryRes] = await Promise.all([
api('/visit-requests/purposes/active'),
api('/visit-requests/categories')
]);
purposes = purposeRes.data || [];
categories = categoryRes.data || [];
const purposeSelect = document.getElementById('purposeId');
purposeSelect.innerHTML = '<option value="">선택</option>' +
purposes.map(p => `<option value="${p.purpose_id}">${escapeHtml(p.purpose_name)}</option>`).join('');
const categorySelect = document.getElementById('categoryId');
categorySelect.innerHTML = '<option value="">선택</option>' +
categories.map(c => `<option value="${c.category_id}">${escapeHtml(c.category_name)}</option>`).join('');
} catch (e) {
showToast('폼 데이터 로드 실패: ' + e.message, 'error');
}
}
/* ===== Load workplaces by category ===== */
async function loadWorkplaces(categoryId) {
const workplaceSelect = document.getElementById('workplaceId');
if (!categoryId) {
workplaceSelect.innerHTML = '<option value="">분류를 먼저 선택하세요</option>';
workplaces = [];
return;
}
try {
const res = await api('/visit-requests/workplaces?category_id=' + categoryId);
workplaces = res.data || [];
workplaceSelect.innerHTML = '<option value="">선택</option>' +
workplaces.map(w => `<option value="${w.workplace_id}">${escapeHtml(w.workplace_name)}</option>`).join('');
} catch (e) {
showToast('작업장 로드 실패: ' + e.message, 'error');
workplaceSelect.innerHTML = '<option value="">로드 실패</option>';
}
}
/* ===== Load my requests ===== */
async function loadMyRequests() {
try {
const res = await api('/visit-requests/requests?requester_id=' + currentUser.id);
myRequests = res.data || [];
renderMyRequests();
} catch (e) {
showToast('신청 목록 로드 실패: ' + e.message, 'error');
}
}
function renderMyRequests() {
const tbody = document.getElementById('myRequestsBody');
if (!myRequests.length) {
tbody.innerHTML = '<tr><td colspan="9" class="text-center text-gray-400 py-8">신청 내역이 없습니다</td></tr>';
return;
}
tbody.innerHTML = myRequests.map(r => {
const canDelete = r.status === 'pending';
return `<tr>
<td>${formatDate(r.created_at)}</td>
<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>${vrStatusBadge(r.status)}</td>
<td class="text-right">
${canDelete ? `<button onclick="deleteRequest(${r.request_id})" class="text-gray-400 hover:text-red-500 text-xs" title="삭제"><i class="fas fa-trash"></i></button>` : ''}
${r.status === 'rejected' && r.rejection_reason ? `<button onclick="alert('반려 사유: ' + ${JSON.stringify(r.rejection_reason)})" class="text-gray-400 hover:text-gray-600 text-xs ml-1" title="반려사유"><i class="fas fa-info-circle"></i></button>` : ''}
</td>
</tr>`;
}).join('');
}
/* ===== Submit request ===== */
async function submitRequest(e) {
e.preventDefault();
const data = {
visitor_company: document.getElementById('visitorCompany').value.trim(),
visitor_count: parseInt(document.getElementById('visitorCount').value) || 1,
category_id: parseInt(document.getElementById('categoryId').value) || null,
workplace_id: parseInt(document.getElementById('workplaceId').value) || null,
visit_date: document.getElementById('visitDate').value,
visit_time: document.getElementById('visitTime').value,
purpose_id: parseInt(document.getElementById('purposeId').value) || null,
notes: document.getElementById('notes').value.trim() || null
};
if (!data.visitor_company) { showToast('업체명을 입력해주세요', 'error'); return; }
if (!data.category_id) { showToast('작업장 분류를 선택해주세요', 'error'); return; }
if (!data.workplace_id) { showToast('작업장을 선택해주세요', 'error'); return; }
if (!data.visit_date) { showToast('방문일을 선택해주세요', 'error'); return; }
if (!data.visit_time) { showToast('방문시간을 입력해주세요', 'error'); return; }
if (!data.purpose_id) { showToast('방문 목적을 선택해주세요', 'error'); return; }
try {
await api('/visit-requests/requests', { method: 'POST', body: JSON.stringify(data) });
showToast('출입 신청이 완료되었습니다');
document.getElementById('visitRequestForm').reset();
document.getElementById('workplaceId').innerHTML = '<option value="">분류를 먼저 선택하세요</option>';
document.getElementById('visitorCount').value = '1';
await loadMyRequests();
} catch (e) {
showToast(e.message, 'error');
}
}
/* ===== Delete request ===== */
async function deleteRequest(id) {
if (!confirm('이 신청을 삭제하시겠습니까?')) return;
try {
await api('/visit-requests/requests/' + id, { method: 'DELETE' });
showToast('삭제되었습니다');
await loadMyRequests();
} catch (e) {
showToast(e.message, 'error');
}
}
/* ===== Init ===== */
function initVisitRequestPage() {
if (!initAuth()) return;
// Set default visit date to today
const today = new Date().toISOString().substring(0, 10);
document.getElementById('visitDate').value = today;
// Category change -> load workplaces
document.getElementById('categoryId').addEventListener('change', function() {
loadWorkplaces(this.value);
});
document.getElementById('visitRequestForm').addEventListener('submit', submitRequest);
loadFormData();
loadMyRequests();
}