Flask + Leaflet 기반 지도 드래그앤드롭 근무 배치 시스템 - 엑셀 업로드/파싱 - Leaflet 지도에 근무지/근무자 표시 - 드래그앤드롭으로 근무자 배치 - 엑셀 내보내기
365 lines
12 KiB
HTML
365 lines
12 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>MapinDrag - 근무 배치 시스템</title>
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" />
|
|
</head>
|
|
<body>
|
|
<div id="header">
|
|
<div class="header-left">
|
|
<h1><i class="fas fa-map-marked-alt"></i> MapinDrag</h1>
|
|
<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>
|
|
<button class="btn btn-success" id="exportBtn" onclick="exportExcel()"><i class="fas fa-download"></i> 내보내기</button>
|
|
<button class="btn btn-danger" id="resetBtn" onclick="resetAssignments()"><i class="fas fa-undo"></i> 초기화</button>
|
|
<span id="statusBadge" class="status-badge"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="main">
|
|
<div id="sidebar">
|
|
<div class="sidebar-header">
|
|
<h2><i class="fas fa-users"></i> 근무자</h2>
|
|
<span id="workerCount" class="count-badge">0</span>
|
|
</div>
|
|
<div id="workerList" class="worker-list"></div>
|
|
</div>
|
|
|
|
<div id="mapContainer">
|
|
<div id="map"></div>
|
|
<div id="dropOverlay">여기에 드롭하여 배치</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="toast" class="toast hidden"></div>
|
|
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
<script>
|
|
const YONGSAN = [37.5326, 126.9906];
|
|
|
|
let map, store;
|
|
|
|
const WORKER_ICON = L.divIcon({
|
|
className: 'marker-icon worker-marker',
|
|
html: '<i class="fas fa-user"></i>',
|
|
iconSize: [36, 36],
|
|
iconAnchor: [18, 36],
|
|
popupAnchor: [0, -40],
|
|
});
|
|
|
|
const WORKER_ASSIGNED_ICON = L.divIcon({
|
|
className: 'marker-icon worker-assigned-marker',
|
|
html: '<i class="fas fa-user-check"></i>',
|
|
iconSize: [36, 36],
|
|
iconAnchor: [18, 36],
|
|
popupAnchor: [0, -40],
|
|
});
|
|
|
|
const WORKPLACE_ICON = L.divIcon({
|
|
className: 'marker-icon workplace-marker',
|
|
html: '<i class="fas fa-building"></i>',
|
|
iconSize: [42, 42],
|
|
iconAnchor: [21, 42],
|
|
popupAnchor: [0, -40],
|
|
});
|
|
|
|
let markers = { workers: {}, workplaces: {} };
|
|
let assignmentLines = [];
|
|
|
|
function init() {
|
|
map = L.map('map').setView(YONGSAN, 14);
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
maxZoom: 18,
|
|
attribution: '© OpenStreetMap contributors',
|
|
}).addTo(map);
|
|
|
|
const mapContainer = document.getElementById('mapContainer');
|
|
|
|
mapContainer.addEventListener('dragover', (e) => {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = 'move';
|
|
document.getElementById('dropOverlay').classList.add('active');
|
|
});
|
|
|
|
mapContainer.addEventListener('dragleave', (e) => {
|
|
document.getElementById('dropOverlay').classList.remove('active');
|
|
});
|
|
|
|
mapContainer.addEventListener('drop', (e) => {
|
|
e.preventDefault();
|
|
document.getElementById('dropOverlay').classList.remove('active');
|
|
const workerId = e.dataTransfer.getData('text/plain');
|
|
if (!workerId || !store) return;
|
|
|
|
const rect = map.getContainer().getBoundingClientRect();
|
|
const x = e.clientX - rect.left;
|
|
const y = e.clientY - rect.top;
|
|
const latlng = map.containerPointToLatLng([x, y]);
|
|
|
|
const nearest = findNearestWorkplace(latlng.lat, latlng.lng);
|
|
if (!nearest) {
|
|
showToast('근무지 마커 근처에 드롭해주세요.', 'warning');
|
|
return;
|
|
}
|
|
assignWorker(workerId, nearest.id);
|
|
});
|
|
|
|
document.getElementById('fileInput').addEventListener('change', uploadExcel);
|
|
}
|
|
|
|
function findNearestWorkplace(lat, lng) {
|
|
const threshold = 0.002;
|
|
let best = null, bestDist = Infinity;
|
|
for (const wp of (store?.workplaces || [])) {
|
|
const d = Math.abs(wp.lat - lat) + Math.abs(wp.lng - lng);
|
|
if (d < threshold && d < bestDist) {
|
|
bestDist = d;
|
|
best = wp;
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
async function uploadExcel(e) {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
|
|
const form = new FormData();
|
|
form.append('file', file);
|
|
|
|
showToast('업로드 중...', 'info');
|
|
|
|
try {
|
|
const resp = await fetch('/upload', { method: 'POST', body: form });
|
|
if (!resp.ok) throw new Error('업로드 실패');
|
|
store = await resp.json();
|
|
render();
|
|
showToast(`파일 로드 완료: ${file.name}`, 'success');
|
|
} catch (err) {
|
|
showToast('업로드 실패: ' + err.message, 'error');
|
|
}
|
|
e.target.value = '';
|
|
}
|
|
|
|
function render() {
|
|
if (!store) return;
|
|
clearMap();
|
|
renderWorkplaceMarkers();
|
|
renderWorkerMarkers();
|
|
renderWorkerList();
|
|
renderAssignmentLines();
|
|
updateBadges();
|
|
}
|
|
|
|
function clearMap() {
|
|
for (const key in markers.workers) {
|
|
map.removeLayer(markers.workers[key]);
|
|
}
|
|
for (const key in markers.workplaces) {
|
|
map.removeLayer(markers.workplaces[key]);
|
|
}
|
|
assignmentLines.forEach(l => map.removeLayer(l));
|
|
markers = { workers: {}, workplaces: {} };
|
|
assignmentLines = [];
|
|
}
|
|
|
|
function getWorkplaceAssigneesHtml(wpId) {
|
|
const assigned = store.workers.filter(w => store.assignments[w.id] === wpId);
|
|
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 0;padding-left:16px;">' +
|
|
assigned.map(w => `<li>${escHtml(w.name)}</li>`).join('') + '</ul></div>';
|
|
}
|
|
|
|
function renderWorkplaceMarkers() {
|
|
const bounds = [];
|
|
for (const wp of store.workplaces) {
|
|
const marker = L.marker([wp.lat, wp.lng], { icon: WORKPLACE_ICON })
|
|
.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)}
|
|
</div>
|
|
`);
|
|
markers.workplaces[wp.id] = marker;
|
|
bounds.push([wp.lat, wp.lng]);
|
|
}
|
|
if (bounds.length > 0) {
|
|
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 16 });
|
|
}
|
|
}
|
|
|
|
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 })
|
|
.addTo(map)
|
|
.bindPopup(`
|
|
<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" 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>` : ''}
|
|
</div>
|
|
`);
|
|
markers.workers[w.id] = marker;
|
|
}
|
|
}
|
|
|
|
function renderWorkerList() {
|
|
const list = document.getElementById('workerList');
|
|
list.innerHTML = '';
|
|
for (const w of store.workers) {
|
|
const isAssigned = store.assignments[w.id];
|
|
const card = document.createElement('div');
|
|
card.className = `worker-card ${isAssigned ? 'assigned' : ''}`;
|
|
card.draggable = true;
|
|
card.dataset.workerId = w.id;
|
|
|
|
card.innerHTML = `
|
|
<div class="worker-card-avatar ${isAssigned ? 'assigned' : ''}">
|
|
<i class="fas ${isAssigned ? 'fa-user-check' : 'fa-user'}"></i>
|
|
</div>
|
|
<div class="worker-card-info">
|
|
<div class="worker-card-name">${escHtml(w.name)}</div>
|
|
<div class="worker-card-detail">${escHtml(w.phone)}</div>
|
|
<div class="worker-card-address" title="${escHtml(w.address)}">${escHtml(w.address)}</div>
|
|
${w.hope ? `<div class="worker-card-hope"><i class="fas fa-star"></i> ${escHtml(w.hope)}</div>` : ''}
|
|
<div class="worker-card-assignment">
|
|
${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('dragstart', (e) => {
|
|
e.dataTransfer.setData('text/plain', w.id);
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
card.classList.add('dragging');
|
|
});
|
|
|
|
card.addEventListener('dragend', (e) => {
|
|
card.classList.remove('dragging');
|
|
});
|
|
|
|
list.appendChild(card);
|
|
}
|
|
}
|
|
|
|
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 updateBadges() {
|
|
const assigned = Object.keys(store.assignments).length;
|
|
const total = store.workers.length;
|
|
document.getElementById('workerCount').textContent = `${assigned}/${total}`;
|
|
document.getElementById('statusBadge').textContent = assigned > 0 ? `배치 ${assigned}/${total}` : '';
|
|
document.getElementById('statusBadge').className = `status-badge ${assigned === total ? 'complete' : assigned > 0 ? 'partial' : ''}`;
|
|
}
|
|
|
|
function assignWorker(workerId, workplaceId) {
|
|
if (!store) return;
|
|
if (store.assignments[workerId] === workplaceId) return;
|
|
|
|
fetch('/assign', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ workerId, workplaceId }),
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
store.assignments = data.assignments;
|
|
render();
|
|
showToast('배치가 저장되었습니다.', 'success');
|
|
})
|
|
.catch(() => showToast('배치 저장 실패', 'error'));
|
|
}
|
|
|
|
function unassignWorker(workerId) {
|
|
if (!store) return;
|
|
|
|
fetch('/unassign', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ workerId }),
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
store.assignments = data.assignments;
|
|
render();
|
|
showToast('배치가 취소되었습니다.', 'info');
|
|
})
|
|
.catch(() => showToast('배치 취소 실패', 'error'));
|
|
}
|
|
|
|
function resetAssignments() {
|
|
if (!store || Object.keys(store.assignments).length === 0) return;
|
|
if (!confirm('모든 배치를 초기화하시겠습니까?')) return;
|
|
|
|
fetch('/reset', { method: 'POST' })
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
store.assignments = data.assignments;
|
|
render();
|
|
showToast('모든 배치가 초기화되었습니다.', 'info');
|
|
})
|
|
.catch(() => showToast('초기화 실패', 'error'));
|
|
}
|
|
|
|
function exportExcel() {
|
|
if (!store || store.workers.length === 0) {
|
|
showToast('내보낼 데이터가 없습니다.', 'warning');
|
|
return;
|
|
}
|
|
window.location.href = '/export';
|
|
}
|
|
|
|
function getWorkplaceName(id) {
|
|
const wp = store.workplaces.find(p => p.id === id);
|
|
return wp ? wp.name : '(알 수 없음)';
|
|
}
|
|
|
|
function escHtml(str) {
|
|
if (!str) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = str;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function showToast(msg, type) {
|
|
const toast = document.getElementById('toast');
|
|
toast.textContent = msg;
|
|
toast.className = `toast ${type}`;
|
|
toast.classList.remove('hidden');
|
|
clearTimeout(toast._timer);
|
|
toast._timer = setTimeout(() => toast.classList.add('hidden'), 3000);
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
</script>
|
|
</body>
|
|
</html>
|