Feat: 탭 기능 추가 - 날짜/용도별 독립 작업 공간
- 탭바: 자유로운 이름 지정, 추가/삭제/더블클릭 이름변경 - 각 탭은 독립적인 근무자/근무지/배치 데이터 보유 - 탭 전환 시 해당 데이터로 지도/사이드바 갱신 - 내보내기 시 현재 탭 이름이 파일명에 포함됨 - API 경로 /api/* 로 통일
This commit is contained in:
254
app.py
254
app.py
@@ -1,8 +1,4 @@
|
||||
import os
|
||||
import io
|
||||
import random
|
||||
import requests
|
||||
import openpyxl
|
||||
import os, io, random, requests, openpyxl
|
||||
from flask import Flask, render_template, request, jsonify, send_file
|
||||
|
||||
app = Flask(__name__)
|
||||
@@ -13,13 +9,20 @@ os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
||||
YONGSAN_CENTER = (37.5326, 126.9906)
|
||||
|
||||
store = {
|
||||
'workers': [],
|
||||
'workplaces': [],
|
||||
'assignments': {},
|
||||
'tabs': {},
|
||||
'active_tab': None,
|
||||
'next_id': 0,
|
||||
'filename': None,
|
||||
}
|
||||
|
||||
|
||||
def tab_data(tab_id):
|
||||
t = store['tabs'].get(tab_id)
|
||||
if not t:
|
||||
return None, 404
|
||||
return t, None
|
||||
|
||||
|
||||
def geocode(address):
|
||||
try:
|
||||
url = 'https://nominatim.openstreetmap.org/search'
|
||||
@@ -48,25 +51,105 @@ def geocode_with_fallback(address):
|
||||
return YONGSAN_CENTER[0] + jitter, YONGSAN_CENTER[1] + jitter
|
||||
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
return render_template('index.html')
|
||||
def empty_tab(name):
|
||||
return {'name': name, 'workers': [], 'workplaces': [], 'assignments': {}}
|
||||
|
||||
|
||||
@app.route('/upload', methods=['POST'])
|
||||
# ── Tab management ──
|
||||
|
||||
@app.route('/api/tabs')
|
||||
def list_tabs():
|
||||
tabs = [{'id': tid, 'name': t['name'],
|
||||
'workerCount': len(t['workers']),
|
||||
'assignCount': len(t['assignments'])}
|
||||
for tid, t in store['tabs'].items()]
|
||||
return jsonify({'tabs': tabs, 'activeTab': store['active_tab']})
|
||||
|
||||
|
||||
@app.route('/api/tabs/add', methods=['POST'])
|
||||
def add_tab():
|
||||
body = request.get_json() or {}
|
||||
name = (body.get('name') or '').strip() or f'탭 {store["next_id"] + 1}'
|
||||
tid = f'tab_{store["next_id"]}'
|
||||
store['next_id'] += 1
|
||||
store['tabs'][tid] = empty_tab(name)
|
||||
if store['active_tab'] is None:
|
||||
store['active_tab'] = tid
|
||||
return jsonify({'id': tid, 'name': name})
|
||||
|
||||
|
||||
@app.route('/api/tabs/rename', methods=['POST'])
|
||||
def rename_tab():
|
||||
body = request.get_json() or {}
|
||||
tid = body.get('id')
|
||||
name = (body.get('name') or '').strip()
|
||||
if not tid or not name or tid not in store['tabs']:
|
||||
return jsonify({'error': '잘못된 요청'}), 400
|
||||
store['tabs'][tid]['name'] = name
|
||||
return jsonify({'id': tid, 'name': name})
|
||||
|
||||
|
||||
@app.route('/api/tabs/delete', methods=['POST'])
|
||||
def delete_tab():
|
||||
body = request.get_json() or {}
|
||||
tid = body.get('id')
|
||||
if not tid or tid not in store['tabs']:
|
||||
return jsonify({'error': '잘못된 요청'}), 400
|
||||
del store['tabs'][tid]
|
||||
if store['active_tab'] == tid:
|
||||
keys = list(store['tabs'].keys())
|
||||
store['active_tab'] = keys[0] if keys else None
|
||||
return jsonify({'deleted': tid})
|
||||
|
||||
|
||||
@app.route('/api/tabs/activate', methods=['POST'])
|
||||
def activate_tab():
|
||||
body = request.get_json() or {}
|
||||
tid = body.get('id')
|
||||
if not tid or tid not in store['tabs']:
|
||||
return jsonify({'error': '탭을 찾을 수 없음'}), 400
|
||||
store['active_tab'] = tid
|
||||
return jsonify({'activeTab': tid})
|
||||
|
||||
|
||||
# ── Data ──
|
||||
|
||||
def get_active():
|
||||
t = store['tabs'].get(store['active_tab'])
|
||||
if not t:
|
||||
return None, jsonify({'error': '탭 없음'}), 400
|
||||
return t, None, None
|
||||
|
||||
|
||||
@app.route('/api/data')
|
||||
def get_data():
|
||||
tid = request.args.get('tab') or store['active_tab']
|
||||
t = store['tabs'].get(tid)
|
||||
if not t:
|
||||
return jsonify({'workers': [], 'workplaces': [], 'assignments': {}})
|
||||
return jsonify({
|
||||
'workers': t['workers'],
|
||||
'workplaces': t['workplaces'],
|
||||
'assignments': t['assignments'],
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/upload', methods=['POST'])
|
||||
def upload():
|
||||
tid = request.form.get('tab') or store['active_tab']
|
||||
t = store['tabs'].get(tid)
|
||||
if not t:
|
||||
return jsonify({'error': '탭 없음'}), 400
|
||||
|
||||
file = request.files.get('file')
|
||||
if not file:
|
||||
return jsonify({'error': '파일이 없습니다.'}), 400
|
||||
|
||||
path = os.path.join(app.config['UPLOAD_FOLDER'], file.filename)
|
||||
file.save(path)
|
||||
|
||||
wb = openpyxl.load_workbook(path, data_only=True)
|
||||
|
||||
workers = []
|
||||
workplaces = []
|
||||
|
||||
workers, workplaces = [], []
|
||||
for sheet_name in wb.sheetnames:
|
||||
ws = wb[sheet_name]
|
||||
headers = [str(c.value or '').strip() for c in ws[1]]
|
||||
@@ -77,22 +160,17 @@ def upload():
|
||||
if not any(row):
|
||||
continue
|
||||
name = str(row[idx.get('이름', 0)] or '').strip()
|
||||
hope = str(row[idx.get('희망사항', 1)] or '').strip()
|
||||
addr = str(row[idx.get('주소', 2)] or '').strip()
|
||||
dob = str(row[idx.get('생년월일', 3)] or '').strip()
|
||||
phone = str(row[idx.get('연락처', 4)] or '').strip()
|
||||
if not name:
|
||||
continue
|
||||
lat, lng = geocode_with_fallback(addr)
|
||||
lat, lng = geocode_with_fallback(str(row[idx.get('주소', 2)] or '').strip())
|
||||
workers.append({
|
||||
'id': f'w{len(workers)}',
|
||||
'name': name,
|
||||
'hope': hope,
|
||||
'address': addr,
|
||||
'dob': dob,
|
||||
'phone': phone,
|
||||
'lat': lat,
|
||||
'lng': lng,
|
||||
'hope': str(row[idx.get('희망사항', 1)] or '').strip(),
|
||||
'address': str(row[idx.get('주소', 2)] or '').strip(),
|
||||
'dob': str(row[idx.get('생년월일', 3)] or '').strip(),
|
||||
'phone': str(row[idx.get('연락처', 4)] or '').strip(),
|
||||
'lat': lat, 'lng': lng,
|
||||
})
|
||||
|
||||
if '희망사항' not in headers:
|
||||
@@ -101,106 +179,100 @@ def upload():
|
||||
if not any(row):
|
||||
continue
|
||||
name = str(row[idx.get('이름', 0)] or '').strip()
|
||||
addr = str(row[idx.get('주소', 1)] or '').strip()
|
||||
if not name:
|
||||
continue
|
||||
lat, lng = geocode_with_fallback(addr)
|
||||
lat, lng = geocode_with_fallback(str(row[idx.get('주소', 1)] or '').strip())
|
||||
workplaces.append({
|
||||
'id': f'p{len(workplaces)}',
|
||||
'name': name,
|
||||
'address': addr,
|
||||
'lat': lat,
|
||||
'lng': lng,
|
||||
'address': str(row[idx.get('주소', 1)] or '').strip(),
|
||||
'lat': lat, 'lng': lng,
|
||||
})
|
||||
|
||||
store['workers'] = workers
|
||||
store['workplaces'] = workplaces
|
||||
store['assignments'] = {}
|
||||
t['workers'] = workers
|
||||
t['workplaces'] = workplaces
|
||||
t['assignments'] = {}
|
||||
store['filename'] = file.filename
|
||||
|
||||
return jsonify({
|
||||
'workers': workers,
|
||||
'workplaces': workplaces,
|
||||
'assignments': {},
|
||||
})
|
||||
return jsonify({'workers': workers, 'workplaces': workplaces, 'assignments': {}})
|
||||
|
||||
|
||||
@app.route('/data')
|
||||
def get_data():
|
||||
return jsonify({
|
||||
'workers': store['workers'],
|
||||
'workplaces': store['workplaces'],
|
||||
'assignments': store['assignments'],
|
||||
})
|
||||
|
||||
|
||||
@app.route('/assign', methods=['POST'])
|
||||
@app.route('/api/assign', methods=['POST'])
|
||||
def assign():
|
||||
body = request.get_json()
|
||||
worker_id = body.get('workerId')
|
||||
workplace_id = body.get('workplaceId')
|
||||
body = request.get_json() or {}
|
||||
tid = body.get('tab') or store['active_tab']
|
||||
t = store['tabs'].get(tid)
|
||||
if not t:
|
||||
return jsonify({'error': '탭 없음'}), 400
|
||||
worker_id, workplace_id = body.get('workerId'), body.get('workplaceId')
|
||||
if not worker_id or not workplace_id:
|
||||
return jsonify({'error': '잘못된 요청입니다.'}), 400
|
||||
store['assignments'][worker_id] = workplace_id
|
||||
return jsonify({'assignments': store['assignments']})
|
||||
return jsonify({'error': '잘못된 요청'}), 400
|
||||
t['assignments'][worker_id] = workplace_id
|
||||
return jsonify({'assignments': t['assignments']})
|
||||
|
||||
|
||||
@app.route('/unassign', methods=['POST'])
|
||||
@app.route('/api/unassign', methods=['POST'])
|
||||
def unassign():
|
||||
body = request.get_json()
|
||||
body = request.get_json() or {}
|
||||
tid = body.get('tab') or store['active_tab']
|
||||
t = store['tabs'].get(tid)
|
||||
if not t:
|
||||
return jsonify({'error': '탭 없음'}), 400
|
||||
worker_id = body.get('workerId')
|
||||
store['assignments'].pop(worker_id, None)
|
||||
return jsonify({'assignments': store['assignments']})
|
||||
t['assignments'].pop(worker_id, None)
|
||||
return jsonify({'assignments': t['assignments']})
|
||||
|
||||
|
||||
@app.route('/reset', methods=['POST'])
|
||||
@app.route('/api/reset', methods=['POST'])
|
||||
def reset():
|
||||
store['assignments'] = {}
|
||||
return jsonify({'assignments': {}})
|
||||
body = request.get_json() or {}
|
||||
tid = body.get('tab') or store['active_tab']
|
||||
t = store['tabs'].get(tid)
|
||||
if t:
|
||||
t['assignments'] = {}
|
||||
return jsonify({'assignments': t['assignments'] if t else {}})
|
||||
|
||||
|
||||
@app.route('/export')
|
||||
@app.route('/api/export')
|
||||
def export():
|
||||
tid = request.args.get('tab') or store['active_tab']
|
||||
t = store['tabs'].get(tid)
|
||||
if not t:
|
||||
return jsonify({'error': '탭 없음'}), 400
|
||||
|
||||
wb = openpyxl.Workbook()
|
||||
|
||||
ws1 = wb.active
|
||||
ws1.title = '배치 현황'
|
||||
ws1.append(['이름', '연락처', '생년월일', '희망사항', '주소', '배치근무지', '근무지주소'])
|
||||
|
||||
wp_lookup = {wp['id']: wp for wp in store['workplaces']}
|
||||
for w in store['workers']:
|
||||
assigned = store['assignments'].get(w['id'])
|
||||
wp_name = ''
|
||||
wp_addr = ''
|
||||
if assigned and assigned in wp_lookup:
|
||||
wp_name = wp_lookup[assigned]['name']
|
||||
wp_addr = wp_lookup[assigned]['address']
|
||||
ws1.append([w['name'], w['phone'], w['dob'], w['hope'], w['address'], wp_name, wp_addr])
|
||||
wp_lookup = {wp['id']: wp for wp in t['workplaces']}
|
||||
for w in t['workers']:
|
||||
a_id = t['assignments'].get(w['id'])
|
||||
wn = wp_lookup[a_id]['name'] if a_id and a_id in wp_lookup else ''
|
||||
wa = wp_lookup[a_id]['address'] if a_id and a_id in wp_lookup else ''
|
||||
ws1.append([w['name'], w['phone'], w['dob'], w['hope'], w['address'], wn, wa])
|
||||
|
||||
ws2 = wb.create_sheet('근무지별 배치')
|
||||
ws2.append(['근무지명', '주소', '배치인원', '배치된근무자'])
|
||||
|
||||
for wp in store['workplaces']:
|
||||
assigned_workers = [
|
||||
w['name'] for w in store['workers']
|
||||
if store['assignments'].get(w['id']) == wp['id']
|
||||
]
|
||||
count = len(assigned_workers)
|
||||
ws2.append([wp['name'], wp['address'], count, ', '.join(assigned_workers)])
|
||||
for wp in t['workplaces']:
|
||||
aw = [w['name'] for w in t['workers'] if t['assignments'].get(w['id']) == wp['id']]
|
||||
ws2.append([wp['name'], wp['address'], len(aw), ', '.join(aw)])
|
||||
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
buf.seek(0)
|
||||
|
||||
name = store.get('filename', 'export.xlsx')
|
||||
base, ext = os.path.splitext(name)
|
||||
download_name = f'{base}_배치결과{ext}'
|
||||
tab_name = t['name']
|
||||
fname = store.get('filename', 'export.xlsx')
|
||||
base, ext = os.path.splitext(fname)
|
||||
dn = f'{base}_{tab_name}_배치결과{ext}'
|
||||
return send_file(buf, as_attachment=True, download_name=dn,
|
||||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
||||
|
||||
return send_file(
|
||||
buf,
|
||||
as_attachment=True,
|
||||
download_name=download_name,
|
||||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
)
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
return render_template('index.html')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -31,6 +31,39 @@ body { font-family: 'Segoe UI', 'Malgun Gothic', sans-serif; height: 100vh; over
|
||||
.status-badge.partial { background: #fef3c7; color: #d97706; }
|
||||
.status-badge.complete { background: #d1fae5; color: #059669; }
|
||||
|
||||
#tabBar {
|
||||
display: flex; align-items: center; background: #e8eaed; padding: 0 12px;
|
||||
border-bottom: 1px solid #d0d0d0; min-height: 40px; gap: 2px; z-index: 999; flex-shrink: 0;
|
||||
}
|
||||
.tab-list { display: flex; align-items: stretch; flex: 1; overflow-x: auto; gap: 2px; }
|
||||
.tab-list::-webkit-scrollbar { height: 3px; }
|
||||
.tab-list::-webkit-scrollbar-thumb { background: #ccc; border-radius: 2px; }
|
||||
.tab-item {
|
||||
display: flex; align-items: center; gap: 6px; padding: 6px 10px;
|
||||
background: #dde0e3; border-radius: 6px 6px 0 0; cursor: pointer;
|
||||
font-size: 13px; color: #555; white-space: nowrap; user-select: none;
|
||||
transition: all 0.15s; min-width: 0;
|
||||
}
|
||||
.tab-item:hover { background: #d0d3d6; }
|
||||
.tab-item.active { background: #fff; color: #1a73e8; font-weight: 600; box-shadow: 0 -1px 2px rgba(0,0,0,0.06); }
|
||||
.tab-name { flex: 1; min-width: 30px; }
|
||||
.tab-name:hover { text-decoration: underline; }
|
||||
.tab-badge { font-size: 10px; color: #888; background: #f0f0f0; padding: 1px 6px; border-radius: 8px; }
|
||||
.tab-item.active .tab-badge { background: #e8f0fe; color: #1a73e8; }
|
||||
.tab-del { font-size: 16px; color: #999; line-height: 1; padding: 0 2px; }
|
||||
.tab-del:hover { color: #ef4444; }
|
||||
.tab-add {
|
||||
flex-shrink: 0; width: 30px; height: 30px; border: none; background: transparent;
|
||||
border-radius: 6px; cursor: pointer; color: #666; font-size: 16px;
|
||||
display: flex; align-items: center; justify-content: center; margin-left: 4px;
|
||||
}
|
||||
.tab-add:hover { background: #d0d3d6; color: #1a73e8; }
|
||||
|
||||
.empty-state {
|
||||
display: flex; align-items: center; justify-content: center; height: 100%;
|
||||
color: #9ca3af; font-size: 14px; text-align: center; padding: 40px;
|
||||
}
|
||||
|
||||
#main { display: flex; flex: 1; overflow: hidden; }
|
||||
|
||||
#sidebar {
|
||||
|
||||
@@ -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', {
|
||||
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 }),
|
||||
})
|
||||
.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);
|
||||
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', {
|
||||
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 }),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const oldWpId = store.assignments[workerId];
|
||||
store.assignments = data.assignments;
|
||||
const w = store.workers.find(x => x.id === workerId);
|
||||
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,19 +487,21 @@ 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) {
|
||||
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]);
|
||||
@@ -431,26 +509,22 @@ function resetAssignments() {
|
||||
marker.setPopupContent(buildWorkerPopup(w.id));
|
||||
}
|
||||
}
|
||||
for (const wp of store.workplaces) {
|
||||
updateWorkplacePopup(wp.id);
|
||||
}
|
||||
for (const wp of d.workplaces) updateWorkplacePopup(wp.id);
|
||||
renderWorkerList();
|
||||
updateBadges();
|
||||
showToast('모든 배치가 초기화되었습니다.', 'info');
|
||||
})
|
||||
.catch(() => showToast('초기화 실패', 'error'));
|
||||
} 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