Feat: 지도 위 근무자 마커 drag & drop 배치 기능 추가
- 근무자 마커를 지도 위에서 직접 드래그하여 근무지에 배치 가능 - 배치된 근무자는 근무지 위치로 이동 및 아이콘 변경 (주황→초록) - 근무지 마커 팝업에서 배치된 근무자 목록 확인 가능 - 배치 취소 시 원래 주소 위치로 복귀 - 모든 UI 업데이트를 점진적으로 처리 (화면 깜빡임 없음)
This commit is contained in:
@@ -73,7 +73,6 @@ const WORKPLACE_ICON = L.divIcon({
|
||||
});
|
||||
|
||||
let markers = { workers: {}, workplaces: {} };
|
||||
let assignmentLines = [];
|
||||
|
||||
function init() {
|
||||
map = L.map('map').setView(YONGSAN, 14);
|
||||
@@ -110,7 +109,7 @@ function init() {
|
||||
showToast('근무지 마커 근처에 드롭해주세요.', 'warning');
|
||||
return;
|
||||
}
|
||||
assignWorker(workerId, nearest.id);
|
||||
doAssignWorker(workerId, nearest.id);
|
||||
});
|
||||
|
||||
document.getElementById('fileInput').addEventListener('change', uploadExcel);
|
||||
@@ -156,7 +155,6 @@ function render() {
|
||||
renderWorkplaceMarkers();
|
||||
renderWorkerMarkers();
|
||||
renderWorkerList();
|
||||
renderAssignmentLines();
|
||||
updateBadges();
|
||||
}
|
||||
|
||||
@@ -167,9 +165,7 @@ function clearMap() {
|
||||
for (const key in markers.workplaces) {
|
||||
map.removeLayer(markers.workplaces[key]);
|
||||
}
|
||||
assignmentLines.forEach(l => map.removeLayer(l));
|
||||
markers = { workers: {}, workplaces: {} };
|
||||
assignmentLines = [];
|
||||
}
|
||||
|
||||
function getWorkplaceAssigneesHtml(wpId) {
|
||||
@@ -199,11 +195,20 @@ function renderWorkplaceMarkers() {
|
||||
}
|
||||
}
|
||||
|
||||
function getWorkerPos(w) {
|
||||
const wpId = store.assignments[w.id];
|
||||
if (wpId) {
|
||||
const wp = store.workplaces.find(p => p.id === wpId);
|
||||
if (wp) return [wp.lat, wp.lng];
|
||||
}
|
||||
return [w.lat, w.lng];
|
||||
}
|
||||
|
||||
function renderWorkerMarkers() {
|
||||
for (const w of store.workers) {
|
||||
const isAssigned = store.assignments[w.id];
|
||||
const icon = isAssigned ? WORKER_ASSIGNED_ICON : WORKER_ICON;
|
||||
const marker = L.marker([w.lat, w.lng], { icon, draggable: false })
|
||||
const marker = L.marker(getWorkerPos(w), { icon, draggable: true })
|
||||
.addTo(map)
|
||||
.bindPopup(`
|
||||
<div class="popup-content">
|
||||
@@ -213,13 +218,86 @@ function renderWorkerMarkers() {
|
||||
<div class="worker-status" id="worker-status-${w.id}">
|
||||
${isAssigned ? `<span class="tag-assigned">배치됨 → ${escHtml(getWorkplaceName(isAssigned))}</span>` : '<span class="tag-unassigned">미배치</span>'}
|
||||
</div>
|
||||
${isAssigned ? `<button class="btn-small btn-danger" onclick="unassignWorker('${w.id}')">배치 취소</button>` : ''}
|
||||
${isAssigned ? `<button class="btn-small btn-danger" onclick="unassignWorker('${w.id}', true)">배치 취소</button>` : ''}
|
||||
</div>
|
||||
`);
|
||||
marker.on('dragend', function () {
|
||||
handleWorkerDragEnd(w.id, this);
|
||||
});
|
||||
markers.workers[w.id] = marker;
|
||||
}
|
||||
}
|
||||
|
||||
function handleWorkerDragEnd(workerId, marker) {
|
||||
const pos = marker.getLatLng();
|
||||
const nearest = findNearestWorkplace(pos.lat, pos.lng);
|
||||
|
||||
if (nearest) {
|
||||
fetch('/assign', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workerId, workplaceId: nearest.id }),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
store.assignments = data.assignments;
|
||||
marker.setLatLng([nearest.lat, nearest.lng]);
|
||||
marker.setIcon(WORKER_ASSIGNED_ICON);
|
||||
marker.setPopupContent(buildWorkerPopup(workerId));
|
||||
updateWorkplacePopup(nearest.id);
|
||||
renderWorkerList();
|
||||
updateBadges();
|
||||
showToast(`${getWorkerName(workerId)} → ${nearest.name} 배치됨`, 'success');
|
||||
})
|
||||
.catch(() => showToast('배치 저장 실패', 'error'));
|
||||
} else if (store.assignments[workerId]) {
|
||||
const oldWpId = store.assignments[workerId];
|
||||
fetch('/unassign', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workerId }),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
store.assignments = data.assignments;
|
||||
const w = store.workers.find(x => x.id === workerId);
|
||||
marker.setLatLng([w.lat, w.lng]);
|
||||
marker.setIcon(WORKER_ICON);
|
||||
marker.setPopupContent(buildWorkerPopup(workerId));
|
||||
updateWorkplacePopup(oldWpId);
|
||||
renderWorkerList();
|
||||
updateBadges();
|
||||
showToast('배치가 취소되었습니다.', 'info');
|
||||
})
|
||||
.catch(() => showToast('배치 취소 실패', 'error'));
|
||||
} else {
|
||||
const w = store.workers.find(x => x.id === workerId);
|
||||
marker.setLatLng([w.lat, w.lng]);
|
||||
}
|
||||
}
|
||||
|
||||
function buildWorkerPopup(workerId) {
|
||||
const w = store.workers.find(x => x.id === workerId);
|
||||
if (!w) return '';
|
||||
const isAssigned = store.assignments[w.id];
|
||||
const wpName = isAssigned ? getWorkplaceName(isAssigned) : '';
|
||||
return `
|
||||
<div class="popup-content">
|
||||
<strong><i class="fas fa-user"></i> ${escHtml(w.name)}</strong><br>
|
||||
<small>${escHtml(w.address)}</small><br>
|
||||
<small>${escHtml(w.phone)}</small>
|
||||
<div class="worker-status">
|
||||
${isAssigned ? `<span class="tag-assigned">배치됨 → ${escHtml(wpName)}</span>` : '<span class="tag-unassigned">미배치</span>'}
|
||||
</div>
|
||||
${isAssigned ? `<button class="btn-small btn-danger" onclick="unassignWorker('${w.id}', true)">배치 취소</button>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function getWorkerName(id) {
|
||||
const w = store.workers.find(x => x.id === id);
|
||||
return w ? w.name : '';
|
||||
}
|
||||
|
||||
function renderWorkerList() {
|
||||
const list = document.getElementById('workerList');
|
||||
list.innerHTML = '';
|
||||
@@ -259,17 +337,18 @@ function renderWorkerList() {
|
||||
}
|
||||
}
|
||||
|
||||
function renderAssignmentLines() {
|
||||
for (const [workerId, workplaceId] of Object.entries(store.assignments)) {
|
||||
const wMarker = markers.workers[workerId];
|
||||
const wpMarker = markers.workplaces[workplaceId];
|
||||
if (!wMarker || !wpMarker) continue;
|
||||
const line = L.polyline(
|
||||
[wMarker.getLatLng(), wpMarker.getLatLng()],
|
||||
{ color: '#10b981', weight: 2, dashArray: '6, 4', opacity: 0.6 }
|
||||
).addTo(map);
|
||||
assignmentLines.push(line);
|
||||
}
|
||||
function updateWorkplacePopup(wpId) {
|
||||
const marker = markers.workplaces[wpId];
|
||||
if (!marker) return;
|
||||
const wp = store.workplaces.find(p => p.id === wpId);
|
||||
if (!wp) return;
|
||||
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)}
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
function updateBadges() {
|
||||
@@ -280,7 +359,7 @@ function updateBadges() {
|
||||
document.getElementById('statusBadge').className = `status-badge ${assigned === total ? 'complete' : assigned > 0 ? 'partial' : ''}`;
|
||||
}
|
||||
|
||||
function assignWorker(workerId, workplaceId) {
|
||||
function doAssignWorker(workerId, workplaceId) {
|
||||
if (!store) return;
|
||||
if (store.assignments[workerId] === workplaceId) return;
|
||||
|
||||
@@ -292,13 +371,23 @@ function assignWorker(workerId, workplaceId) {
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
store.assignments = data.assignments;
|
||||
render();
|
||||
const w = store.workers.find(x => x.id === workerId);
|
||||
const wp = store.workplaces.find(p => p.id === workplaceId);
|
||||
const marker = markers.workers[workerId];
|
||||
if (marker) {
|
||||
marker.setLatLng([wp.lat, wp.lng]);
|
||||
marker.setIcon(WORKER_ASSIGNED_ICON);
|
||||
marker.setPopupContent(buildWorkerPopup(workerId));
|
||||
}
|
||||
updateWorkplacePopup(workplaceId);
|
||||
renderWorkerList();
|
||||
updateBadges();
|
||||
showToast('배치가 저장되었습니다.', 'success');
|
||||
})
|
||||
.catch(() => showToast('배치 저장 실패', 'error'));
|
||||
}
|
||||
|
||||
function unassignWorker(workerId) {
|
||||
function unassignWorker(workerId, fromPopup) {
|
||||
if (!store) return;
|
||||
|
||||
fetch('/unassign', {
|
||||
@@ -308,8 +397,19 @@ function unassignWorker(workerId) {
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const oldWpId = store.assignments[workerId];
|
||||
store.assignments = data.assignments;
|
||||
render();
|
||||
const w = store.workers.find(x => x.id === workerId);
|
||||
const marker = markers.workers[workerId];
|
||||
if (marker) {
|
||||
marker.setLatLng([w.lat, w.lng]);
|
||||
marker.setIcon(WORKER_ICON);
|
||||
marker.setPopupContent(buildWorkerPopup(workerId));
|
||||
}
|
||||
if (oldWpId) updateWorkplacePopup(oldWpId);
|
||||
renderWorkerList();
|
||||
updateBadges();
|
||||
if (fromPopup) map.closePopup();
|
||||
showToast('배치가 취소되었습니다.', 'info');
|
||||
})
|
||||
.catch(() => showToast('배치 취소 실패', 'error'));
|
||||
@@ -323,7 +423,19 @@ function resetAssignments() {
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
store.assignments = data.assignments;
|
||||
render();
|
||||
for (const w of store.workers) {
|
||||
const marker = markers.workers[w.id];
|
||||
if (marker) {
|
||||
marker.setLatLng([w.lat, w.lng]);
|
||||
marker.setIcon(WORKER_ICON);
|
||||
marker.setPopupContent(buildWorkerPopup(w.id));
|
||||
}
|
||||
}
|
||||
for (const wp of store.workplaces) {
|
||||
updateWorkplacePopup(wp.id);
|
||||
}
|
||||
renderWorkerList();
|
||||
updateBadges();
|
||||
showToast('모든 배치가 초기화되었습니다.', 'info');
|
||||
})
|
||||
.catch(() => showToast('초기화 실패', 'error'));
|
||||
|
||||
Reference in New Issue
Block a user