From 078f0defe91a16707bc97d9875aac82663f63014 Mon Sep 17 00:00:00 2001 From: user01 Date: Tue, 12 May 2026 15:37:07 +0900 Subject: [PATCH] =?UTF-8?q?Feat:=20=EC=A7=80=EB=8F=84=20=EC=9C=84=20?= =?UTF-8?q?=EA=B7=BC=EB=AC=B4=EC=9E=90=20=EB=A7=88=EC=BB=A4=20drag=20&=20d?= =?UTF-8?q?rop=20=EB=B0=B0=EC=B9=98=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 근무자 마커를 지도 위에서 직접 드래그하여 근무지에 배치 가능 - 배치된 근무자는 근무지 위치로 이동 및 아이콘 변경 (주황→초록) - 근무지 마커 팝업에서 배치된 근무자 목록 확인 가능 - 배치 취소 시 원래 주소 위치로 복귀 - 모든 UI 업데이트를 점진적으로 처리 (화면 깜빡임 없음) --- templates/index.html | 158 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 135 insertions(+), 23 deletions(-) diff --git a/templates/index.html b/templates/index.html index 2b6eab6..c78e869 100644 --- a/templates/index.html +++ b/templates/index.html @@ -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(` `); + 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 ` + `; +} + +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(` + + `); } 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'));