883 lines
31 KiB
HTML
883 lines
31 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-info" onclick="openGoogleDriveModal()"><i class="fab fa-google-drive"></i> 구글 드라이브</button>
|
|
<button class="btn btn-success" onclick="exportExcel()"><i class="fas fa-download"></i> 내보내기</button>
|
|
<button class="btn btn-danger" onclick="resetAssignments()"><i class="fas fa-undo"></i> 초기화</button>
|
|
<span id="statusBadge" class="status-badge"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="tabBar">
|
|
<div id="tabList" class="tab-list"></div>
|
|
<button class="tab-add" onclick="addTab()" title="새 탭 추가"><i class="fas fa-plus"></i></button>
|
|
</div>
|
|
|
|
<div id="main">
|
|
<div id="sidebar">
|
|
<div class="sidebar-tabs">
|
|
<div class="sidebar-tab active" data-tab="workers" onclick="switchSidebarTab('workers')">
|
|
<i class="fas fa-users"></i> 근무자 <span id="workerCount" class="count">0/0</span>
|
|
</div>
|
|
<div class="sidebar-tab" data-tab="workplaces" onclick="switchSidebarTab('workplaces')">
|
|
<i class="fas fa-building"></i> 근무처 <span id="workplaceCount" class="count">0</span>
|
|
</div>
|
|
</div>
|
|
<div id="workerList" class="worker-list active"></div>
|
|
<div id="workplaceList" class="workplace-list"></div>
|
|
</div>
|
|
<div id="mapContainer">
|
|
<div id="map"></div>
|
|
<div id="dropOverlay">여기에 드롭하여 배치</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="toast" class="toast hidden"></div>
|
|
|
|
<div id="loadingModal" class="modal-overlay hidden">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h3><i class="fas fa-file-upload"></i> 엑셀 파일 처리 중</h3>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="loading-spinner">
|
|
<i class="fas fa-spinner fa-spin"></i>
|
|
</div>
|
|
<div id="loadingMessage" class="loading-message">파일을 읽는 중...</div>
|
|
<div id="loadingProgress" class="loading-progress hidden">
|
|
<div class="progress-bar">
|
|
<div id="progressFill" class="progress-fill" style="width: 0%"></div>
|
|
</div>
|
|
<div id="progressText" class="progress-text">0 / 0</div>
|
|
</div>
|
|
<div id="loadingLog" class="loading-log"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="googleDriveModal" class="modal-overlay hidden">
|
|
<div class="modal-content" style="width: 500px;">
|
|
<div class="modal-header">
|
|
<h3><i class="fab fa-google-drive"></i> 구글 드라이브에서 불러오기</h3>
|
|
<button class="modal-close" onclick="closeGoogleDriveModal()" style="background: none; border: none; font-size: 24px; cursor: pointer; color: #666;">×</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div style="margin-bottom: 16px;">
|
|
<label style="display: block; margin-bottom: 8px; font-weight: bold;">파일 ID 또는 공유 링크</label>
|
|
<input type="text" id="googleDriveInput" placeholder="파일 ID 또는 https://drive.google.com/file/d/FILE_ID/view"
|
|
style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; box-sizing: border-box;" />
|
|
<div style="margin-top: 8px; font-size: 12px; color: #666;">
|
|
<p>• 구글 드라이브에서 공유된 파일의 링크를 붙여넣으세요</p>
|
|
<p>• 또는 파일 ID만 입력하세요 (예: 1a2b3c4d5e...)</p>
|
|
</div>
|
|
</div>
|
|
<div style="display: flex; gap: 8px; justify-content: flex-end;">
|
|
<button onclick="closeGoogleDriveModal()" class="btn" style="background: #ddd; color: #333;">취소</button>
|
|
<button onclick="downloadFromGoogleDrive()" class="btn btn-primary">다운로드</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
<script>
|
|
const YONGSAN = [37.5326, 126.9906];
|
|
let map;
|
|
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],
|
|
});
|
|
function makeWorkplaceIcon(count) {
|
|
const badge = count > 0 ? `<div class="marker-badge">${count}</div>` : '';
|
|
return L.divIcon({
|
|
className: 'marker-icon workplace-marker',
|
|
html: `<i class="fas fa-building"></i>${badge}`,
|
|
iconSize: [42, 42], iconAnchor: [21, 42], popupAnchor: [0, -40],
|
|
});
|
|
}
|
|
|
|
let markers = { workers: {}, workplaces: {} };
|
|
let tabs = [];
|
|
let activeTabId = null;
|
|
|
|
// caches the current tab's data loaded from server
|
|
let tabDataCache = {};
|
|
|
|
async function api(url, opts = {}) {
|
|
const resp = await fetch(url, opts);
|
|
if (!resp.ok) throw new Error(await resp.text());
|
|
return resp.json();
|
|
}
|
|
|
|
function tabQuery() { return activeTabId ? `?tab=${activeTabId}` : ''; }
|
|
function tabBody() { return { tab: activeTabId }; }
|
|
|
|
// ── Tab management ──
|
|
|
|
async function loadTabs() {
|
|
const r = await api('/api/tabs');
|
|
tabs = r.tabs;
|
|
activeTabId = r.activeTab;
|
|
renderTabBar();
|
|
if (activeTabId) {
|
|
await loadTabData(activeTabId);
|
|
} else {
|
|
document.getElementById('workerList').innerHTML = '<div class="empty-state">새 탭을 추가하거나 엑셀 파일을 업로드하세요</div>';
|
|
}
|
|
}
|
|
|
|
async function loadTabData(tid) {
|
|
const r = await api(`/api/data?tab=${tid}`);
|
|
tabDataCache[tid] = r;
|
|
if (tid === activeTabId) render();
|
|
}
|
|
|
|
function currentData() {
|
|
return tabDataCache[activeTabId] || { workers: [], workplaces: [], assignments: {} };
|
|
}
|
|
|
|
async function addTab() {
|
|
const name = prompt('탭 이름을 입력하세요 (예: 5/12, 1차 등)');
|
|
if (name === null) return;
|
|
const r = await api('/api/tabs/add', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: name.trim() || undefined }),
|
|
});
|
|
tabs.push({ id: r.id, name: r.name, workerCount: 0, assignCount: 0 });
|
|
tabDataCache[r.id] = { workers: [], workplaces: [], assignments: {} };
|
|
activeTabId = r.activeTab;
|
|
render();
|
|
renderTabBar();
|
|
}
|
|
|
|
async function renameTab(e, tid) {
|
|
e.stopPropagation();
|
|
const tab = tabs.find(t => t.id === tid);
|
|
if (!tab) return;
|
|
const name = prompt('새 이름을 입력하세요', tab.name);
|
|
if (!name || name.trim() === tab.name) return;
|
|
await api('/api/tabs/rename', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id: tid, name: name.trim() }),
|
|
});
|
|
await loadTabs();
|
|
}
|
|
|
|
async function deleteTab(e, tid) {
|
|
e.stopPropagation();
|
|
if (tabs.length <= 1) { showToast('최소 1개의 탭이 필요합니다.', 'warning'); return; }
|
|
if (!confirm('이 탭을 삭제하시겠습니까? 데이터가 모두 사라집니다.')) return;
|
|
await api('/api/tabs/delete', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id: tid }),
|
|
});
|
|
delete tabDataCache[tid];
|
|
await loadTabs();
|
|
}
|
|
|
|
async function switchTab(tid) {
|
|
if (tid === activeTabId) return;
|
|
activeTabId = tid;
|
|
await api('/api/tabs/activate', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id: tid }),
|
|
});
|
|
if (!tabDataCache[tid]) await loadTabData(tid);
|
|
else render();
|
|
renderTabBar();
|
|
document.getElementById('statusBadge').className = 'status-badge';
|
|
}
|
|
|
|
function renderTabBar() {
|
|
const list = document.getElementById('tabList');
|
|
list.innerHTML = '';
|
|
for (const tab of tabs) {
|
|
const el = document.createElement('div');
|
|
el.className = `tab-item ${tab.id === activeTabId ? 'active' : ''}`;
|
|
|
|
const nameSpan = document.createElement('span');
|
|
nameSpan.className = 'tab-name';
|
|
nameSpan.textContent = tab.name;
|
|
nameSpan.title = '더블클릭하여 이름 변경';
|
|
|
|
const badge = document.createElement('span');
|
|
badge.className = 'tab-badge';
|
|
badge.textContent = `${tab.assignCount}/${tab.workerCount}`;
|
|
|
|
const del = document.createElement('span');
|
|
del.className = 'tab-del';
|
|
del.innerHTML = '×';
|
|
del.title = '탭 삭제';
|
|
|
|
el.appendChild(nameSpan);
|
|
el.appendChild(badge);
|
|
el.appendChild(del);
|
|
|
|
el.addEventListener('click', () => switchTab(tab.id));
|
|
nameSpan.addEventListener('dblclick', (e) => renameTab(e, tab.id));
|
|
del.addEventListener('click', (e) => deleteTab(e, tab.id));
|
|
|
|
list.appendChild(el);
|
|
}
|
|
}
|
|
|
|
// ── Map ──
|
|
|
|
let tileLayer = null;
|
|
let mapSourceType = 'openstreetmap'; // 'vworld' 또는 'openstreetmap'
|
|
let vworldApiKey = null;
|
|
|
|
function setMapTileLayer(useVWorld = false) {
|
|
if (tileLayer) {
|
|
map.removeLayer(tileLayer);
|
|
}
|
|
|
|
if (useVWorld && vworldApiKey) {
|
|
tileLayer = L.tileLayer(
|
|
`https://api.vworld.kr/req/wmts/1.0.0/${vworldApiKey}/Base/{z}/{y}/{x}.png`,
|
|
{
|
|
maxZoom: 19,
|
|
minZoom: 1,
|
|
attribution: '© <a href="http://www.vworld.kr" target="_blank">VWorld</a>',
|
|
errorTileUrl: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
|
|
}
|
|
).addTo(map);
|
|
mapSourceType = 'vworld';
|
|
} else {
|
|
// OpenStreetMap (기본값)
|
|
tileLayer = L.tileLayer(
|
|
'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
|
{
|
|
maxZoom: 19,
|
|
minZoom: 1,
|
|
attribution: '© OpenStreetMap contributors',
|
|
}
|
|
).addTo(map);
|
|
mapSourceType = 'openstreetmap';
|
|
console.log('[지도] OpenStreetMap 로드됨');
|
|
}
|
|
}
|
|
|
|
async function init() {
|
|
map = L.map('map').setView(YONGSAN, 14);
|
|
|
|
// VWorld API 상태 확인 및 맵 초기화
|
|
try {
|
|
const statusResp = await fetch('/api/vworld-api-status');
|
|
const statusData = await statusResp.json();
|
|
console.log('[지도] VWorld API 상태:', statusData);
|
|
|
|
if (statusData.valid && statusData.key) {
|
|
vworldApiKey = statusData.key;
|
|
setMapTileLayer(true); // VWorld 시도
|
|
} else {
|
|
setMapTileLayer(false); // OpenStreetMap 사용
|
|
}
|
|
} catch (e) {
|
|
console.warn('[지도] VWorld API 상태 확인 실패:', e);
|
|
setMapTileLayer(false); // 에러 시 OpenStreetMap 사용
|
|
}
|
|
|
|
const mc = document.getElementById('mapContainer');
|
|
mc.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; document.getElementById('dropOverlay').classList.add('active'); });
|
|
mc.addEventListener('dragleave', () => document.getElementById('dropOverlay').classList.remove('active'));
|
|
mc.addEventListener('drop', e => {
|
|
e.preventDefault();
|
|
document.getElementById('dropOverlay').classList.remove('active');
|
|
const workerId = e.dataTransfer.getData('text/plain');
|
|
if (!workerId || !activeTabId) return;
|
|
const rect = map.getContainer().getBoundingClientRect();
|
|
const latlng = map.containerPointToLatLng([e.clientX - rect.left, e.clientY - rect.top]);
|
|
const nearest = findNearestWorkplace(latlng.lat, latlng.lng);
|
|
if (!nearest) { showToast('근무지 마커 근처에 드롭해주세요.', 'warning'); return; }
|
|
doAssignWorker(workerId, nearest.id);
|
|
});
|
|
|
|
document.getElementById('fileInput').addEventListener('change', uploadExcel);
|
|
loadTabs();
|
|
}
|
|
|
|
function findNearestWorkplace(lat, lng) {
|
|
const d = currentData();
|
|
const threshold = 0.002;
|
|
let best = null, bestDist = Infinity;
|
|
for (const wp of (d.workplaces || [])) {
|
|
const dist = Math.abs(wp.lat - lat) + Math.abs(wp.lng - lng);
|
|
if (dist < threshold && dist < bestDist) { bestDist = dist; best = wp; }
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function showLoadingModal() {
|
|
document.getElementById('loadingModal').classList.remove('hidden');
|
|
}
|
|
|
|
function hideLoadingModal() {
|
|
document.getElementById('loadingModal').classList.add('hidden');
|
|
}
|
|
|
|
function updateLoadingModal(msg, progress = null, total = null) {
|
|
document.getElementById('loadingMessage').textContent = msg;
|
|
|
|
const progEl = document.getElementById('loadingProgress');
|
|
const fillEl = document.getElementById('progressFill');
|
|
const textEl = document.getElementById('progressText');
|
|
const logEl = document.getElementById('loadingLog');
|
|
|
|
if (progress !== null && total !== null && total > 0) {
|
|
progEl.classList.remove('hidden');
|
|
const pct = (progress / total) * 100;
|
|
fillEl.style.width = pct + '%';
|
|
textEl.textContent = `${progress} / ${total}`;
|
|
} else {
|
|
progEl.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
async function pollUploadProgress() {
|
|
try {
|
|
const r = await api('/api/upload-progress');
|
|
updateLoadingModal(r.message, r.current, r.total);
|
|
|
|
if (r.logs && r.logs.length > 0) {
|
|
const logEl = document.getElementById('loadingLog');
|
|
logEl.innerHTML = r.logs.map(l => '<div>' + escHtml(l) + '</div>').join('');
|
|
logEl.scrollTop = logEl.scrollHeight;
|
|
}
|
|
|
|
return r.active;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function uploadExcel(e) {
|
|
const file = e.target.files[0];
|
|
if (!file || !activeTabId) return;
|
|
|
|
showLoadingModal();
|
|
updateLoadingModal('파일 업로드 준비 중...');
|
|
|
|
const form = new FormData();
|
|
form.append('file', file);
|
|
form.append('tab', activeTabId);
|
|
|
|
let pollInterval = null;
|
|
|
|
try {
|
|
pollInterval = setInterval(async () => {
|
|
const active = await pollUploadProgress();
|
|
if (!active) {
|
|
clearInterval(pollInterval);
|
|
pollInterval = null;
|
|
}
|
|
}, 500);
|
|
|
|
const r = await api('/api/upload', { method: 'POST', body: form });
|
|
|
|
if (pollInterval) {
|
|
clearInterval(pollInterval);
|
|
pollInterval = null;
|
|
}
|
|
|
|
await pollUploadProgress();
|
|
|
|
console.log('[업로드 결과] 근무자:', r.workers?.length || 0, '명, 근무처:', r.workplaces?.length || 0, '개');
|
|
console.log('[근무처 목록]', r.workplaces);
|
|
console.log('[근무자 목록]', r.workers);
|
|
tabDataCache[activeTabId] = r;
|
|
render();
|
|
await loadTabs();
|
|
updateLoadingModal('완료!');
|
|
setTimeout(hideLoadingModal, 500);
|
|
showToast(`파일 로드 완료: 근무자 ${r.workers?.length || 0}명, 근무처 ${r.workplaces?.length || 0}개`, 'success');
|
|
} catch (err) {
|
|
if (pollInterval) {
|
|
clearInterval(pollInterval);
|
|
}
|
|
console.error('[업로드 실패]', err);
|
|
hideLoadingModal();
|
|
showToast('업로드 실패: ' + err.message, 'error');
|
|
}
|
|
e.target.value = '';
|
|
}
|
|
|
|
// ── Google Drive Integration ──
|
|
|
|
function openGoogleDriveModal() {
|
|
if (!activeTabId) {
|
|
showToast('먼저 탭을 선택하세요', 'warning');
|
|
return;
|
|
}
|
|
document.getElementById('googleDriveModal').classList.remove('hidden');
|
|
document.getElementById('googleDriveInput').value = '';
|
|
document.getElementById('googleDriveInput').focus();
|
|
}
|
|
|
|
function closeGoogleDriveModal() {
|
|
document.getElementById('googleDriveModal').classList.add('hidden');
|
|
}
|
|
|
|
function extractFileId(input) {
|
|
// 링크에서 파일 ID 추출: https://drive.google.com/file/d/FILE_ID/view
|
|
let match = input.match(/\/d\/([a-zA-Z0-9-_]+)/);
|
|
if (match) return match[1];
|
|
|
|
// 또는 링크에서: https://drive.google.com/open?id=FILE_ID
|
|
match = input.match(/id=([a-zA-Z0-9-_]+)/);
|
|
if (match) return match[1];
|
|
|
|
// 파일 ID 자체일 수도 있음
|
|
if (input.match(/^[a-zA-Z0-9-_]+$/)) return input;
|
|
|
|
return null;
|
|
}
|
|
|
|
async function downloadFromGoogleDrive() {
|
|
const input = document.getElementById('googleDriveInput').value.trim();
|
|
if (!input) {
|
|
showToast('파일 ID 또는 링크를 입력하세요', 'warning');
|
|
return;
|
|
}
|
|
|
|
const fileId = extractFileId(input);
|
|
if (!fileId) {
|
|
showToast('올바르지 않은 파일 ID 또는 링크입니다', 'error');
|
|
return;
|
|
}
|
|
|
|
closeGoogleDriveModal();
|
|
showLoadingModal();
|
|
updateLoadingModal('구글 드라이브에서 파일을 다운로드 중...');
|
|
|
|
let pollInterval = null;
|
|
|
|
try {
|
|
pollInterval = setInterval(async () => {
|
|
const active = await pollUploadProgress();
|
|
if (!active) {
|
|
clearInterval(pollInterval);
|
|
pollInterval = null;
|
|
}
|
|
}, 500);
|
|
|
|
const r = await api('/api/google-drive-upload', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ fileId: fileId, tab: activeTabId }),
|
|
});
|
|
|
|
if (pollInterval) {
|
|
clearInterval(pollInterval);
|
|
pollInterval = null;
|
|
}
|
|
|
|
await pollUploadProgress();
|
|
|
|
console.log('[구글 드라이브 업로드 결과] 근무자:', r.workers?.length || 0, '명, 근무처:', r.workplaces?.length || 0, '개');
|
|
tabDataCache[activeTabId] = r;
|
|
render();
|
|
await loadTabs();
|
|
updateLoadingModal('완료!');
|
|
setTimeout(hideLoadingModal, 500);
|
|
showToast(`파일 로드 완료: 근무자 ${r.workers?.length || 0}명, 근무처 ${r.workplaces?.length || 0}개`, 'success');
|
|
} catch (err) {
|
|
if (pollInterval) {
|
|
clearInterval(pollInterval);
|
|
}
|
|
console.error('[구글 드라이브 다운로드 실패]', err);
|
|
hideLoadingModal();
|
|
showToast('다운로드 실패: ' + err.message, 'error');
|
|
}
|
|
}
|
|
|
|
// ── Rendering ──
|
|
|
|
function render() {
|
|
if (!activeTabId) return;
|
|
clearMap();
|
|
renderWorkplaceMarkers();
|
|
renderWorkerMarkers();
|
|
renderWorkerList();
|
|
renderWorkplaceList();
|
|
updateBadges();
|
|
}
|
|
|
|
function clearMap() {
|
|
for (const k in markers.workers) map.removeLayer(markers.workers[k]);
|
|
for (const k in markers.workplaces) map.removeLayer(markers.workplaces[k]);
|
|
markers = { workers: {}, workplaces: {} };
|
|
}
|
|
|
|
function getWorkplaceAssigneeCount(wpId) {
|
|
const d = currentData();
|
|
return d.workers.filter(w => d.assignments[w.id] === wpId).length;
|
|
}
|
|
|
|
function getWorkplaceAssigneesHtml(wpId) {
|
|
const d = currentData();
|
|
const assigned = d.workers.filter(w => d.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 16px;">' +
|
|
assigned.map(w => `<li>${escHtml(w.name)}</li>`).join('') + '</ul></div>';
|
|
}
|
|
|
|
function renderWorkplaceMarkers() {
|
|
const d = currentData();
|
|
const bounds = [];
|
|
for (const wp of d.workplaces) {
|
|
const cnt = getWorkplaceAssigneeCount(wp.id);
|
|
const marker = L.marker([wp.lat, wp.lng], { icon: makeWorkplaceIcon(cnt) })
|
|
.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 getWorkerPos(w) {
|
|
const d = currentData();
|
|
const wpId = d.assignments[w.id];
|
|
if (wpId) {
|
|
const wp = d.workplaces.find(p => p.id === wpId);
|
|
if (wp) return [wp.lat, wp.lng];
|
|
}
|
|
return [w.lat, w.lng];
|
|
}
|
|
|
|
function renderWorkerMarkers() {
|
|
const d = currentData();
|
|
for (const w of d.workers) {
|
|
const isAssigned = d.assignments[w.id];
|
|
const marker = L.marker(getWorkerPos(w), { icon: isAssigned ? WORKER_ASSIGNED_ICON : WORKER_ICON, draggable: true })
|
|
.addTo(map).bindPopup(buildWorkerPopup(w.id));
|
|
marker.on('dragend', function () { handleWorkerDragEnd(w.id, this); });
|
|
markers.workers[w.id] = marker;
|
|
}
|
|
}
|
|
|
|
function handleWorkerDragEnd(workerId, marker) {
|
|
const d = currentData();
|
|
const pos = marker.getLatLng();
|
|
const nearest = findNearestWorkplace(pos.lat, pos.lng);
|
|
const oldWpId = d.assignments[workerId];
|
|
|
|
if (nearest) {
|
|
if (oldWpId === nearest.id) return;
|
|
api('/api/assign', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ workerId, workplaceId: nearest.id, tab: activeTabId }),
|
|
}).then(data => {
|
|
tabDataCache[activeTabId].assignments = data.assignments;
|
|
marker.setLatLng([nearest.lat, nearest.lng]);
|
|
marker.setIcon(WORKER_ASSIGNED_ICON);
|
|
marker.setPopupContent(buildWorkerPopup(workerId));
|
|
if (oldWpId) updateWorkplacePopup(oldWpId);
|
|
updateWorkplacePopup(nearest.id);
|
|
renderWorkerList();
|
|
renderWorkplaceList();
|
|
updateBadges();
|
|
showToast(`${getWorkerName(workerId)} → ${nearest.name} 배치됨`, 'success');
|
|
}).catch(() => showToast('배치 저장 실패', 'error'));
|
|
|
|
} else if (oldWpId) {
|
|
api('/api/unassign', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ workerId, tab: activeTabId }),
|
|
}).then(data => {
|
|
tabDataCache[activeTabId].assignments = data.assignments;
|
|
const w = d.workers.find(x => x.id === workerId);
|
|
marker.setLatLng([w.lat, w.lng]);
|
|
marker.setIcon(WORKER_ICON);
|
|
marker.setPopupContent(buildWorkerPopup(workerId));
|
|
updateWorkplacePopup(oldWpId);
|
|
renderWorkerList();
|
|
renderWorkplaceList();
|
|
updateBadges();
|
|
showToast('배치가 취소되었습니다.', 'info');
|
|
}).catch(() => showToast('배치 취소 실패', 'error'));
|
|
|
|
} else {
|
|
const w = d.workers.find(x => x.id === workerId);
|
|
marker.setLatLng([w.lat, w.lng]);
|
|
}
|
|
}
|
|
|
|
function buildWorkerPopup(workerId) {
|
|
const d = currentData();
|
|
const w = d.workers.find(x => x.id === workerId);
|
|
if (!w) return '';
|
|
const isAssigned = d.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 = currentData().workers.find(x => x.id === id);
|
|
return w ? w.name : '';
|
|
}
|
|
|
|
function renderWorkerList() {
|
|
const d = currentData();
|
|
const list = document.getElementById('workerList');
|
|
list.innerHTML = '';
|
|
for (const w of d.workers) {
|
|
const isAssigned = d.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', () => card.classList.remove('dragging'));
|
|
list.appendChild(card);
|
|
}
|
|
if (d.workers.length === 0) {
|
|
list.innerHTML = '<div class="empty-state">엑셀 파일을 업로드하세요</div>';
|
|
}
|
|
}
|
|
|
|
function updateWorkplaceMarker(wpId) {
|
|
const d = currentData();
|
|
const marker = markers.workplaces[wpId];
|
|
const wp = d.workplaces.find(p => p.id === wpId);
|
|
if (!marker || !wp) return;
|
|
const cnt = getWorkplaceAssigneeCount(wpId);
|
|
marker.setIcon(makeWorkplaceIcon(cnt));
|
|
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 updateWorkplacePopup(wpId) {
|
|
updateWorkplaceMarker(wpId);
|
|
}
|
|
|
|
let currentSidebarTab = 'workers';
|
|
|
|
function switchSidebarTab(tab) {
|
|
currentSidebarTab = tab;
|
|
document.querySelectorAll('.sidebar-tab').forEach(el => el.classList.toggle('active', el.dataset.tab === tab));
|
|
document.getElementById('workerList').classList.toggle('hidden', tab !== 'workers');
|
|
document.getElementById('workplaceList').classList.toggle('active', tab === 'workplaces');
|
|
}
|
|
|
|
function renderWorkplaceList() {
|
|
const d = currentData();
|
|
const list = document.getElementById('workplaceList');
|
|
list.innerHTML = '';
|
|
for (const wp of d.workplaces) {
|
|
const cnt = getWorkplaceAssigneeCount(wp.id);
|
|
const assigned = d.workers.filter(w => d.assignments[w.id] === wp.id);
|
|
const card = document.createElement('div');
|
|
card.className = 'workplace-card';
|
|
card.innerHTML = `
|
|
<div class="workplace-card-avatar">
|
|
<i class="fas fa-building"></i>
|
|
</div>
|
|
<div class="workplace-card-info">
|
|
<div class="workplace-card-name">${escHtml(wp.name)}</div>
|
|
<div class="workplace-card-address">${escHtml(wp.address)}</div>
|
|
<div class="workplace-card-assignment">
|
|
<i class="fas fa-users"></i> ${cnt}명 배치
|
|
${assigned.length > 0 ? `<span style="font-weight:400;color:#6b7280;margin-left:6px;">${assigned.slice(0, 4).map(w => escHtml(w.name)).join(', ')}${assigned.length > 4 ? ' 외 ' + (assigned.length - 4) + '명' : ''}</span>` : ''}
|
|
</div>
|
|
</div>`;
|
|
card.addEventListener('click', () => {
|
|
map.setView([wp.lat, wp.lng], 16);
|
|
if (markers.workplaces[wp.id]) markers.workplaces[wp.id].openPopup();
|
|
});
|
|
list.appendChild(card);
|
|
}
|
|
if (d.workplaces.length === 0) {
|
|
list.innerHTML = '<div class="empty-state">엑셀 파일을 업로드하세요</div>';
|
|
}
|
|
}
|
|
|
|
function updateBadges() {
|
|
const d = currentData();
|
|
const assigned = Object.keys(d.assignments).length;
|
|
const total = d.workers.length;
|
|
document.getElementById('workerCount').textContent = `${assigned}/${total}`;
|
|
document.getElementById('workplaceCount').textContent = d.workplaces.length;
|
|
const badge = document.getElementById('statusBadge');
|
|
badge.textContent = assigned > 0 ? `배치 ${assigned}/${total}` : '';
|
|
badge.className = `status-badge ${!total ? '' : assigned === total ? 'complete' : assigned > 0 ? 'partial' : ''}`;
|
|
}
|
|
|
|
// ── Actions ──
|
|
|
|
async function doAssignWorker(workerId, workplaceId) {
|
|
if (!activeTabId) return;
|
|
const d = currentData();
|
|
const oldWpId = d.assignments[workerId];
|
|
if (oldWpId === workplaceId) return;
|
|
try {
|
|
const r = await api('/api/assign', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ workerId, workplaceId, tab: activeTabId }),
|
|
});
|
|
tabDataCache[activeTabId].assignments = r.assignments;
|
|
const wp = d.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));
|
|
}
|
|
if (oldWpId) updateWorkplacePopup(oldWpId);
|
|
updateWorkplacePopup(workplaceId);
|
|
renderWorkerList();
|
|
renderWorkplaceList();
|
|
updateBadges();
|
|
showToast('배치가 저장되었습니다.', 'success');
|
|
} catch { showToast('배치 저장 실패', 'error'); }
|
|
}
|
|
|
|
async function unassignWorker(workerId, fromPopup) {
|
|
if (!activeTabId) return;
|
|
const d = currentData();
|
|
const oldWpId = d.assignments[workerId];
|
|
try {
|
|
const r = await api('/api/unassign', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ workerId, tab: activeTabId }),
|
|
});
|
|
tabDataCache[activeTabId].assignments = r.assignments;
|
|
const w = d.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();
|
|
renderWorkplaceList();
|
|
updateBadges();
|
|
if (fromPopup) map.closePopup();
|
|
showToast('배치가 취소되었습니다.', 'info');
|
|
} catch { showToast('배치 취소 실패', 'error'); }
|
|
}
|
|
|
|
async function resetAssignments() {
|
|
const d = currentData();
|
|
if (!activeTabId || Object.keys(d.assignments).length === 0) return;
|
|
if (!confirm('모든 배치를 초기화하시겠습니까?')) return;
|
|
try {
|
|
await api('/api/reset', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ tab: activeTabId }),
|
|
});
|
|
tabDataCache[activeTabId].assignments = {};
|
|
for (const w of d.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 d.workplaces) updateWorkplacePopup(wp.id);
|
|
renderWorkerList();
|
|
renderWorkplaceList();
|
|
updateBadges();
|
|
showToast('모든 배치가 초기화되었습니다.', 'info');
|
|
} catch { showToast('초기화 실패', 'error'); }
|
|
}
|
|
|
|
function exportExcel() {
|
|
if (!activeTabId) { showToast('내보낼 탭이 없습니다.', 'warning'); return; }
|
|
const d = currentData();
|
|
if (d.workers.length === 0) { showToast('내보낼 데이터가 없습니다.', 'warning'); return; }
|
|
window.location.href = `/api/export?tab=${activeTabId}`;
|
|
}
|
|
|
|
function getWorkplaceName(id) {
|
|
const wp = currentData().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>
|