Feat: 탭 기능 추가 - 날짜/용도별 독립 작업 공간
- 탭바: 자유로운 이름 지정, 추가/삭제/더블클릭 이름변경 - 각 탭은 독립적인 근무자/근무지/배치 데이터 보유 - 탭 전환 시 해당 데이터로 지도/사이드바 갱신 - 내보내기 시 현재 탭 이름이 파일명에 포함됨 - API 경로 /api/* 로 통일
This commit is contained in:
@@ -19,12 +19,17 @@
|
||||
<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>
|
||||
<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-header">
|
||||
@@ -33,7 +38,6 @@
|
||||
</div>
|
||||
<div id="workerList" class="worker-list"></div>
|
||||
</div>
|
||||
|
||||
<div id="mapContainer">
|
||||
<div id="map"></div>
|
||||
<div id="dropOverlay">여기에 드롭하여 배치</div>
|
||||
@@ -45,34 +49,151 @@
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script>
|
||||
const YONGSAN = [37.5326, 126.9906];
|
||||
|
||||
let map, store;
|
||||
|
||||
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],
|
||||
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],
|
||||
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],
|
||||
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 }),
|
||||
});
|
||||
tabDataCache[r.id] = { workers: [], workplaces: [], assignments: {} };
|
||||
activeTabId = r.id;
|
||||
await loadTabs();
|
||||
}
|
||||
|
||||
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 ──
|
||||
|
||||
function init() {
|
||||
map = L.map('map').setView(YONGSAN, 14);
|
||||
@@ -81,67 +202,48 @@ function init() {
|
||||
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) => {
|
||||
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 || !store) return;
|
||||
|
||||
if (!workerId || !activeTabId) 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 latlng = map.containerPointToLatLng([e.clientX - rect.left, e.clientY - rect.top]);
|
||||
const nearest = findNearestWorkplace(latlng.lat, latlng.lng);
|
||||
if (!nearest) {
|
||||
showToast('근무지 마커 근처에 드롭해주세요.', 'warning');
|
||||
return;
|
||||
}
|
||||
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 (store?.workplaces || [])) {
|
||||
const d = Math.abs(wp.lat - lat) + Math.abs(wp.lng - lng);
|
||||
if (d < threshold && d < bestDist) {
|
||||
bestDist = d;
|
||||
best = wp;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
async function uploadExcel(e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!file || !activeTabId) return;
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
|
||||
form.append('tab', activeTabId);
|
||||
showToast('업로드 중...', 'info');
|
||||
|
||||
try {
|
||||
const resp = await fetch('/upload', { method: 'POST', body: form });
|
||||
if (!resp.ok) throw new Error('업로드 실패');
|
||||
store = await resp.json();
|
||||
const r = await api('/api/upload', { method: 'POST', body: form });
|
||||
tabDataCache[activeTabId] = r;
|
||||
render();
|
||||
await loadTabs();
|
||||
showToast(`파일 로드 완료: ${file.name}`, 'success');
|
||||
} catch (err) {
|
||||
showToast('업로드 실패: ' + err.message, 'error');
|
||||
@@ -149,8 +251,10 @@ async function uploadExcel(e) {
|
||||
e.target.value = '';
|
||||
}
|
||||
|
||||
// ── Rendering ──
|
||||
|
||||
function render() {
|
||||
if (!store) return;
|
||||
if (!activeTabId) return;
|
||||
clearMap();
|
||||
renderWorkplaceMarkers();
|
||||
renderWorkerMarkers();
|
||||
@@ -159,88 +263,69 @@ function render() {
|
||||
}
|
||||
|
||||
function clearMap() {
|
||||
for (const key in markers.workers) {
|
||||
map.removeLayer(markers.workers[key]);
|
||||
}
|
||||
for (const key in markers.workplaces) {
|
||||
map.removeLayer(markers.workplaces[key]);
|
||||
}
|
||||
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 getWorkplaceAssigneesHtml(wpId) {
|
||||
const assigned = store.workers.filter(w => store.assignments[w.id] === 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 0;padding-left:16px;">' +
|
||||
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 store.workplaces) {
|
||||
for (const wp of d.workplaces) {
|
||||
const marker = L.marker([wp.lat, wp.lng], { icon: WORKPLACE_ICON })
|
||||
.addTo(map)
|
||||
.bindPopup(`
|
||||
.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>
|
||||
`);
|
||||
</div>`);
|
||||
markers.workplaces[wp.id] = marker;
|
||||
bounds.push([wp.lat, wp.lng]);
|
||||
}
|
||||
if (bounds.length > 0) {
|
||||
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 16 });
|
||||
}
|
||||
if (bounds.length > 0) map.fitBounds(bounds, { padding: [50, 50], maxZoom: 16 });
|
||||
}
|
||||
|
||||
function getWorkerPos(w) {
|
||||
const wpId = store.assignments[w.id];
|
||||
const d = currentData();
|
||||
const wpId = d.assignments[w.id];
|
||||
if (wpId) {
|
||||
const wp = store.workplaces.find(p => p.id === wpId);
|
||||
const wp = d.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(getWorkerPos(w), { icon, draggable: true })
|
||||
.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}', true)">배치 취소</button>` : ''}
|
||||
</div>
|
||||
`);
|
||||
marker.on('dragend', function () {
|
||||
handleWorkerDragEnd(w.id, this);
|
||||
});
|
||||
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);
|
||||
|
||||
if (nearest) {
|
||||
fetch('/assign', {
|
||||
api('/api/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;
|
||||
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));
|
||||
@@ -248,19 +333,17 @@ function handleWorkerDragEnd(workerId, marker) {
|
||||
renderWorkerList();
|
||||
updateBadges();
|
||||
showToast(`${getWorkerName(workerId)} → ${nearest.name} 배치됨`, 'success');
|
||||
})
|
||||
.catch(() => showToast('배치 저장 실패', 'error'));
|
||||
} else if (store.assignments[workerId]) {
|
||||
const oldWpId = store.assignments[workerId];
|
||||
fetch('/unassign', {
|
||||
}).catch(() => showToast('배치 저장 실패', 'error'));
|
||||
|
||||
} else if (d.assignments[workerId]) {
|
||||
const oldWpId = d.assignments[workerId];
|
||||
api('/api/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);
|
||||
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));
|
||||
@@ -268,18 +351,19 @@ function handleWorkerDragEnd(workerId, marker) {
|
||||
renderWorkerList();
|
||||
updateBadges();
|
||||
showToast('배치가 취소되었습니다.', 'info');
|
||||
})
|
||||
.catch(() => showToast('배치 취소 실패', 'error'));
|
||||
}).catch(() => showToast('배치 취소 실패', 'error'));
|
||||
|
||||
} else {
|
||||
const w = store.workers.find(x => x.id === workerId);
|
||||
const w = d.workers.find(x => x.id === workerId);
|
||||
marker.setLatLng([w.lat, w.lng]);
|
||||
}
|
||||
}
|
||||
|
||||
function buildWorkerPopup(workerId) {
|
||||
const w = store.workers.find(x => x.id === workerId);
|
||||
const d = currentData();
|
||||
const w = d.workers.find(x => x.id === workerId);
|
||||
if (!w) return '';
|
||||
const isAssigned = store.assignments[w.id];
|
||||
const isAssigned = d.assignments[w.id];
|
||||
const wpName = isAssigned ? getWorkplaceName(isAssigned) : '';
|
||||
return `
|
||||
<div class="popup-content">
|
||||
@@ -294,20 +378,20 @@ function buildWorkerPopup(workerId) {
|
||||
}
|
||||
|
||||
function getWorkerName(id) {
|
||||
const w = store.workers.find(x => x.id === 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 store.workers) {
|
||||
const isAssigned = store.assignments[w.id];
|
||||
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>
|
||||
@@ -320,59 +404,53 @@ function renderWorkerList() {
|
||||
<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');
|
||||
});
|
||||
|
||||
</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 updateWorkplacePopup(wpId) {
|
||||
const d = currentData();
|
||||
const marker = markers.workplaces[wpId];
|
||||
if (!marker) return;
|
||||
const wp = store.workplaces.find(p => p.id === wpId);
|
||||
if (!wp) return;
|
||||
const wp = d.workplaces.find(p => p.id === wpId);
|
||||
if (!marker || !wp) return;
|
||||
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>
|
||||
`);
|
||||
</div>`);
|
||||
}
|
||||
|
||||
function updateBadges() {
|
||||
const assigned = Object.keys(store.assignments).length;
|
||||
const total = store.workers.length;
|
||||
const d = currentData();
|
||||
const assigned = Object.keys(d.assignments).length;
|
||||
const total = d.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' : ''}`;
|
||||
const badge = document.getElementById('statusBadge');
|
||||
badge.textContent = assigned > 0 ? `배치 ${assigned}/${total}` : '';
|
||||
badge.className = `status-badge ${!total ? '' : assigned === total ? 'complete' : assigned > 0 ? 'partial' : ''}`;
|
||||
}
|
||||
|
||||
function doAssignWorker(workerId, workplaceId) {
|
||||
if (!store) return;
|
||||
if (store.assignments[workerId] === workplaceId) return;
|
||||
// ── Actions ──
|
||||
|
||||
fetch('/assign', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workerId, workplaceId }),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
store.assignments = data.assignments;
|
||||
const w = store.workers.find(x => x.id === workerId);
|
||||
const wp = store.workplaces.find(p => p.id === workplaceId);
|
||||
async function doAssignWorker(workerId, workplaceId) {
|
||||
if (!activeTabId) return;
|
||||
const d = currentData();
|
||||
if (d.assignments[workerId] === 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]);
|
||||
@@ -383,23 +461,21 @@ function doAssignWorker(workerId, workplaceId) {
|
||||
renderWorkerList();
|
||||
updateBadges();
|
||||
showToast('배치가 저장되었습니다.', 'success');
|
||||
})
|
||||
.catch(() => showToast('배치 저장 실패', 'error'));
|
||||
} catch { showToast('배치 저장 실패', 'error'); }
|
||||
}
|
||||
|
||||
function unassignWorker(workerId, fromPopup) {
|
||||
if (!store) return;
|
||||
|
||||
fetch('/unassign', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workerId }),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const oldWpId = store.assignments[workerId];
|
||||
store.assignments = data.assignments;
|
||||
const w = store.workers.find(x => x.id === workerId);
|
||||
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]);
|
||||
@@ -411,46 +487,44 @@ function unassignWorker(workerId, fromPopup) {
|
||||
updateBadges();
|
||||
if (fromPopup) map.closePopup();
|
||||
showToast('배치가 취소되었습니다.', 'info');
|
||||
})
|
||||
.catch(() => showToast('배치 취소 실패', 'error'));
|
||||
} catch { showToast('배치 취소 실패', 'error'); }
|
||||
}
|
||||
|
||||
function resetAssignments() {
|
||||
if (!store || Object.keys(store.assignments).length === 0) return;
|
||||
async function resetAssignments() {
|
||||
const d = currentData();
|
||||
if (!activeTabId || Object.keys(d.assignments).length === 0) return;
|
||||
if (!confirm('모든 배치를 초기화하시겠습니까?')) return;
|
||||
|
||||
fetch('/reset', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
store.assignments = data.assignments;
|
||||
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));
|
||||
}
|
||||
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 store.workplaces) {
|
||||
updateWorkplacePopup(wp.id);
|
||||
}
|
||||
renderWorkerList();
|
||||
updateBadges();
|
||||
showToast('모든 배치가 초기화되었습니다.', 'info');
|
||||
})
|
||||
.catch(() => showToast('초기화 실패', 'error'));
|
||||
}
|
||||
for (const wp of d.workplaces) updateWorkplacePopup(wp.id);
|
||||
renderWorkerList();
|
||||
updateBadges();
|
||||
showToast('모든 배치가 초기화되었습니다.', 'info');
|
||||
} catch { showToast('초기화 실패', 'error'); }
|
||||
}
|
||||
|
||||
function exportExcel() {
|
||||
if (!store || store.workers.length === 0) {
|
||||
showToast('내보낼 데이터가 없습니다.', 'warning');
|
||||
return;
|
||||
}
|
||||
window.location.href = '/export';
|
||||
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 = store.workplaces.find(p => p.id === id);
|
||||
const wp = currentData().workplaces.find(p => p.id === id);
|
||||
return wp ? wp.name : '(알 수 없음)';
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user