feat(tksafety): 통합 출입신고 관리 시스템 구현

- DB 마이그레이션: request_type, visitor_name, department_id, check_in/out_time 컬럼 + status ENUM 확장
- 4소스 UNION 대시보드: 방문(외부/내부) + TBM + 협력업체 통합 조회
- 체크인/체크아웃 API + 내부 출입 신고(승인 불필요) 지원
- 통합 출입 현황판 페이지 신규 (entry-dashboard.html)
- 출입 신청/관리 페이지에 유형 필터 + 체크인/아웃 버튼 추가
- safety_entry_dashboard 권한 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-03-13 14:24:13 +09:00
parent 5a062759c5
commit 6a20056e05
16 changed files with 810 additions and 57 deletions

View File

@@ -87,6 +87,7 @@ function renderNavbar() {
{ 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: '/entry-dashboard.html', icon: 'fa-id-card-alt', label: '출입 현황판', match: ['entry-dashboard.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 },

View File

@@ -0,0 +1,136 @@
/* ===== Entry Dashboard (출입 현황판) ===== */
let dashboardData = [];
let currentSourceFilter = '';
let refreshTimer = null;
/* ===== Source/Status badges ===== */
function sourceBadge(s) {
const m = {
tbm: ['badge-blue', 'TBM'],
partner: ['badge-green', '협력업체'],
visit: ['badge-amber', '방문']
};
const [cls, label] = m[s] || ['badge-gray', s];
return `<span class="badge ${cls}">${label}</span>`;
}
function entryStatusBadge(s) {
const m = {
checked_in: ['badge-blue', '체크인'],
checked_out: ['badge-gray', '체크아웃'],
approved: ['badge-green', '승인'],
training_completed: ['badge-blue', '교육완료'],
absent: ['badge-red', '불참']
};
const [cls, label] = m[s] || ['badge-gray', s];
return `<span class="badge ${cls}">${label}</span>`;
}
/* ===== Load dashboard data ===== */
async function loadDashboard() {
const date = document.getElementById('dashboardDate').value;
try {
const [dashRes, statsRes] = await Promise.all([
api('/visit-requests/entry-dashboard?date=' + date),
api('/visit-requests/entry-dashboard/stats?date=' + date)
]);
dashboardData = dashRes.data || [];
updateStats(statsRes.data || {});
renderDashboard();
} catch (e) {
showToast('대시보드 로드 실패: ' + e.message, 'error');
}
}
function updateStats(stats) {
const total = stats.external_visit + stats.internal_visit + stats.partner + stats.tbm;
document.getElementById('statTotal').textContent = total;
document.getElementById('statTbm').textContent = stats.tbm;
document.getElementById('statPartner').textContent = stats.partner;
document.getElementById('statExternal').textContent = stats.external_visit;
document.getElementById('statInternal').textContent = stats.internal_visit;
}
/* ===== Render table ===== */
function renderDashboard() {
const tbody = document.getElementById('dashboardBody');
const filtered = currentSourceFilter
? dashboardData.filter(r => r.source === currentSourceFilter)
: dashboardData;
if (!filtered.length) {
tbody.innerHTML = '<tr><td colspan="9" class="text-center text-gray-400 py-8">데이터가 없습니다</td></tr>';
return;
}
tbody.innerHTML = filtered.map(r => {
const name = escapeHtml(r.visitor_name || '-');
const org = escapeHtml(r.visitor_company || '-');
const workplace = escapeHtml(r.workplace_name || '-');
const inTime = r.check_in_time ? String(r.check_in_time).substring(11, 16) : (r.entry_time ? String(r.entry_time).substring(0, 5) : '-');
const outTime = r.check_out_time ? String(r.check_out_time).substring(11, 16) : '-';
const purpose = escapeHtml(r.purpose_name || '-');
const note = r.source_note ? `<span class="text-xs text-gray-500 italic">${escapeHtml(r.source_note)}</span>` : '';
const count = r.visitor_count > 1 ? ` <span class="text-xs text-gray-400">(${r.visitor_count}명)</span>` : '';
return `<tr>
<td>${sourceBadge(r.source)}</td>
<td>${name}${count}</td>
<td>${org}</td>
<td>${workplace}</td>
<td>${inTime}</td>
<td>${outTime}</td>
<td>${purpose}</td>
<td>${entryStatusBadge(r.status)}</td>
<td class="hide-mobile">${note}</td>
</tr>`;
}).join('');
}
/* ===== Tab filter ===== */
function filterSource(source) {
currentSourceFilter = source;
document.querySelectorAll('.source-tab').forEach(t => {
t.classList.toggle('active', t.dataset.source === source);
});
renderDashboard();
}
/* ===== Auto refresh ===== */
function setupAutoRefresh() {
const cb = document.getElementById('autoRefresh');
cb.addEventListener('change', () => {
if (cb.checked) startAutoRefresh();
else stopAutoRefresh();
});
startAutoRefresh();
}
function startAutoRefresh() {
stopAutoRefresh();
refreshTimer = setInterval(loadDashboard, 180000); // 3분
}
function stopAutoRefresh() {
if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; }
}
/* ===== Init ===== */
function initEntryDashboard() {
if (!initAuth()) return;
const today = new Date().toISOString().substring(0, 10);
document.getElementById('dashboardDate').value = today;
document.getElementById('dashboardDate').addEventListener('change', loadDashboard);
// Source tab styling
const style = document.createElement('style');
style.textContent = `.source-tab { border-bottom-color: transparent; color: #6b7280; cursor: pointer; }
.source-tab:hover { color: #374151; background: #f9fafb; }
.source-tab.active { border-bottom-color: #2563eb; color: #2563eb; font-weight: 600; }`;
document.head.appendChild(style);
loadDashboard();
setupAutoRefresh();
}

View File

@@ -8,12 +8,20 @@ function vrStatusBadge(s) {
pending: ['badge-amber', '대기중'],
approved: ['badge-green', '승인됨'],
rejected: ['badge-red', '반려됨'],
training_completed: ['badge-blue', '교육완료']
training_completed: ['badge-blue', '교육완료'],
checked_in: ['badge-blue', '체크인'],
checked_out: ['badge-gray', '체크아웃']
};
const [cls, label] = m[s] || ['badge-gray', s];
return `<span class="badge ${cls}">${label}</span>`;
}
function requestTypeBadge(t) {
return t === 'internal'
? '<span class="badge badge-blue">내부</span>'
: '<span class="badge badge-amber">외부</span>';
}
/* ===== Load requests ===== */
async function loadRequests() {
try {
@@ -21,9 +29,11 @@ async function loadRequests() {
const status = document.getElementById('filterStatus').value;
const dateFrom = document.getElementById('filterDateFrom').value;
const dateTo = document.getElementById('filterDateTo').value;
const type = document.getElementById('filterType').value;
if (status) params.set('status', status);
if (dateFrom) params.set('start_date', dateFrom);
if (dateTo) params.set('end_date', dateTo);
if (type) params.set('request_type', type);
const res = await api('/visit-requests/requests?' + params.toString());
allRequests = res.data || [];
@@ -35,12 +45,14 @@ async function loadRequests() {
}
function renderStats() {
const counts = { pending: 0, approved: 0, rejected: 0, training_completed: 0 };
const counts = { pending: 0, approved: 0, rejected: 0, training_completed: 0, checked_in: 0, checked_out: 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;
document.getElementById('statCheckedIn').textContent = counts.checked_in;
document.getElementById('statCheckedOut').textContent = counts.checked_out;
}
function renderRequestsTable() {
@@ -51,6 +63,8 @@ function renderRequestsTable() {
}
tbody.innerHTML = allRequests.map(r => {
let actions = '';
// 승인/반려 (pending만)
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="승인">
@@ -60,15 +74,32 @@ function renderRequestsTable() {
<i class="fas fa-times"></i>
</button>`;
}
// 체크인 버튼 (approved 또는 training_completed)
const canCheckIn = (r.request_type === 'internal' && r.status === 'approved') ||
(['approved', 'training_completed'].includes(r.status));
if (canCheckIn) {
actions += ` <button onclick="doCheckIn(${r.request_id})" class="text-blue-600 hover:text-blue-800 text-xs px-2 py-1 border border-blue-200 rounded hover:bg-blue-50" title="체크인"><i class="fas fa-sign-in-alt"></i></button>`;
}
// 체크아웃 버튼 (checked_in)
if (r.status === 'checked_in') {
actions += ` <button onclick="doCheckOut(${r.request_id})" class="text-gray-600 hover:text-gray-800 text-xs px-2 py-1 border border-gray-200 rounded hover:bg-gray-50" title="체크아웃"><i class="fas fa-sign-out-alt"></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>`;
}
const displayName = r.request_type === 'internal'
? escapeHtml(r.visitor_name || r.requester_full_name || '-')
: escapeHtml(r.visitor_company);
return `<tr>
<td>${formatDate(r.created_at)}</td>
<td>${requestTypeBadge(r.request_type)}</td>
<td>${escapeHtml(r.requester_full_name || r.requester_name || '-')}</td>
<td>${escapeHtml(r.visitor_company)}</td>
<td>${displayName}</td>
<td class="text-center">${r.visitor_count}</td>
<td>${escapeHtml(r.workplace_name || '-')}</td>
<td>${formatDate(r.visit_date)}</td>
@@ -80,13 +111,40 @@ function renderRequestsTable() {
}).join('');
}
/* ===== Check-in / Check-out ===== */
async function doCheckIn(id) {
if (!confirm('체크인 처리하시겠습니까?')) return;
try {
await api('/visit-requests/requests/' + id + '/check-in', { method: 'PUT', body: JSON.stringify({}) });
showToast('체크인 완료');
await loadRequests();
} catch (e) {
showToast(e.message, 'error');
}
}
async function doCheckOut(id) {
if (!confirm('체크아웃 처리하시겠습니까?')) return;
try {
await api('/visit-requests/requests/' + id + '/check-out', { method: 'PUT', body: JSON.stringify({}) });
showToast('체크아웃 완료');
await loadRequests();
} catch (e) {
showToast(e.message, 'error');
}
}
/* ===== Approve Modal ===== */
function openApproveModal(id) {
const r = allRequests.find(x => x.request_id === id);
if (!r) return;
actionRequestId = id;
const displayName = r.request_type === 'internal'
? escapeHtml(r.visitor_name || r.requester_full_name || '-')
: escapeHtml(r.visitor_company);
document.getElementById('approveDetail').innerHTML = `
<p><strong>업체:</strong> ${escapeHtml(r.visitor_company)}</p>
<p><strong>유형:</strong> ${r.request_type === 'internal' ? '내부 출입' : '외부 방문'}</p>
<p><strong>업체/이름:</strong> ${displayName}</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>
@@ -120,7 +178,7 @@ function openRejectModal(id) {
if (!r) return;
actionRequestId = id;
document.getElementById('rejectDetail').innerHTML = `
<p><strong>업체:</strong> ${escapeHtml(r.visitor_company)}</p>
<p><strong>업체/이름:</strong> ${escapeHtml(r.visitor_company || r.visitor_name || '-')}</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>
`;
@@ -158,8 +216,10 @@ function openDetailModal(id) {
if (!r) return;
document.getElementById('detailContent').innerHTML = `
<div class="grid grid-cols-2 gap-3">
<div><span class="text-gray-500">유형:</span> ${r.request_type === 'internal' ? '내부 출입' : '외부 방문'}</div>
<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">${escapeHtml(r.visitor_company || '-')}</span></div>
<div><span class="text-gray-500">방문자:</span> <span class="font-medium">${escapeHtml(r.visitor_name || '-')}</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>
@@ -168,6 +228,9 @@ function openDetailModal(id) {
<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.check_in_time ? `<div><span class="text-gray-500">체크인:</span> <span class="font-medium">${formatDateTime(r.check_in_time)}</span></div>` : ''}
${r.check_out_time ? `<div><span class="text-gray-500">체크아웃:</span> <span class="font-medium">${formatDateTime(r.check_out_time)}</span></div>` : ''}
${r.department_name ? `<div><span class="text-gray-500">부서:</span> <span class="font-medium">${escapeHtml(r.department_name)}</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>` : ''}
@@ -209,6 +272,7 @@ function initVisitManagementPage() {
}
document.getElementById('filterStatus').addEventListener('change', loadRequests);
document.getElementById('filterType').addEventListener('change', loadRequests);
document.getElementById('filterDateFrom').addEventListener('change', loadRequests);
document.getElementById('filterDateTo').addEventListener('change', loadRequests);

View File

@@ -3,6 +3,7 @@ let myRequests = [];
let categories = [];
let workplaces = [];
let purposes = [];
let departments = [];
/* ===== Status badge for visit requests ===== */
function vrStatusBadge(s) {
@@ -10,21 +11,40 @@ function vrStatusBadge(s) {
pending: ['badge-amber', '대기중'],
approved: ['badge-green', '승인됨'],
rejected: ['badge-red', '반려됨'],
training_completed: ['badge-blue', '교육완료']
training_completed: ['badge-blue', '교육완료'],
checked_in: ['badge-blue', '체크인'],
checked_out: ['badge-gray', '체크아웃']
};
const [cls, label] = m[s] || ['badge-gray', s];
return `<span class="badge ${cls}">${label}</span>`;
}
/* ===== Load form data (purposes, categories) ===== */
function requestTypeBadge(t) {
return t === 'internal'
? '<span class="badge badge-blue">내부</span>'
: '<span class="badge badge-amber">외부</span>';
}
/* ===== Toggle form fields based on request type ===== */
function toggleRequestType() {
const isInternal = document.querySelector('input[name="requestType"]:checked')?.value === 'internal';
document.getElementById('companyField').style.display = isInternal ? 'none' : '';
document.getElementById('countField').style.display = isInternal ? 'none' : '';
document.getElementById('departmentField').style.display = isInternal ? '' : 'none';
document.getElementById('visitorNameRequired').classList.toggle('hidden', !isInternal);
}
/* ===== Load form data (purposes, categories, departments) ===== */
async function loadFormData() {
try {
const [purposeRes, categoryRes] = await Promise.all([
const [purposeRes, categoryRes, deptRes] = await Promise.all([
api('/visit-requests/purposes/active'),
api('/visit-requests/categories')
api('/visit-requests/categories'),
api('/visit-requests/departments')
]);
purposes = purposeRes.data || [];
categories = categoryRes.data || [];
departments = deptRes.data || [];
const purposeSelect = document.getElementById('purposeId');
purposeSelect.innerHTML = '<option value="">선택</option>' +
@@ -33,6 +53,10 @@ async function loadFormData() {
const categorySelect = document.getElementById('categoryId');
categorySelect.innerHTML = '<option value="">선택</option>' +
categories.map(c => `<option value="${c.category_id}">${escapeHtml(c.category_name)}</option>`).join('');
const deptSelect = document.getElementById('departmentId');
deptSelect.innerHTML = '<option value="">선택 (선택사항)</option>' +
departments.map(d => `<option value="${d.department_id}">${escapeHtml(d.name)}</option>`).join('');
} catch (e) {
showToast('폼 데이터 로드 실패: ' + e.message, 'error');
}
@@ -76,29 +100,74 @@ function renderMyRequests() {
}
tbody.innerHTML = myRequests.map(r => {
const canDelete = r.status === 'pending';
const displayName = r.request_type === 'internal'
? escapeHtml(r.visitor_name || r.requester_full_name || '-')
: escapeHtml(r.visitor_company);
// 체크인/체크아웃 버튼
let checkBtn = '';
const canCheckIn = (r.request_type === 'internal' && r.status === 'approved') ||
(r.request_type === 'external' && ['approved', 'training_completed'].includes(r.status));
if (canCheckIn) {
checkBtn = `<button onclick="doCheckIn(${r.request_id})" class="text-blue-600 hover:text-blue-800 text-xs px-2 py-1 border border-blue-200 rounded hover:bg-blue-50" title="체크인"><i class="fas fa-sign-in-alt"></i></button>`;
}
if (r.status === 'checked_in') {
checkBtn = `<button onclick="doCheckOut(${r.request_id})" class="text-gray-600 hover:text-gray-800 text-xs px-2 py-1 border border-gray-200 rounded hover:bg-gray-50" title="체크아웃"><i class="fas fa-sign-out-alt"></i></button>`;
}
return `<tr>
<td>${formatDate(r.created_at)}</td>
<td>${escapeHtml(r.visitor_company)}</td>
<td>${requestTypeBadge(r.request_type)}</td>
<td>${displayName}</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>` : ''}
<td class="text-right whitespace-nowrap">
${checkBtn}
${canDelete ? `<button onclick="deleteRequest(${r.request_id})" class="text-gray-400 hover:text-red-500 text-xs ml-1" 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('');
}
/* ===== Check-in / Check-out ===== */
async function doCheckIn(id) {
if (!confirm('체크인 하시겠습니까?')) return;
try {
await api('/visit-requests/requests/' + id + '/check-in', { method: 'PUT', body: JSON.stringify({}) });
showToast('체크인 완료');
await loadMyRequests();
} catch (e) {
showToast(e.message, 'error');
}
}
async function doCheckOut(id) {
if (!confirm('체크아웃 하시겠습니까?')) return;
try {
await api('/visit-requests/requests/' + id + '/check-out', { method: 'PUT', body: JSON.stringify({}) });
showToast('체크아웃 완료');
await loadMyRequests();
} catch (e) {
showToast(e.message, 'error');
}
}
/* ===== Submit request ===== */
async function submitRequest(e) {
e.preventDefault();
const requestType = document.querySelector('input[name="requestType"]:checked')?.value || 'external';
const isInternal = requestType === 'internal';
const data = {
visitor_company: document.getElementById('visitorCompany').value.trim(),
visitor_count: parseInt(document.getElementById('visitorCount').value) || 1,
request_type: requestType,
visitor_company: isInternal ? '내부' : document.getElementById('visitorCompany').value.trim(),
visitor_name: document.getElementById('visitorName').value.trim() || null,
visitor_count: isInternal ? 1 : (parseInt(document.getElementById('visitorCount').value) || 1),
department_id: isInternal ? (parseInt(document.getElementById('departmentId').value) || null) : null,
category_id: parseInt(document.getElementById('categoryId').value) || null,
workplace_id: parseInt(document.getElementById('workplaceId').value) || null,
visit_date: document.getElementById('visitDate').value,
@@ -107,7 +176,11 @@ async function submitRequest(e) {
notes: document.getElementById('notes').value.trim() || null
};
if (!data.visitor_company) { showToast('업체명을 입력해주세요', 'error'); return; }
if (isInternal) {
if (!data.visitor_name) { showToast('방문자 이름을 입력해주세요', 'error'); return; }
} else {
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; }
@@ -116,10 +189,11 @@ async function submitRequest(e) {
try {
await api('/visit-requests/requests', { method: 'POST', body: JSON.stringify(data) });
showToast('출입 신청이 완료되었습니다');
showToast(isInternal ? '내부 출입 신고가 완료되었습니다' : '출입 신청이 완료되었습니다');
document.getElementById('visitRequestForm').reset();
document.getElementById('workplaceId').innerHTML = '<option value="">분류를 먼저 선택하세요</option>';
document.getElementById('visitorCount').value = '1';
toggleRequestType();
await loadMyRequests();
} catch (e) {
showToast(e.message, 'error');
@@ -146,6 +220,12 @@ function initVisitRequestPage() {
const today = new Date().toISOString().substring(0, 10);
document.getElementById('visitDate').value = today;
// Request type toggle
document.querySelectorAll('input[name="requestType"]').forEach(r => {
r.addEventListener('change', toggleRequestType);
});
toggleRequestType();
// Category change -> load workplaces
document.getElementById('categoryId').addEventListener('change', function() {
loadWorkplaces(this.value);