Feat: 지도 위 근무자 마커 drag & drop 배치 기능 추가

- 근무자 마커를 지도 위에서 직접 드래그하여 근무지에 배치 가능
- 배치된 근무자는 근무지 위치로 이동 및 아이콘 변경 (주황→초록)
- 근무지 마커 팝업에서 배치된 근무자 목록 확인 가능
- 배치 취소 시 원래 주소 위치로 복귀
- 모든 UI 업데이트를 점진적으로 처리 (화면 깜빡임 없음)
This commit is contained in:
user01
2026-05-12 15:37:07 +09:00
parent a9dcef0285
commit 078f0defe9

View File

@@ -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'));