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