Feat: 근무처 주소 중복 시 표시/내보내기 통합, 근무자 클릭 시 지도 이동
This commit is contained in:
@@ -15,9 +15,13 @@
|
||||
<span class="header-subtitle">서울특별시 용산구 근무 배치 시스템</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<form id="uploadForm" enctype="multipart/form-data">
|
||||
<label for="fileInput" class="btn btn-primary"><i class="fas fa-upload"></i> 로컬 업로드</label>
|
||||
<input type="file" id="fileInput" accept=".xlsx,.xls" hidden />
|
||||
<form id="workerUploadForm" enctype="multipart/form-data">
|
||||
<label for="workerFileInput" class="btn btn-primary"><i class="fas fa-users"></i> 근무자 업로드</label>
|
||||
<input type="file" id="workerFileInput" accept=".xlsx,.xls" hidden />
|
||||
</form>
|
||||
<form id="workplaceUploadForm" enctype="multipart/form-data">
|
||||
<label for="workplaceFileInput" class="btn" style="background:#4a6cf7;color:#fff;"><i class="fas fa-building"></i> 근무처 업로드</label>
|
||||
<input type="file" id="workplaceFileInput" accept=".xlsx,.xls" hidden />
|
||||
</form>
|
||||
<button class="btn btn-info" onclick="openGoogleDriveModal()"><i class="fab fa-google-drive"></i> 구글 드라이브</button>
|
||||
<button class="btn btn-success" onclick="exportExcel()"><i class="fas fa-download"></i> 내보내기</button>
|
||||
@@ -81,6 +85,11 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div style="margin-bottom: 16px;">
|
||||
<label style="display: block; margin-bottom: 8px; font-weight: bold;">데이터 유형</label>
|
||||
<div style="display: flex; gap: 16px; margin-bottom: 16px;">
|
||||
<label style="cursor:pointer;"><input type="radio" name="gdType" value="worker" checked /> <i class="fas fa-users"></i> 근무자</label>
|
||||
<label style="cursor:pointer;"><input type="radio" name="gdType" value="workplace" /> <i class="fas fa-building"></i> 근무처</label>
|
||||
</div>
|
||||
<label style="display: block; margin-bottom: 8px; font-weight: bold;">파일 ID 또는 공유 링크</label>
|
||||
<input type="text" id="googleDriveInput" placeholder="파일 ID 또는 https://drive.google.com/file/d/FILE_ID/view"
|
||||
style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; box-sizing: border-box;" />
|
||||
@@ -321,7 +330,8 @@ async function init() {
|
||||
doAssignWorker(workerId, nearest.id);
|
||||
});
|
||||
|
||||
document.getElementById('fileInput').addEventListener('change', uploadExcel);
|
||||
document.getElementById('workerFileInput').addEventListener('change', uploadWorkerExcel);
|
||||
document.getElementById('workplaceFileInput').addEventListener('change', uploadWorkplaceExcel);
|
||||
loadTabs();
|
||||
}
|
||||
|
||||
@@ -379,12 +389,12 @@ async function pollUploadProgress() {
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadExcel(e) {
|
||||
async function uploadWorkerExcel(e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file || !activeTabId) return;
|
||||
|
||||
showLoadingModal();
|
||||
updateLoadingModal('파일 업로드 준비 중...');
|
||||
updateLoadingModal('근무자 파일 업로드 준비 중...');
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
@@ -401,7 +411,7 @@ async function uploadExcel(e) {
|
||||
}
|
||||
}, 500);
|
||||
|
||||
const r = await api('/api/upload', { method: 'POST', body: form });
|
||||
const r = await api('/api/upload-workers', { method: 'POST', body: form });
|
||||
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval);
|
||||
@@ -410,22 +420,69 @@ async function uploadExcel(e) {
|
||||
|
||||
await pollUploadProgress();
|
||||
|
||||
console.log('[업로드 결과] 근무자:', r.workers?.length || 0, '명, 근무처:', r.workplaces?.length || 0, '개');
|
||||
console.log('[근무처 목록]', r.workplaces);
|
||||
console.log('[근무자 목록]', r.workers);
|
||||
console.log('[근무자 업로드 결과] 근무자:', r.workers?.length || 0, '명');
|
||||
tabDataCache[activeTabId] = r;
|
||||
render();
|
||||
await loadTabs();
|
||||
updateLoadingModal('완료!');
|
||||
setTimeout(hideLoadingModal, 500);
|
||||
showToast(`파일 로드 완료: 근무자 ${r.workers?.length || 0}명, 근무처 ${r.workplaces?.length || 0}개`, 'success');
|
||||
showToast(`근무자 업로드 완료: ${r.workers?.length || 0}명`, 'success');
|
||||
} catch (err) {
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval);
|
||||
}
|
||||
console.error('[업로드 실패]', err);
|
||||
console.error('[근무자 업로드 실패]', err);
|
||||
hideLoadingModal();
|
||||
showToast('업로드 실패: ' + err.message, 'error');
|
||||
showToast('근무자 업로드 실패: ' + err.message, 'error');
|
||||
}
|
||||
e.target.value = '';
|
||||
}
|
||||
|
||||
async function uploadWorkplaceExcel(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);
|
||||
|
||||
let pollInterval = null;
|
||||
|
||||
try {
|
||||
pollInterval = setInterval(async () => {
|
||||
const active = await pollUploadProgress();
|
||||
if (!active) {
|
||||
clearInterval(pollInterval);
|
||||
pollInterval = null;
|
||||
}
|
||||
}, 500);
|
||||
|
||||
const r = await api('/api/upload-workplaces', { method: 'POST', body: form });
|
||||
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval);
|
||||
pollInterval = null;
|
||||
}
|
||||
|
||||
await pollUploadProgress();
|
||||
|
||||
console.log('[근무처 업로드 결과] 근무처:', r.workplaces?.length || 0, '개');
|
||||
tabDataCache[activeTabId] = r;
|
||||
render();
|
||||
await loadTabs();
|
||||
updateLoadingModal('완료!');
|
||||
setTimeout(hideLoadingModal, 500);
|
||||
showToast(`근무처 업로드 완료: ${r.workplaces?.length || 0}개`, 'success');
|
||||
} catch (err) {
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval);
|
||||
}
|
||||
console.error('[근무처 업로드 실패]', err);
|
||||
hideLoadingModal();
|
||||
showToast('근무처 업로드 실패: ' + err.message, 'error');
|
||||
}
|
||||
e.target.value = '';
|
||||
}
|
||||
@@ -474,6 +531,8 @@ async function downloadFromGoogleDrive() {
|
||||
return;
|
||||
}
|
||||
|
||||
const gdType = document.querySelector('input[name="gdType"]:checked').value;
|
||||
|
||||
closeGoogleDriveModal();
|
||||
showLoadingModal();
|
||||
updateLoadingModal('구글 드라이브에서 파일을 다운로드 중...');
|
||||
@@ -492,7 +551,7 @@ async function downloadFromGoogleDrive() {
|
||||
const r = await api('/api/google-drive-upload', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ fileId: fileId, tab: activeTabId }),
|
||||
body: JSON.stringify({ fileId: fileId, tab: activeTabId, type: gdType }),
|
||||
});
|
||||
|
||||
if (pollInterval) {
|
||||
@@ -502,13 +561,15 @@ async function downloadFromGoogleDrive() {
|
||||
|
||||
await pollUploadProgress();
|
||||
|
||||
console.log('[구글 드라이브 업로드 결과] 근무자:', r.workers?.length || 0, '명, 근무처:', r.workplaces?.length || 0, '개');
|
||||
const label = gdType === 'worker' ? '근무자' : '근무처';
|
||||
const count = gdType === 'worker' ? (r.workers?.length || 0) + '명' : (r.workplaces?.length || 0) + '개';
|
||||
console.log(`[구글 드라이브 업로드 결과] ${label}:`, count);
|
||||
tabDataCache[activeTabId] = r;
|
||||
render();
|
||||
await loadTabs();
|
||||
updateLoadingModal('완료!');
|
||||
setTimeout(hideLoadingModal, 500);
|
||||
showToast(`파일 로드 완료: 근무자 ${r.workers?.length || 0}명, 근무처 ${r.workplaces?.length || 0}개`, 'success');
|
||||
showToast(`${label} 로드 완료: ${count}`, 'success');
|
||||
} catch (err) {
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval);
|
||||
@@ -537,11 +598,33 @@ function clearMap() {
|
||||
markers = { workers: {}, workplaces: {} };
|
||||
}
|
||||
|
||||
function getWorkplaceGroups() {
|
||||
const d = currentData();
|
||||
const groups = {};
|
||||
for (const wp of d.workplaces) {
|
||||
const key = wp.address;
|
||||
if (!groups[key]) groups[key] = { address: key, names: [], ids: [], lat: wp.lat, lng: wp.lng };
|
||||
groups[key].names.push(wp.name);
|
||||
groups[key].ids.push(wp.id);
|
||||
}
|
||||
return Object.values(groups);
|
||||
}
|
||||
|
||||
function getWorkplaceAddress(wpId) {
|
||||
const wp = currentData().workplaces.find(p => p.id === wpId);
|
||||
return wp ? wp.address : null;
|
||||
}
|
||||
|
||||
function getWorkplaceAssigneeCount(wpId) {
|
||||
const d = currentData();
|
||||
return d.workers.filter(w => d.assignments[w.id] === wpId).length;
|
||||
}
|
||||
|
||||
function getGroupAssigneeCount(ids) {
|
||||
const d = currentData();
|
||||
return d.workers.filter(w => ids.includes(d.assignments[w.id])).length;
|
||||
}
|
||||
|
||||
function getWorkplaceAssigneesHtml(wpId) {
|
||||
const d = currentData();
|
||||
const assigned = d.workers.filter(w => d.assignments[w.id] === wpId);
|
||||
@@ -550,20 +633,31 @@ function getWorkplaceAssigneesHtml(wpId) {
|
||||
assigned.map(w => `<li>${escHtml(w.name)}</li>`).join('') + '</ul></div>';
|
||||
}
|
||||
|
||||
function getGroupAssigneesHtml(ids) {
|
||||
const d = currentData();
|
||||
const assigned = d.workers.filter(w => ids.includes(d.assignments[w.id]));
|
||||
if (assigned.length === 0) return '<div class="wp-assignees" style="color:#9ca3af;">배정된 근무자 없음</div>';
|
||||
return '<div class="wp-assignees"><strong>배정된 근무자</strong><ul style="margin:4px 0 0 16px;">' +
|
||||
assigned.map(w => `<li>${escHtml(w.name)}</li>`).join('') + '</ul></div>';
|
||||
}
|
||||
|
||||
function renderWorkplaceMarkers() {
|
||||
const d = currentData();
|
||||
const bounds = [];
|
||||
for (const wp of d.workplaces) {
|
||||
const cnt = getWorkplaceAssigneeCount(wp.id);
|
||||
const marker = L.marker([wp.lat, wp.lng], { icon: makeWorkplaceIcon(cnt) })
|
||||
const groups = getWorkplaceGroups();
|
||||
for (const g of groups) {
|
||||
const cnt = getGroupAssigneeCount(g.ids);
|
||||
const namesHtml = escHtml(g.names.join(', '));
|
||||
const marker = L.marker([g.lat, g.lng], { icon: makeWorkplaceIcon(cnt) })
|
||||
.addTo(map).bindPopup(`
|
||||
<div class="popup-content">
|
||||
<strong><i class="fas fa-building"></i> ${escHtml(wp.name)}</strong><br>
|
||||
<small>${escHtml(wp.address)}</small>
|
||||
${getWorkplaceAssigneesHtml(wp.id)}
|
||||
<strong><i class="fas fa-building"></i> ${namesHtml}</strong><br>
|
||||
<small>${escHtml(g.address)}</small>
|
||||
${g.ids.length > 1 ? `<div style="font-size:11px;color:#6b7280;margin:2px 0;">(${g.ids.length}개 투표소)</div>` : ''}
|
||||
${getGroupAssigneesHtml(g.ids)}
|
||||
</div>`);
|
||||
markers.workplaces[wp.id] = marker;
|
||||
bounds.push([wp.lat, wp.lng]);
|
||||
markers.workplaces[g.address] = marker;
|
||||
bounds.push([g.lat, g.lng]);
|
||||
}
|
||||
if (bounds.length > 0) map.fitBounds(bounds, { padding: [50, 50], maxZoom: 16 });
|
||||
}
|
||||
@@ -684,6 +778,11 @@ function renderWorkerList() {
|
||||
${isAssigned ? `<span class="tag-assigned"><i class="fas fa-check-circle"></i> ${escHtml(getWorkplaceName(isAssigned))}</span>` : '<span class="tag-unassigned">미배치</span>'}
|
||||
</div>
|
||||
</div>`;
|
||||
card.addEventListener('click', () => {
|
||||
const pos = getWorkerPos(w);
|
||||
map.setView(pos, 16);
|
||||
if (markers.workers[w.id]) markers.workers[w.id].openPopup();
|
||||
});
|
||||
card.addEventListener('dragstart', e => { e.dataTransfer.setData('text/plain', w.id); e.dataTransfer.effectAllowed = 'move'; card.classList.add('dragging'); });
|
||||
card.addEventListener('dragend', () => card.classList.remove('dragging'));
|
||||
list.appendChild(card);
|
||||
@@ -693,23 +792,27 @@ function renderWorkerList() {
|
||||
}
|
||||
}
|
||||
|
||||
function updateWorkplaceMarker(wpId) {
|
||||
function updateWorkplaceByAddress(addr) {
|
||||
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);
|
||||
const marker = markers.workplaces[addr];
|
||||
if (!marker) return;
|
||||
const group = getWorkplaceGroups().find(g => g.address === addr);
|
||||
if (!group) return;
|
||||
const cnt = getGroupAssigneeCount(group.ids);
|
||||
const namesHtml = escHtml(group.names.join(', '));
|
||||
marker.setIcon(makeWorkplaceIcon(cnt));
|
||||
marker.setPopupContent(`
|
||||
<div class="popup-content">
|
||||
<strong><i class="fas fa-building"></i> ${escHtml(wp.name)}</strong><br>
|
||||
<small>${escHtml(wp.address)}</small>
|
||||
${getWorkplaceAssigneesHtml(wpId)}
|
||||
<strong><i class="fas fa-building"></i> ${namesHtml}</strong><br>
|
||||
<small>${escHtml(group.address)}</small>
|
||||
${group.ids.length > 1 ? `<div style="font-size:11px;color:#6b7280;margin:2px 0;">(${group.ids.length}개 투표소)</div>` : ''}
|
||||
${getGroupAssigneesHtml(group.ids)}
|
||||
</div>`);
|
||||
}
|
||||
|
||||
function updateWorkplacePopup(wpId) {
|
||||
updateWorkplaceMarker(wpId);
|
||||
const addr = getWorkplaceAddress(wpId);
|
||||
if (addr) updateWorkplaceByAddress(addr);
|
||||
}
|
||||
|
||||
let currentSidebarTab = 'workers';
|
||||
@@ -725,9 +828,11 @@ 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 groups = getWorkplaceGroups();
|
||||
for (const g of groups) {
|
||||
const cnt = getGroupAssigneeCount(g.ids);
|
||||
const assigned = d.workers.filter(w => g.ids.includes(d.assignments[w.id]));
|
||||
const namesHtml = escHtml(g.names.join(', '));
|
||||
const card = document.createElement('div');
|
||||
card.className = 'workplace-card';
|
||||
card.innerHTML = `
|
||||
@@ -735,20 +840,20 @@ function renderWorkplaceList() {
|
||||
<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-name">${namesHtml}</div>
|
||||
<div class="workplace-card-address">${escHtml(g.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();
|
||||
map.setView([g.lat, g.lng], 16);
|
||||
if (markers.workplaces[g.address]) markers.workplaces[g.address].openPopup();
|
||||
});
|
||||
list.appendChild(card);
|
||||
}
|
||||
if (d.workplaces.length === 0) {
|
||||
if (groups.length === 0) {
|
||||
list.innerHTML = '<div class="empty-state">엑셀 파일을 업로드하세요</div>';
|
||||
}
|
||||
}
|
||||
@@ -758,7 +863,7 @@ function updateBadges() {
|
||||
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;
|
||||
document.getElementById('workplaceCount').textContent = getWorkplaceGroups().length;
|
||||
const badge = document.getElementById('statusBadge');
|
||||
badge.textContent = assigned > 0 ? `배치 ${assigned}/${total}` : '';
|
||||
badge.className = `status-badge ${!total ? '' : assigned === total ? 'complete' : assigned > 0 ? 'partial' : ''}`;
|
||||
|
||||
Reference in New Issue
Block a user