Feat: 근무처 목록 및 배지 표시, 엑셀 업로드 진행 팝업 추가
- 근무처 마커에 할당 인원 배지 표시 - 근무처 목록 패널 추가 (탭 전환 가능) - 탭 생성 후 F5 없이 즉시 표시되도록 버그 수정 - VWorld/카카오 지오코딩 API 지원 추가 (type 파라미터 포함) - 엑셀 업로드 중 진행 상황 팝업 모달 추가 - 근무처 목록 근무자 표시 3명 → 4명으로 변경 - 지오코딩 진행 상황 폴링 API 추가
This commit is contained in:
@@ -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'); }
|
||||
|
||||
Reference in New Issue
Block a user