Feat: 근무처 목록 및 배지 표시, 엑셀 업로드 진행 팝업 추가

- 근무처 마커에 할당 인원 배지 표시
- 근무처 목록 패널 추가 (탭 전환 가능)
- 탭 생성 후 F5 없이 즉시 표시되도록 버그 수정
- VWorld/카카오 지오코딩 API 지원 추가 (type 파라미터 포함)
- 엑셀 업로드 중 진행 상황 팝업 모달 추가
- 근무처 목록 근무자 표시 3명 → 4명으로 변경
- 지오코딩 진행 상황 폴링 API 추가
This commit is contained in:
user01
2026-05-13 12:45:22 +09:00
parent a5db41187c
commit 43f23c52fc
3 changed files with 680 additions and 59 deletions

View File

@@ -32,11 +32,16 @@
<div id="main">
<div id="sidebar">
<div class="sidebar-header">
<h2><i class="fas fa-users"></i> 근무자</h2>
<span id="workerCount" class="count-badge">0</span>
<div class="sidebar-tabs">
<div class="sidebar-tab active" data-tab="workers" onclick="switchSidebarTab('workers')">
<i class="fas fa-users"></i> 근무자 <span id="workerCount" class="count">0/0</span>
</div>
<div class="sidebar-tab" data-tab="workplaces" onclick="switchSidebarTab('workplaces')">
<i class="fas fa-building"></i> 근무처 <span id="workplaceCount" class="count">0</span>
</div>
</div>
<div id="workerList" class="worker-list"></div>
<div id="workerList" class="worker-list active"></div>
<div id="workplaceList" class="workplace-list"></div>
</div>
<div id="mapContainer">
<div id="map"></div>
@@ -46,6 +51,27 @@
<div id="toast" class="toast hidden"></div>
<div id="loadingModal" class="modal-overlay hidden">
<div class="modal-content">
<div class="modal-header">
<h3><i class="fas fa-file-upload"></i> 엑셀 파일 처리 중</h3>
</div>
<div class="modal-body">
<div class="loading-spinner">
<i class="fas fa-spinner fa-spin"></i>
</div>
<div id="loadingMessage" class="loading-message">파일을 읽는 중...</div>
<div id="loadingProgress" class="loading-progress hidden">
<div class="progress-bar">
<div id="progressFill" class="progress-fill" style="width: 0%"></div>
</div>
<div id="progressText" class="progress-text">0 / 0</div>
</div>
<div id="loadingLog" class="loading-log"></div>
</div>
</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const YONGSAN = [37.5326, 126.9906];
@@ -60,11 +86,14 @@ const WORKER_ASSIGNED_ICON = L.divIcon({
html: '<i class="fas fa-user-check"></i>',
iconSize: [36, 36], iconAnchor: [18, 36], popupAnchor: [0, -40],
});
const WORKPLACE_ICON = L.divIcon({
className: 'marker-icon workplace-marker',
html: '<i class="fas fa-building"></i>',
iconSize: [42, 42], iconAnchor: [21, 42], popupAnchor: [0, -40],
});
function makeWorkplaceIcon(count) {
const badge = count > 0 ? `<div class="marker-badge">${count}</div>` : '';
return L.divIcon({
className: 'marker-icon workplace-marker',
html: `<i class="fas fa-building"></i>${badge}`,
iconSize: [42, 42], iconAnchor: [21, 42], popupAnchor: [0, -40],
});
}
let markers = { workers: {}, workplaces: {} };
let tabs = [];
@@ -114,6 +143,7 @@ async function addTab() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name.trim() || undefined }),
});
tabs.push({ id: r.id, name: r.name, workerCount: 0, assignCount: 0 });
tabDataCache[r.id] = { workers: [], workplaces: [], assignments: {} };
activeTabId = r.activeTab;
render();
@@ -233,20 +263,95 @@ function findNearestWorkplace(lat, lng) {
return best;
}
function showLoadingModal() {
document.getElementById('loadingModal').classList.remove('hidden');
}
function hideLoadingModal() {
document.getElementById('loadingModal').classList.add('hidden');
}
function updateLoadingModal(msg, progress = null, total = null) {
document.getElementById('loadingMessage').textContent = msg;
const progEl = document.getElementById('loadingProgress');
const fillEl = document.getElementById('progressFill');
const textEl = document.getElementById('progressText');
const logEl = document.getElementById('loadingLog');
if (progress !== null && total !== null && total > 0) {
progEl.classList.remove('hidden');
const pct = (progress / total) * 100;
fillEl.style.width = pct + '%';
textEl.textContent = `${progress} / ${total}`;
} else {
progEl.classList.add('hidden');
}
}
async function pollUploadProgress() {
try {
const r = await api('/api/upload-progress');
updateLoadingModal(r.message, r.current, r.total);
if (r.logs && r.logs.length > 0) {
const logEl = document.getElementById('loadingLog');
logEl.innerHTML = r.logs.map(l => '<div>' + escHtml(l) + '</div>').join('');
logEl.scrollTop = logEl.scrollHeight;
}
return r.active;
} catch {
return false;
}
}
async function uploadExcel(e) {
const file = e.target.files[0];
if (!file || !activeTabId) return;
showLoadingModal();
updateLoadingModal('파일 업로드 준비 중...');
const form = new FormData();
form.append('file', file);
form.append('tab', activeTabId);
showToast('업로드 중...', 'info');
let pollInterval = null;
try {
pollInterval = setInterval(async () => {
const active = await pollUploadProgress();
if (!active) {
clearInterval(pollInterval);
pollInterval = null;
}
}, 500);
const r = await api('/api/upload', { method: 'POST', body: form });
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
await pollUploadProgress();
console.log('[업로드 결과] 근무자:', r.workers?.length || 0, '명, 근무처:', r.workplaces?.length || 0, '개');
console.log('[근무처 목록]', r.workplaces);
console.log('[근무자 목록]', r.workers);
tabDataCache[activeTabId] = r;
render();
await loadTabs();
showToast(`파일 로드 완료: ${file.name}`, 'success');
updateLoadingModal('완료!');
setTimeout(hideLoadingModal, 500);
showToast(`파일 로드 완료: 근무자 ${r.workers?.length || 0}명, 근무처 ${r.workplaces?.length || 0}`, 'success');
} catch (err) {
if (pollInterval) {
clearInterval(pollInterval);
}
console.error('[업로드 실패]', err);
hideLoadingModal();
showToast('업로드 실패: ' + err.message, 'error');
}
e.target.value = '';
@@ -260,6 +365,7 @@ function render() {
renderWorkplaceMarkers();
renderWorkerMarkers();
renderWorkerList();
renderWorkplaceList();
updateBadges();
}
@@ -269,6 +375,11 @@ function clearMap() {
markers = { workers: {}, workplaces: {} };
}
function getWorkplaceAssigneeCount(wpId) {
const d = currentData();
return d.workers.filter(w => d.assignments[w.id] === wpId).length;
}
function getWorkplaceAssigneesHtml(wpId) {
const d = currentData();
const assigned = d.workers.filter(w => d.assignments[w.id] === wpId);
@@ -281,7 +392,8 @@ function renderWorkplaceMarkers() {
const d = currentData();
const bounds = [];
for (const wp of d.workplaces) {
const marker = L.marker([wp.lat, wp.lng], { icon: WORKPLACE_ICON })
const cnt = getWorkplaceAssigneeCount(wp.id);
const marker = L.marker([wp.lat, wp.lng], { icon: makeWorkplaceIcon(cnt) })
.addTo(map).bindPopup(`
<div class="popup-content">
<strong><i class="fas fa-building"></i> ${escHtml(wp.name)}</strong><br>
@@ -319,8 +431,10 @@ function handleWorkerDragEnd(workerId, marker) {
const d = currentData();
const pos = marker.getLatLng();
const nearest = findNearestWorkplace(pos.lat, pos.lng);
const oldWpId = d.assignments[workerId];
if (nearest) {
if (oldWpId === nearest.id) return;
api('/api/assign', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -330,14 +444,15 @@ function handleWorkerDragEnd(workerId, marker) {
marker.setLatLng([nearest.lat, nearest.lng]);
marker.setIcon(WORKER_ASSIGNED_ICON);
marker.setPopupContent(buildWorkerPopup(workerId));
if (oldWpId) updateWorkplacePopup(oldWpId);
updateWorkplacePopup(nearest.id);
renderWorkerList();
renderWorkplaceList();
updateBadges();
showToast(`${getWorkerName(workerId)}${nearest.name} 배치됨`, 'success');
}).catch(() => showToast('배치 저장 실패', 'error'));
} else if (d.assignments[workerId]) {
const oldWpId = d.assignments[workerId];
} else if (oldWpId) {
api('/api/unassign', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -350,6 +465,7 @@ function handleWorkerDragEnd(workerId, marker) {
marker.setPopupContent(buildWorkerPopup(workerId));
updateWorkplacePopup(oldWpId);
renderWorkerList();
renderWorkplaceList();
updateBadges();
showToast('배치가 취소되었습니다.', 'info');
}).catch(() => showToast('배치 취소 실패', 'error'));
@@ -415,11 +531,13 @@ function renderWorkerList() {
}
}
function updateWorkplacePopup(wpId) {
function updateWorkplaceMarker(wpId) {
const d = currentData();
const marker = markers.workplaces[wpId];
const wp = d.workplaces.find(p => p.id === wpId);
if (!marker || !wp) return;
const cnt = getWorkplaceAssigneeCount(wpId);
marker.setIcon(makeWorkplaceIcon(cnt));
marker.setPopupContent(`
<div class="popup-content">
<strong><i class="fas fa-building"></i> ${escHtml(wp.name)}</strong><br>
@@ -428,11 +546,57 @@ function updateWorkplacePopup(wpId) {
</div>`);
}
function updateWorkplacePopup(wpId) {
updateWorkplaceMarker(wpId);
}
let currentSidebarTab = 'workers';
function switchSidebarTab(tab) {
currentSidebarTab = tab;
document.querySelectorAll('.sidebar-tab').forEach(el => el.classList.toggle('active', el.dataset.tab === tab));
document.getElementById('workerList').classList.toggle('hidden', tab !== 'workers');
document.getElementById('workplaceList').classList.toggle('active', tab === 'workplaces');
}
function renderWorkplaceList() {
const d = currentData();
const list = document.getElementById('workplaceList');
list.innerHTML = '';
for (const wp of d.workplaces) {
const cnt = getWorkplaceAssigneeCount(wp.id);
const assigned = d.workers.filter(w => d.assignments[w.id] === wp.id);
const card = document.createElement('div');
card.className = 'workplace-card';
card.innerHTML = `
<div class="workplace-card-avatar">
<i class="fas fa-building"></i>
</div>
<div class="workplace-card-info">
<div class="workplace-card-name">${escHtml(wp.name)}</div>
<div class="workplace-card-address">${escHtml(wp.address)}</div>
<div class="workplace-card-assignment">
<i class="fas fa-users"></i> ${cnt}명 배치
${assigned.length > 0 ? `<span style="font-weight:400;color:#6b7280;margin-left:6px;">${assigned.slice(0, 4).map(w => escHtml(w.name)).join(', ')}${assigned.length > 4 ? ' 외 ' + (assigned.length - 4) + '명' : ''}</span>` : ''}
</div>
</div>`;
card.addEventListener('click', () => {
map.setView([wp.lat, wp.lng], 16);
if (markers.workplaces[wp.id]) markers.workplaces[wp.id].openPopup();
});
list.appendChild(card);
}
if (d.workplaces.length === 0) {
list.innerHTML = '<div class="empty-state">엑셀 파일을 업로드하세요</div>';
}
}
function updateBadges() {
const d = currentData();
const assigned = Object.keys(d.assignments).length;
const total = d.workers.length;
document.getElementById('workerCount').textContent = `${assigned}/${total}`;
document.getElementById('workplaceCount').textContent = d.workplaces.length;
const badge = document.getElementById('statusBadge');
badge.textContent = assigned > 0 ? `배치 ${assigned}/${total}` : '';
badge.className = `status-badge ${!total ? '' : assigned === total ? 'complete' : assigned > 0 ? 'partial' : ''}`;
@@ -443,7 +607,8 @@ function updateBadges() {
async function doAssignWorker(workerId, workplaceId) {
if (!activeTabId) return;
const d = currentData();
if (d.assignments[workerId] === workplaceId) return;
const oldWpId = d.assignments[workerId];
if (oldWpId === workplaceId) return;
try {
const r = await api('/api/assign', {
method: 'POST',
@@ -458,8 +623,10 @@ async function doAssignWorker(workerId, workplaceId) {
marker.setIcon(WORKER_ASSIGNED_ICON);
marker.setPopupContent(buildWorkerPopup(workerId));
}
if (oldWpId) updateWorkplacePopup(oldWpId);
updateWorkplacePopup(workplaceId);
renderWorkerList();
renderWorkplaceList();
updateBadges();
showToast('배치가 저장되었습니다.', 'success');
} catch { showToast('배치 저장 실패', 'error'); }
@@ -485,6 +652,7 @@ async function unassignWorker(workerId, fromPopup) {
}
if (oldWpId) updateWorkplacePopup(oldWpId);
renderWorkerList();
renderWorkplaceList();
updateBadges();
if (fromPopup) map.closePopup();
showToast('배치가 취소되었습니다.', 'info');
@@ -512,6 +680,7 @@ async function resetAssignments() {
}
for (const wp of d.workplaces) updateWorkplacePopup(wp.id);
renderWorkerList();
renderWorkplaceList();
updateBadges();
showToast('모든 배치가 초기화되었습니다.', 'info');
} catch { showToast('초기화 실패', 'error'); }