Feat: 근무처 목록 및 배지 표시, 엑셀 업로드 진행 팝업 추가
- 근무처 마커에 할당 인원 배지 표시 - 근무처 목록 패널 추가 (탭 전환 가능) - 탭 생성 후 F5 없이 즉시 표시되도록 버그 수정 - VWorld/카카오 지오코딩 API 지원 추가 (type 파라미터 포함) - 엑셀 업로드 중 진행 상황 팝업 모달 추가 - 근무처 목록 근무자 표시 3명 → 4명으로 변경 - 지오코딩 진행 상황 폴링 API 추가
This commit is contained in:
443
app.py
443
app.py
@@ -1,5 +1,9 @@
|
|||||||
import os, io, random, requests, openpyxl, concurrent.futures
|
import os, io, random, requests, openpyxl, concurrent.futures
|
||||||
from flask import Flask, render_template, request, jsonify, send_file
|
from flask import Flask, render_template, request, jsonify, send_file
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.DEBUG, format='%(asctime)s %(levelname)s: %(message)s')
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config['UPLOAD_FOLDER'] = 'uploads'
|
app.config['UPLOAD_FOLDER'] = 'uploads'
|
||||||
@@ -15,6 +19,15 @@ store = {
|
|||||||
'filename': None,
|
'filename': None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
upload_progress = {
|
||||||
|
'active': False,
|
||||||
|
'stage': '',
|
||||||
|
'total': 0,
|
||||||
|
'current': 0,
|
||||||
|
'message': '',
|
||||||
|
'logs': [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def tab_data(tab_id):
|
def tab_data(tab_id):
|
||||||
t = store['tabs'].get(tab_id)
|
t = store['tabs'].get(tab_id)
|
||||||
@@ -24,23 +37,217 @@ def tab_data(tab_id):
|
|||||||
|
|
||||||
|
|
||||||
GEO_CACHE = {}
|
GEO_CACHE = {}
|
||||||
|
GEO_STATS = {'success': 0, 'failed': 0, 'failed_addrs': []}
|
||||||
|
|
||||||
def geocode_with_fallback(address):
|
import time, re
|
||||||
if address in GEO_CACHE:
|
|
||||||
return GEO_CACHE[address]
|
KAKAO_API_KEY = os.environ.get('KAKAO_API_KEY', '')
|
||||||
|
VWORLD_API_KEY = os.environ.get('VWORLD_API_KEY', 'E1FD0345-8021-3C9C-A397-CE0D88736D78')
|
||||||
|
|
||||||
|
def geocode_kakao(address, timeout=10):
|
||||||
|
if not KAKAO_API_KEY:
|
||||||
|
return None
|
||||||
try:
|
try:
|
||||||
url = 'https://nominatim.openstreetmap.org/search'
|
url = 'https://dapi.kakao.com/v2/local/search/address.json'
|
||||||
params = {'q': address.strip(), 'format': 'json', 'limit': 1, 'countrycodes': 'kr'}
|
headers = {'Authorization': f'KakaoAK {KAKAO_API_KEY}'}
|
||||||
headers = {'User-Agent': 'MapinDrag/1.0'}
|
params = {'query': address.strip(), 'size': 1}
|
||||||
resp = requests.get(url, params=params, headers=headers, timeout=5)
|
resp = requests.get(url, params=params, headers=headers, timeout=timeout)
|
||||||
if resp.ok:
|
if resp.ok:
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
if data:
|
if data.get('documents'):
|
||||||
lat, lng = float(data[0]['lat']), float(data[0]['lon'])
|
doc = data['documents'][0]
|
||||||
GEO_CACHE[address] = (lat, lng)
|
return float(doc['y']), float(doc['x'])
|
||||||
return lat, lng
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def looks_like_road_address(addr):
|
||||||
|
road_patterns = ['로', '길', '대로', '번길']
|
||||||
|
for p in road_patterns:
|
||||||
|
if p in addr and any(c.isdigit() for c in addr[addr.find(p):]):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def geocode_vworld(address, timeout=15):
|
||||||
|
if not VWORLD_API_KEY:
|
||||||
|
return None
|
||||||
|
|
||||||
|
addr = address.strip()
|
||||||
|
variants = [
|
||||||
|
addr,
|
||||||
|
f'서울특별시 용산구 {addr}' if '용산구' not in addr and '서울' not in addr else None,
|
||||||
|
f'용산구 {addr}' if '용산구' not in addr else None,
|
||||||
|
]
|
||||||
|
variants = [v for v in variants if v]
|
||||||
|
|
||||||
|
if looks_like_road_address(addr):
|
||||||
|
types = ['ROAD', 'PARCEL']
|
||||||
|
else:
|
||||||
|
types = ['PARCEL', 'ROAD']
|
||||||
|
|
||||||
|
for v in variants:
|
||||||
|
for addr_type in types:
|
||||||
|
try:
|
||||||
|
url = 'https://api.vworld.kr/req/address'
|
||||||
|
params = {
|
||||||
|
'service': 'address',
|
||||||
|
'request': 'getcoord',
|
||||||
|
'version': '2.0',
|
||||||
|
'crs': 'EPSG:4326',
|
||||||
|
'address': v,
|
||||||
|
'format': 'json',
|
||||||
|
'type': addr_type,
|
||||||
|
'key': VWORLD_API_KEY,
|
||||||
|
}
|
||||||
|
logger.debug(f' [VWorld 요청] type={addr_type}, address={v}')
|
||||||
|
resp = requests.get(url, params=params, timeout=timeout)
|
||||||
|
|
||||||
|
if not resp.ok:
|
||||||
|
logger.warning(f' [VWorld HTTP 오류] 상태={resp.status_code}, 응답={resp.text[:200]}')
|
||||||
|
continue
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
logger.debug(f' [VWorld 응답] {data}')
|
||||||
|
|
||||||
|
response = data.get('response', {})
|
||||||
|
status = response.get('status')
|
||||||
|
|
||||||
|
if status == 'OK' or status == 'SUCCESS':
|
||||||
|
result = response.get('result')
|
||||||
|
if result:
|
||||||
|
point = result.get('point')
|
||||||
|
if point and point.get('x') and point.get('y'):
|
||||||
|
lat = float(point['y'])
|
||||||
|
lng = float(point['x'])
|
||||||
|
logger.info(f' [VWorld 성공] {addr} (type={addr_type}) -> ({lat}, {lng})')
|
||||||
|
return lat, lng
|
||||||
|
else:
|
||||||
|
err_msg = response.get('error', {}).get('message', str(data))
|
||||||
|
logger.debug(f' [VWorld {addr_type} 실패] {v}: status={status}, 오류={err_msg}')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f' [VWorld 예외] {v}: {e}')
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_korean_address(addr):
|
||||||
|
patterns = [
|
||||||
|
(r'서울시', '서울특별시'),
|
||||||
|
(r'경기도', '경기'),
|
||||||
|
(r'(\d+)동', r'\1동'),
|
||||||
|
]
|
||||||
|
for old, new in patterns:
|
||||||
|
addr = re.sub(old, new, addr)
|
||||||
|
return addr
|
||||||
|
|
||||||
|
|
||||||
|
def geocode_nominatim(address, timeout=10):
|
||||||
|
addr = normalize_korean_address(address.strip())
|
||||||
|
variants = [
|
||||||
|
addr,
|
||||||
|
f'{addr}, 서울특별시, 대한민국',
|
||||||
|
f'{addr}, 대한민국',
|
||||||
|
]
|
||||||
|
|
||||||
|
for v in variants:
|
||||||
|
try:
|
||||||
|
url = 'https://nominatim.openstreetmap.org/search'
|
||||||
|
params = {
|
||||||
|
'q': v,
|
||||||
|
'format': 'json',
|
||||||
|
'limit': 3,
|
||||||
|
'countrycodes': 'kr',
|
||||||
|
'accept-language': 'ko,en',
|
||||||
|
}
|
||||||
|
headers = {'User-Agent': 'MapinDrag/1.0 (non-commercial research)'}
|
||||||
|
resp = requests.get(url, params=params, headers=headers, timeout=timeout)
|
||||||
|
if resp.ok:
|
||||||
|
results = resp.json()
|
||||||
|
if results:
|
||||||
|
for r in results:
|
||||||
|
lat = float(r['lat'])
|
||||||
|
lng = float(r['lon'])
|
||||||
|
if 37 < lat < 38 and 126.9 < lng < 127.1:
|
||||||
|
return lat, lng
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def geocode_with_fallback(address):
|
||||||
|
address = str(address or '').strip()
|
||||||
|
|
||||||
|
if not address:
|
||||||
|
jitter = random.uniform(-0.005, 0.005)
|
||||||
|
return (YONGSAN_CENTER[0] + jitter, YONGSAN_CENTER[1] + jitter)
|
||||||
|
|
||||||
|
if address in GEO_CACHE:
|
||||||
|
return GEO_CACHE[address]
|
||||||
|
|
||||||
|
addr_clean = address
|
||||||
|
prefixes = ['서울특별시 ', '서울시 ', '서울 ', '용산구 ']
|
||||||
|
for p in prefixes:
|
||||||
|
if addr_clean.startswith(p):
|
||||||
|
addr_clean = addr_clean[len(p):]
|
||||||
|
break
|
||||||
|
|
||||||
|
variants = []
|
||||||
|
|
||||||
|
if '용산구' not in address and '서울' not in address:
|
||||||
|
variants.append(f'서울특별시 용산구 ' + addr_clean)
|
||||||
|
variants.append(f'용산구 ' + addr_clean)
|
||||||
|
variants.append(address)
|
||||||
|
|
||||||
|
if '서울' in address and '용산구' not in address:
|
||||||
|
new_addr = address.replace('서울특별시', '서울특별시 용산구').replace('서울시', '서울시 용산구')
|
||||||
|
variants.append(new_addr)
|
||||||
|
|
||||||
|
unique_vars = []
|
||||||
|
seen = set()
|
||||||
|
for v in variants:
|
||||||
|
if v not in seen:
|
||||||
|
seen.add(v)
|
||||||
|
unique_vars.append(v)
|
||||||
|
|
||||||
|
result = None
|
||||||
|
used_api = None
|
||||||
|
|
||||||
|
api_order = []
|
||||||
|
if KAKAO_API_KEY:
|
||||||
|
api_order.append(('카카오', geocode_kakao))
|
||||||
|
if VWORLD_API_KEY:
|
||||||
|
api_order.append(('VWorld', geocode_vworld))
|
||||||
|
api_order.append(('Nominatim', geocode_nominatim))
|
||||||
|
|
||||||
|
for api_name, geo_func in api_order:
|
||||||
|
if result:
|
||||||
|
break
|
||||||
|
for v in unique_vars:
|
||||||
|
try:
|
||||||
|
result = geo_func(v)
|
||||||
|
if result:
|
||||||
|
used_api = api_name
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f'{api_name} 시도 실패 ({v}): {e}')
|
||||||
|
continue
|
||||||
|
|
||||||
|
if result:
|
||||||
|
GEO_STATS['success'] += 1
|
||||||
|
logger.info(f' [지오코딩 성공 ({used_api})] {address} -> {result}')
|
||||||
|
GEO_CACHE[address] = result
|
||||||
|
return result
|
||||||
|
|
||||||
|
GEO_STATS['failed'] += 1
|
||||||
|
if len(GEO_STATS['failed_addrs']) < 20:
|
||||||
|
GEO_STATS['failed_addrs'].append(address)
|
||||||
|
logger.warning(f' [지오코딩 실패 - 폴백 사용] {address}')
|
||||||
|
|
||||||
jitter = random.uniform(-0.005, 0.005)
|
jitter = random.uniform(-0.005, 0.005)
|
||||||
result = (YONGSAN_CENTER[0] + jitter, YONGSAN_CENTER[1] + jitter)
|
result = (YONGSAN_CENTER[0] + jitter, YONGSAN_CENTER[1] + jitter)
|
||||||
GEO_CACHE[address] = result
|
GEO_CACHE[address] = result
|
||||||
@@ -62,6 +269,28 @@ def list_tabs():
|
|||||||
return jsonify({'tabs': tabs, 'activeTab': store['active_tab']})
|
return jsonify({'tabs': tabs, 'activeTab': store['active_tab']})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/upload-progress')
|
||||||
|
def get_upload_progress():
|
||||||
|
return jsonify({
|
||||||
|
'active': upload_progress['active'],
|
||||||
|
'stage': upload_progress['stage'],
|
||||||
|
'total': upload_progress['total'],
|
||||||
|
'current': upload_progress['current'],
|
||||||
|
'message': upload_progress['message'],
|
||||||
|
'logs': upload_progress['logs'][-15:],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def update_progress(stage, message='', total=0, current=0):
|
||||||
|
upload_progress['stage'] = stage
|
||||||
|
upload_progress['message'] = message
|
||||||
|
upload_progress['total'] = total
|
||||||
|
upload_progress['current'] = current
|
||||||
|
if message:
|
||||||
|
upload_progress['logs'].append(message)
|
||||||
|
logger.info(f'[진행: {stage}] {message}')
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/tabs/add', methods=['POST'])
|
@app.route('/api/tabs/add', methods=['POST'])
|
||||||
def add_tab():
|
def add_tab():
|
||||||
body = request.get_json() or {}
|
body = request.get_json() or {}
|
||||||
@@ -129,6 +358,29 @@ def get_data():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def find_header_idx(headers, keywords):
|
||||||
|
for kw in keywords:
|
||||||
|
for i, h in enumerate(headers):
|
||||||
|
if kw in h:
|
||||||
|
return i
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_likely_worker_sheet(sheet_name, headers):
|
||||||
|
sheet_lower = sheet_name.lower()
|
||||||
|
if any(kw in sheet_lower for kw in ['근무자', '참관인', '인력', '선거인', 'worker']):
|
||||||
|
return True
|
||||||
|
if any(kw in sheet_lower for kw in ['근무처', '투표소', '장소', '기관', 'workplace', 'poll']):
|
||||||
|
return False
|
||||||
|
|
||||||
|
hope_idx = find_header_idx(headers, ['희망사항', '희망', '배치희망'])
|
||||||
|
dob_idx = find_header_idx(headers, ['생년월일', '생일', '주민번호'])
|
||||||
|
phone_idx = find_header_idx(headers, ['연락처', '전화', '휴대폰', '핸드폰'])
|
||||||
|
|
||||||
|
worker_indicators = sum(1 for x in [hope_idx, dob_idx, phone_idx] if x is not None)
|
||||||
|
return worker_indicators >= 2
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/upload', methods=['POST'])
|
@app.route('/api/upload', methods=['POST'])
|
||||||
def upload():
|
def upload():
|
||||||
tid = request.form.get('tab') or store['active_tab']
|
tid = request.form.get('tab') or store['active_tab']
|
||||||
@@ -140,70 +392,175 @@ def upload():
|
|||||||
if not file:
|
if not file:
|
||||||
return jsonify({'error': '파일이 없습니다.'}), 400
|
return jsonify({'error': '파일이 없습니다.'}), 400
|
||||||
|
|
||||||
|
upload_progress.update({
|
||||||
|
'active': True,
|
||||||
|
'stage': '시작',
|
||||||
|
'total': 0,
|
||||||
|
'current': 0,
|
||||||
|
'message': '',
|
||||||
|
'logs': [],
|
||||||
|
})
|
||||||
|
|
||||||
|
update_progress('파일 로딩', f'파일을 읽는 중: {file.filename}')
|
||||||
|
|
||||||
path = os.path.join(app.config['UPLOAD_FOLDER'], file.filename)
|
path = os.path.join(app.config['UPLOAD_FOLDER'], file.filename)
|
||||||
file.save(path)
|
file.save(path)
|
||||||
wb = openpyxl.load_workbook(path, data_only=True)
|
wb = openpyxl.load_workbook(path, data_only=True)
|
||||||
|
|
||||||
|
logger.info(f'=== 엑셀 업로드 시작: {file.filename} ===')
|
||||||
|
logger.info(f'시트 목록: {wb.sheetnames}')
|
||||||
|
|
||||||
workers, workplaces, all_addrs = [], [], set()
|
workers, workplaces, all_addrs = [], [], set()
|
||||||
for sheet_name in wb.sheetnames:
|
|
||||||
ws = wb[sheet_name]
|
|
||||||
headers = [str(c.value or '').strip() for c in ws[1]]
|
|
||||||
if '희망사항' in headers:
|
|
||||||
idx = {h: i for i, h in enumerate(headers)}
|
|
||||||
for row in ws.iter_rows(min_row=2, values_only=True):
|
|
||||||
if not any(row): continue
|
|
||||||
name = str(row[idx.get('이름', 0)] or '').strip()
|
|
||||||
if name: all_addrs.add(str(row[idx.get('주소', 2)] or '').strip())
|
|
||||||
if '희망사항' not in headers:
|
|
||||||
idx = {h: i for i, h in enumerate(headers)}
|
|
||||||
for row in ws.iter_rows(min_row=2, values_only=True):
|
|
||||||
if not any(row): continue
|
|
||||||
name = str(row[idx.get('이름', 0)] or '').strip()
|
|
||||||
if name: all_addrs.add(str(row[idx.get('주소', 1)] or '').strip())
|
|
||||||
|
|
||||||
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as exc:
|
|
||||||
geo_results = {a: exc.submit(geocode_with_fallback, a) for a in all_addrs}
|
|
||||||
geo_results = {a: f.result() for a, f in geo_results.items()}
|
|
||||||
|
|
||||||
for sheet_name in wb.sheetnames:
|
for sheet_name in wb.sheetnames:
|
||||||
ws = wb[sheet_name]
|
ws = wb[sheet_name]
|
||||||
headers = [str(c.value or '').strip() for c in ws[1]]
|
headers = [str(c.value or '').strip() for c in ws[1]]
|
||||||
|
logger.info(f' 시트: {sheet_name}, 헤더: {headers}')
|
||||||
|
update_progress('엑셀 파싱', f'시트 처리 중: {sheet_name}')
|
||||||
|
|
||||||
if '희망사항' in headers:
|
hope_idx = find_header_idx(headers, ['희망사항', '희망', '배치희망'])
|
||||||
idx = {h: i for i, h in enumerate(headers)}
|
is_worker_sheet = hope_idx is not None
|
||||||
|
|
||||||
|
name_idx = find_header_idx(headers, ['이름', '성명', '명칭', '근무지명', '투표소명'])
|
||||||
|
addr_idx = find_header_idx(headers, ['주소', '위치', '소재지'])
|
||||||
|
|
||||||
|
logger.info(f' → 희망사항: {hope_idx}, 이름: {name_idx}, 주소: {addr_idx}, 근무자시트: {is_worker_sheet}')
|
||||||
|
|
||||||
|
if is_worker_sheet:
|
||||||
|
hope_idx = hope_idx if hope_idx is not None else 1
|
||||||
|
name_idx = name_idx if name_idx is not None else 0
|
||||||
|
dob_idx = find_header_idx(headers, ['생년월일', '생일']) or 3
|
||||||
|
phone_idx = find_header_idx(headers, ['연락처', '전화', '휴대폰']) or 4
|
||||||
|
if addr_idx is None:
|
||||||
|
addr_idx = 2
|
||||||
|
logger.warning(f' [주의] 주소 컬럼을 찾을 수 없어 기본 인덱스 {addr_idx} 사용')
|
||||||
|
|
||||||
|
row_count = 0
|
||||||
for row in ws.iter_rows(min_row=2, values_only=True):
|
for row in ws.iter_rows(min_row=2, values_only=True):
|
||||||
if not any(row): continue
|
if not any(row): continue
|
||||||
name = str(row[idx.get('이름', 0)] or '').strip()
|
name = str(row[name_idx] or '').strip() if name_idx is not None else ''
|
||||||
if not name: continue
|
if not name: continue
|
||||||
addr = str(row[idx.get('주소', 2)] or '').strip()
|
addr = str(row[addr_idx] or '').strip()
|
||||||
lat, lng = geo_results[addr]
|
if addr:
|
||||||
|
all_addrs.add(addr)
|
||||||
|
row_count += 1
|
||||||
|
logger.info(f' → 근무자 행 수: {row_count}')
|
||||||
|
else:
|
||||||
|
name_idx = name_idx if name_idx is not None else 0
|
||||||
|
if addr_idx is None:
|
||||||
|
addr_idx = 1
|
||||||
|
logger.warning(f' [주의] 주소 컬럼을 찾을 수 없어 기본 인덱스 {addr_idx} 사용')
|
||||||
|
|
||||||
|
row_count = 0
|
||||||
|
for row in ws.iter_rows(min_row=2, values_only=True):
|
||||||
|
if not any(row): continue
|
||||||
|
name = str(row[name_idx] or '').strip() if name_idx is not None else ''
|
||||||
|
if not name: continue
|
||||||
|
addr = str(row[addr_idx] or '').strip()
|
||||||
|
if addr:
|
||||||
|
all_addrs.add(addr)
|
||||||
|
row_count += 1
|
||||||
|
logger.info(f' → 근무처 행 수: {row_count}')
|
||||||
|
|
||||||
|
total_addrs = len(all_addrs)
|
||||||
|
logger.info(f'수집된 고유 주소 수: {total_addrs}')
|
||||||
|
GEO_STATS['success'] = 0
|
||||||
|
GEO_STATS['failed'] = 0
|
||||||
|
GEO_STATS['failed_addrs'] = []
|
||||||
|
|
||||||
|
if VWORLD_API_KEY:
|
||||||
|
api_name = 'VWorld API'
|
||||||
|
delay = 0.1
|
||||||
|
elif KAKAO_API_KEY:
|
||||||
|
api_name = '카카오 API'
|
||||||
|
delay = 0.1
|
||||||
|
else:
|
||||||
|
api_name = 'Nominatim (API 키 권장)'
|
||||||
|
logger.info(' (참고: VWorld API 키 발급받으시면 정확도와 속도가 크게 향상됩니다)')
|
||||||
|
delay = 1.0
|
||||||
|
|
||||||
|
logger.info(f'=== 지오코딩 시작 ({api_name}) ===')
|
||||||
|
update_progress('지오코딩', f'주소를 좌표로 변환 중 ({api_name})', total=total_addrs, current=0)
|
||||||
|
|
||||||
|
geo_results = {}
|
||||||
|
for idx, addr in enumerate(all_addrs, 1):
|
||||||
|
msg = f'[{idx}/{total_addrs}] {addr}'
|
||||||
|
logger.info(f' {msg}')
|
||||||
|
geo_results[addr] = geocode_with_fallback(addr)
|
||||||
|
|
||||||
|
if idx % 5 == 0 or idx == total_addrs:
|
||||||
|
update_progress('지오코딩', f'좌표 변환 중... {idx}/{total_addrs}', total=total_addrs, current=idx)
|
||||||
|
|
||||||
|
if idx < total_addrs:
|
||||||
|
time.sleep(delay)
|
||||||
|
|
||||||
|
logger.info(f'=== 지오코딩 완료: 성공 {GEO_STATS["success"]}, 실패 {GEO_STATS["failed"]} ===')
|
||||||
|
if GEO_STATS['failed_addrs']:
|
||||||
|
logger.warning(f'실패한 주소 목록 (최대 20개): {GEO_STATS["failed_addrs"]}')
|
||||||
|
|
||||||
|
update_progress('완료', f'처리 완료: 근무자 {len(workers or [])}명, 근무처 {len(workplaces or [])}개')
|
||||||
|
upload_progress['active'] = False
|
||||||
|
|
||||||
|
for sheet_name in wb.sheetnames:
|
||||||
|
ws = wb[sheet_name]
|
||||||
|
headers = [str(c.value or '').strip() for c in ws[1]]
|
||||||
|
|
||||||
|
hope_idx = find_header_idx(headers, ['희망사항', '희망', '배치희망'])
|
||||||
|
is_worker_sheet = hope_idx is not None
|
||||||
|
|
||||||
|
name_idx = find_header_idx(headers, ['이름', '성명', '명칭', '근무지명', '투표소명'])
|
||||||
|
addr_idx = find_header_idx(headers, ['주소', '위치', '소재지'])
|
||||||
|
|
||||||
|
if is_worker_sheet:
|
||||||
|
hope_idx = hope_idx if hope_idx is not None else 1
|
||||||
|
name_idx = name_idx if name_idx is not None else 0
|
||||||
|
dob_idx = find_header_idx(headers, ['생년월일', '생일']) or 3
|
||||||
|
phone_idx = find_header_idx(headers, ['연락처', '전화', '휴대폰']) or 4
|
||||||
|
if addr_idx is None:
|
||||||
|
addr_idx = 2
|
||||||
|
|
||||||
|
for row in ws.iter_rows(min_row=2, values_only=True):
|
||||||
|
if not any(row): continue
|
||||||
|
name = str(row[name_idx] or '').strip() if name_idx is not None else ''
|
||||||
|
if not name: continue
|
||||||
|
addr = str(row[addr_idx] or '').strip()
|
||||||
|
lat, lng = geo_results.get(addr, (YONGSAN_CENTER[0] + random.uniform(-0.005, 0.005),
|
||||||
|
YONGSAN_CENTER[1] + random.uniform(-0.005, 0.005)))
|
||||||
workers.append({
|
workers.append({
|
||||||
'id': f'w{len(workers)}', 'name': name,
|
'id': f'w{len(workers)}', 'name': name,
|
||||||
'hope': str(row[idx.get('희망사항', 1)] or '').strip(),
|
'hope': str(row[hope_idx] or '').strip(),
|
||||||
'address': addr,
|
'address': addr,
|
||||||
'dob': str(row[idx.get('생년월일', 3)] or '').strip(),
|
'dob': str(row[dob_idx] or '').strip(),
|
||||||
'phone': str(row[idx.get('연락처', 4)] or '').strip(),
|
'phone': str(row[phone_idx] or '').strip(),
|
||||||
'lat': lat, 'lng': lng,
|
'lat': lat, 'lng': lng,
|
||||||
})
|
})
|
||||||
|
else:
|
||||||
|
name_idx = name_idx if name_idx is not None else 0
|
||||||
|
if addr_idx is None:
|
||||||
|
addr_idx = 1
|
||||||
|
|
||||||
if '희망사항' not in headers:
|
|
||||||
idx = {h: i for i, h in enumerate(headers)}
|
|
||||||
for row in ws.iter_rows(min_row=2, values_only=True):
|
for row in ws.iter_rows(min_row=2, values_only=True):
|
||||||
if not any(row): continue
|
if not any(row): continue
|
||||||
name = str(row[idx.get('이름', 0)] or '').strip()
|
name = str(row[name_idx] or '').strip() if name_idx is not None else ''
|
||||||
if not name: continue
|
if not name: continue
|
||||||
addr = str(row[idx.get('주소', 1)] or '').strip()
|
addr = str(row[addr_idx] or '').strip()
|
||||||
lat, lng = geo_results[addr]
|
lat, lng = geo_results.get(addr, (YONGSAN_CENTER[0] + random.uniform(-0.005, 0.005),
|
||||||
|
YONGSAN_CENTER[1] + random.uniform(-0.005, 0.005)))
|
||||||
workplaces.append({
|
workplaces.append({
|
||||||
'id': f'p{len(workplaces)}', 'name': name,
|
'id': f'p{len(workplaces)}', 'name': name,
|
||||||
'address': addr, 'lat': lat, 'lng': lng,
|
'address': addr, 'lat': lat, 'lng': lng,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
logger.info(f'=== 업로드 완료: 근무자 {len(workers)}명, 근무처 {len(workplaces)}개 ===')
|
||||||
|
|
||||||
t['workers'] = workers
|
t['workers'] = workers
|
||||||
t['workplaces'] = workplaces
|
t['workplaces'] = workplaces
|
||||||
t['assignments'] = {}
|
t['assignments'] = {}
|
||||||
store['filename'] = file.filename
|
store['filename'] = file.filename
|
||||||
|
|
||||||
|
update_progress('완료', f'처리 완료: 근무자 {len(workers)}명, 근무처 {len(workplaces)}개')
|
||||||
|
upload_progress['active'] = False
|
||||||
|
|
||||||
return jsonify({'workers': workers, 'workplaces': workplaces, 'assignments': {}})
|
return jsonify({'workers': workers, 'workplaces': workplaces, 'assignments': {}})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -133,6 +133,47 @@ body { font-family: 'Segoe UI', 'Malgun Gothic', sans-serif; height: 100vh; over
|
|||||||
background: #1a73e8; color: #fff; font-size: 18px; border-radius: 8px; width: 42px; height: 42px;
|
background: #1a73e8; color: #fff; font-size: 18px; border-radius: 8px; width: 42px; height: 42px;
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
}
|
}
|
||||||
|
.marker-badge {
|
||||||
|
position: absolute; top: -8px; right: -8px; min-width: 20px; height: 20px;
|
||||||
|
background: #ef4444; color: #fff; border-radius: 10px; font-size: 11px; font-weight: 700;
|
||||||
|
display: flex; align-items: center; justify-content: center; padding: 0 4px;
|
||||||
|
border: 2px solid #fff; box-shadow: 0 1px 4px rgba(0,0,0,0.25);
|
||||||
|
}
|
||||||
|
.sidebar-tabs {
|
||||||
|
display: flex; background: #f0f2f5; border-bottom: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
.sidebar-tab {
|
||||||
|
flex: 1; padding: 10px 8px; text-align: center; cursor: pointer; font-size: 13px;
|
||||||
|
font-weight: 500; color: #6b7280; transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.sidebar-tab:hover { color: #1a73e8; }
|
||||||
|
.sidebar-tab.active {
|
||||||
|
color: #1a73e8; background: #fff; border-bottom: 2px solid #1a73e8;
|
||||||
|
}
|
||||||
|
.sidebar-tab .count { margin-left: 4px; font-size: 11px; padding: 1px 6px; border-radius: 8px; background: #e5e7eb; }
|
||||||
|
.sidebar-tab.active .count { background: #e8f0fe; }
|
||||||
|
.workplace-list {
|
||||||
|
flex: 1; overflow-y: auto; padding: 8px; display: none;
|
||||||
|
}
|
||||||
|
.workplace-list.active { display: block; }
|
||||||
|
.worker-list.hidden { display: none; }
|
||||||
|
.workplace-card {
|
||||||
|
display: flex; gap: 10px; padding: 10px 12px; margin-bottom: 6px;
|
||||||
|
background: #f8f9fa; border: 1px solid #e5e7eb; border-radius: 8px;
|
||||||
|
transition: all 0.2s; cursor: pointer;
|
||||||
|
}
|
||||||
|
.workplace-card:hover { border-color: #1a73e8; box-shadow: 0 2px 8px rgba(26,115,232,0.12); }
|
||||||
|
.workplace-card-avatar {
|
||||||
|
width: 40px; height: 40px; border-radius: 8px; background: #dbeafe;
|
||||||
|
display: flex; align-items: center; justify-content: center; color: #1a73e8;
|
||||||
|
flex-shrink: 0; font-size: 16px;
|
||||||
|
}
|
||||||
|
.workplace-card-info { flex: 1; min-width: 0; }
|
||||||
|
.workplace-card-name { font-weight: 600; font-size: 14px; color: #111; }
|
||||||
|
.workplace-card-address { font-size: 11px; color: #9ca3af; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 2px; }
|
||||||
|
.workplace-card-assignment {
|
||||||
|
margin-top: 4px; font-size: 12px; color: #1a73e8; font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
.popup-content { font-size: 13px; line-height: 1.5; min-width: 150px; }
|
.popup-content { font-size: 13px; line-height: 1.5; min-width: 150px; }
|
||||||
.wp-assignees { margin-top: 6px; padding-top: 6px; border-top: 1px solid #e5e7eb; font-size: 12px; color: #6b7280; }
|
.wp-assignees { margin-top: 6px; padding-top: 6px; border-top: 1px solid #e5e7eb; font-size: 12px; color: #6b7280; }
|
||||||
@@ -148,3 +189,57 @@ body { font-family: 'Segoe UI', 'Malgun Gothic', sans-serif; height: 100vh; over
|
|||||||
.toast.error { background: #ef4444; }
|
.toast.error { background: #ef4444; }
|
||||||
.toast.info { background: #1a73e8; }
|
.toast.info { background: #1a73e8; }
|
||||||
.toast.warning { background: #f59e0b; color: #333; }
|
.toast.warning { background: #f59e0b; color: #333; }
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
.modal-overlay.hidden { display: none; }
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: #fff; border-radius: 12px; box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
||||||
|
min-width: 400px; max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
padding: 16px 20px; border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
.modal-header h3 {
|
||||||
|
font-size: 16px; color: #1f2937; margin: 0;
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 24px 20px; text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
font-size: 48px; color: #1a73e8; margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-message {
|
||||||
|
font-size: 14px; color: #374151; margin-bottom: 16px; font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-progress { margin-bottom: 12px; }
|
||||||
|
.loading-progress.hidden { display: none; }
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
width: 100%; height: 8px; background: #e5e7eb; border-radius: 4px; overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%; background: #1a73e8; transition: width 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
font-size: 12px; color: #6b7280; margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-log {
|
||||||
|
max-height: 150px; overflow-y: auto; text-align: left;
|
||||||
|
background: #f9fafb; border-radius: 6px; padding: 8px 12px;
|
||||||
|
font-size: 11px; color: #6b7280; line-height: 1.5;
|
||||||
|
}
|
||||||
|
.loading-log:empty { display: none; }
|
||||||
|
|||||||
@@ -32,11 +32,16 @@
|
|||||||
|
|
||||||
<div id="main">
|
<div id="main">
|
||||||
<div id="sidebar">
|
<div id="sidebar">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-tabs">
|
||||||
<h2><i class="fas fa-users"></i> 근무자</h2>
|
<div class="sidebar-tab active" data-tab="workers" onclick="switchSidebarTab('workers')">
|
||||||
<span id="workerCount" class="count-badge">0</span>
|
<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>
|
||||||
<div id="workerList" class="worker-list"></div>
|
<div id="workerList" class="worker-list active"></div>
|
||||||
|
<div id="workplaceList" class="workplace-list"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="mapContainer">
|
<div id="mapContainer">
|
||||||
<div id="map"></div>
|
<div id="map"></div>
|
||||||
@@ -46,6 +51,27 @@
|
|||||||
|
|
||||||
<div id="toast" class="toast hidden"></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>
|
||||||
|
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const YONGSAN = [37.5326, 126.9906];
|
const YONGSAN = [37.5326, 126.9906];
|
||||||
@@ -60,11 +86,14 @@ const WORKER_ASSIGNED_ICON = L.divIcon({
|
|||||||
html: '<i class="fas fa-user-check"></i>',
|
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({
|
function makeWorkplaceIcon(count) {
|
||||||
className: 'marker-icon workplace-marker',
|
const badge = count > 0 ? `<div class="marker-badge">${count}</div>` : '';
|
||||||
html: '<i class="fas fa-building"></i>',
|
return L.divIcon({
|
||||||
iconSize: [42, 42], iconAnchor: [21, 42], popupAnchor: [0, -40],
|
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 markers = { workers: {}, workplaces: {} };
|
||||||
let tabs = [];
|
let tabs = [];
|
||||||
@@ -114,6 +143,7 @@ async function addTab() {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name: name.trim() || undefined }),
|
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: {} };
|
tabDataCache[r.id] = { workers: [], workplaces: [], assignments: {} };
|
||||||
activeTabId = r.activeTab;
|
activeTabId = r.activeTab;
|
||||||
render();
|
render();
|
||||||
@@ -233,20 +263,95 @@ function findNearestWorkplace(lat, lng) {
|
|||||||
return best;
|
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) {
|
async function uploadExcel(e) {
|
||||||
const file = e.target.files[0];
|
const file = e.target.files[0];
|
||||||
if (!file || !activeTabId) return;
|
if (!file || !activeTabId) return;
|
||||||
|
|
||||||
|
showLoadingModal();
|
||||||
|
updateLoadingModal('파일 업로드 준비 중...');
|
||||||
|
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append('file', file);
|
form.append('file', file);
|
||||||
form.append('tab', activeTabId);
|
form.append('tab', activeTabId);
|
||||||
showToast('업로드 중...', 'info');
|
|
||||||
|
let pollInterval = null;
|
||||||
|
|
||||||
try {
|
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 });
|
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;
|
tabDataCache[activeTabId] = r;
|
||||||
render();
|
render();
|
||||||
await loadTabs();
|
await loadTabs();
|
||||||
showToast(`파일 로드 완료: ${file.name}`, 'success');
|
updateLoadingModal('완료!');
|
||||||
|
setTimeout(hideLoadingModal, 500);
|
||||||
|
showToast(`파일 로드 완료: 근무자 ${r.workers?.length || 0}명, 근무처 ${r.workplaces?.length || 0}개`, 'success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (pollInterval) {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
}
|
||||||
|
console.error('[업로드 실패]', err);
|
||||||
|
hideLoadingModal();
|
||||||
showToast('업로드 실패: ' + err.message, 'error');
|
showToast('업로드 실패: ' + err.message, 'error');
|
||||||
}
|
}
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
@@ -260,6 +365,7 @@ function render() {
|
|||||||
renderWorkplaceMarkers();
|
renderWorkplaceMarkers();
|
||||||
renderWorkerMarkers();
|
renderWorkerMarkers();
|
||||||
renderWorkerList();
|
renderWorkerList();
|
||||||
|
renderWorkplaceList();
|
||||||
updateBadges();
|
updateBadges();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,6 +375,11 @@ function clearMap() {
|
|||||||
markers = { workers: {}, workplaces: {} };
|
markers = { workers: {}, workplaces: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getWorkplaceAssigneeCount(wpId) {
|
||||||
|
const d = currentData();
|
||||||
|
return d.workers.filter(w => d.assignments[w.id] === wpId).length;
|
||||||
|
}
|
||||||
|
|
||||||
function getWorkplaceAssigneesHtml(wpId) {
|
function getWorkplaceAssigneesHtml(wpId) {
|
||||||
const d = currentData();
|
const d = currentData();
|
||||||
const assigned = d.workers.filter(w => d.assignments[w.id] === wpId);
|
const assigned = d.workers.filter(w => d.assignments[w.id] === wpId);
|
||||||
@@ -281,7 +392,8 @@ function renderWorkplaceMarkers() {
|
|||||||
const d = currentData();
|
const d = currentData();
|
||||||
const bounds = [];
|
const bounds = [];
|
||||||
for (const wp of d.workplaces) {
|
for (const wp of d.workplaces) {
|
||||||
const marker = L.marker([wp.lat, wp.lng], { icon: WORKPLACE_ICON })
|
const cnt = getWorkplaceAssigneeCount(wp.id);
|
||||||
|
const marker = L.marker([wp.lat, wp.lng], { icon: makeWorkplaceIcon(cnt) })
|
||||||
.addTo(map).bindPopup(`
|
.addTo(map).bindPopup(`
|
||||||
<div class="popup-content">
|
<div class="popup-content">
|
||||||
<strong><i class="fas fa-building"></i> ${escHtml(wp.name)}</strong><br>
|
<strong><i class="fas fa-building"></i> ${escHtml(wp.name)}</strong><br>
|
||||||
@@ -319,8 +431,10 @@ function handleWorkerDragEnd(workerId, marker) {
|
|||||||
const d = currentData();
|
const d = currentData();
|
||||||
const pos = marker.getLatLng();
|
const pos = marker.getLatLng();
|
||||||
const nearest = findNearestWorkplace(pos.lat, pos.lng);
|
const nearest = findNearestWorkplace(pos.lat, pos.lng);
|
||||||
|
const oldWpId = d.assignments[workerId];
|
||||||
|
|
||||||
if (nearest) {
|
if (nearest) {
|
||||||
|
if (oldWpId === nearest.id) return;
|
||||||
api('/api/assign', {
|
api('/api/assign', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -330,14 +444,15 @@ function handleWorkerDragEnd(workerId, marker) {
|
|||||||
marker.setLatLng([nearest.lat, nearest.lng]);
|
marker.setLatLng([nearest.lat, nearest.lng]);
|
||||||
marker.setIcon(WORKER_ASSIGNED_ICON);
|
marker.setIcon(WORKER_ASSIGNED_ICON);
|
||||||
marker.setPopupContent(buildWorkerPopup(workerId));
|
marker.setPopupContent(buildWorkerPopup(workerId));
|
||||||
|
if (oldWpId) updateWorkplacePopup(oldWpId);
|
||||||
updateWorkplacePopup(nearest.id);
|
updateWorkplacePopup(nearest.id);
|
||||||
renderWorkerList();
|
renderWorkerList();
|
||||||
|
renderWorkplaceList();
|
||||||
updateBadges();
|
updateBadges();
|
||||||
showToast(`${getWorkerName(workerId)} → ${nearest.name} 배치됨`, 'success');
|
showToast(`${getWorkerName(workerId)} → ${nearest.name} 배치됨`, 'success');
|
||||||
}).catch(() => showToast('배치 저장 실패', 'error'));
|
}).catch(() => showToast('배치 저장 실패', 'error'));
|
||||||
|
|
||||||
} else if (d.assignments[workerId]) {
|
} else if (oldWpId) {
|
||||||
const oldWpId = d.assignments[workerId];
|
|
||||||
api('/api/unassign', {
|
api('/api/unassign', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -350,6 +465,7 @@ function handleWorkerDragEnd(workerId, marker) {
|
|||||||
marker.setPopupContent(buildWorkerPopup(workerId));
|
marker.setPopupContent(buildWorkerPopup(workerId));
|
||||||
updateWorkplacePopup(oldWpId);
|
updateWorkplacePopup(oldWpId);
|
||||||
renderWorkerList();
|
renderWorkerList();
|
||||||
|
renderWorkplaceList();
|
||||||
updateBadges();
|
updateBadges();
|
||||||
showToast('배치가 취소되었습니다.', 'info');
|
showToast('배치가 취소되었습니다.', 'info');
|
||||||
}).catch(() => showToast('배치 취소 실패', 'error'));
|
}).catch(() => showToast('배치 취소 실패', 'error'));
|
||||||
@@ -415,11 +531,13 @@ function renderWorkerList() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateWorkplacePopup(wpId) {
|
function updateWorkplaceMarker(wpId) {
|
||||||
const d = currentData();
|
const d = currentData();
|
||||||
const marker = markers.workplaces[wpId];
|
const marker = markers.workplaces[wpId];
|
||||||
const wp = d.workplaces.find(p => p.id === wpId);
|
const wp = d.workplaces.find(p => p.id === wpId);
|
||||||
if (!marker || !wp) return;
|
if (!marker || !wp) return;
|
||||||
|
const cnt = getWorkplaceAssigneeCount(wpId);
|
||||||
|
marker.setIcon(makeWorkplaceIcon(cnt));
|
||||||
marker.setPopupContent(`
|
marker.setPopupContent(`
|
||||||
<div class="popup-content">
|
<div class="popup-content">
|
||||||
<strong><i class="fas fa-building"></i> ${escHtml(wp.name)}</strong><br>
|
<strong><i class="fas fa-building"></i> ${escHtml(wp.name)}</strong><br>
|
||||||
@@ -428,11 +546,57 @@ function updateWorkplacePopup(wpId) {
|
|||||||
</div>`);
|
</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() {
|
function updateBadges() {
|
||||||
const d = currentData();
|
const d = currentData();
|
||||||
const assigned = Object.keys(d.assignments).length;
|
const assigned = Object.keys(d.assignments).length;
|
||||||
const total = d.workers.length;
|
const total = d.workers.length;
|
||||||
document.getElementById('workerCount').textContent = `${assigned}/${total}`;
|
document.getElementById('workerCount').textContent = `${assigned}/${total}`;
|
||||||
|
document.getElementById('workplaceCount').textContent = d.workplaces.length;
|
||||||
const badge = document.getElementById('statusBadge');
|
const badge = document.getElementById('statusBadge');
|
||||||
badge.textContent = assigned > 0 ? `배치 ${assigned}/${total}` : '';
|
badge.textContent = assigned > 0 ? `배치 ${assigned}/${total}` : '';
|
||||||
badge.className = `status-badge ${!total ? '' : assigned === total ? 'complete' : assigned > 0 ? 'partial' : ''}`;
|
badge.className = `status-badge ${!total ? '' : assigned === total ? 'complete' : assigned > 0 ? 'partial' : ''}`;
|
||||||
@@ -443,7 +607,8 @@ function updateBadges() {
|
|||||||
async function doAssignWorker(workerId, workplaceId) {
|
async function doAssignWorker(workerId, workplaceId) {
|
||||||
if (!activeTabId) return;
|
if (!activeTabId) return;
|
||||||
const d = currentData();
|
const d = currentData();
|
||||||
if (d.assignments[workerId] === workplaceId) return;
|
const oldWpId = d.assignments[workerId];
|
||||||
|
if (oldWpId === workplaceId) return;
|
||||||
try {
|
try {
|
||||||
const r = await api('/api/assign', {
|
const r = await api('/api/assign', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -458,8 +623,10 @@ async function doAssignWorker(workerId, workplaceId) {
|
|||||||
marker.setIcon(WORKER_ASSIGNED_ICON);
|
marker.setIcon(WORKER_ASSIGNED_ICON);
|
||||||
marker.setPopupContent(buildWorkerPopup(workerId));
|
marker.setPopupContent(buildWorkerPopup(workerId));
|
||||||
}
|
}
|
||||||
|
if (oldWpId) updateWorkplacePopup(oldWpId);
|
||||||
updateWorkplacePopup(workplaceId);
|
updateWorkplacePopup(workplaceId);
|
||||||
renderWorkerList();
|
renderWorkerList();
|
||||||
|
renderWorkplaceList();
|
||||||
updateBadges();
|
updateBadges();
|
||||||
showToast('배치가 저장되었습니다.', 'success');
|
showToast('배치가 저장되었습니다.', 'success');
|
||||||
} catch { showToast('배치 저장 실패', 'error'); }
|
} catch { showToast('배치 저장 실패', 'error'); }
|
||||||
@@ -485,6 +652,7 @@ async function unassignWorker(workerId, fromPopup) {
|
|||||||
}
|
}
|
||||||
if (oldWpId) updateWorkplacePopup(oldWpId);
|
if (oldWpId) updateWorkplacePopup(oldWpId);
|
||||||
renderWorkerList();
|
renderWorkerList();
|
||||||
|
renderWorkplaceList();
|
||||||
updateBadges();
|
updateBadges();
|
||||||
if (fromPopup) map.closePopup();
|
if (fromPopup) map.closePopup();
|
||||||
showToast('배치가 취소되었습니다.', 'info');
|
showToast('배치가 취소되었습니다.', 'info');
|
||||||
@@ -512,6 +680,7 @@ async function resetAssignments() {
|
|||||||
}
|
}
|
||||||
for (const wp of d.workplaces) updateWorkplacePopup(wp.id);
|
for (const wp of d.workplaces) updateWorkplacePopup(wp.id);
|
||||||
renderWorkerList();
|
renderWorkerList();
|
||||||
|
renderWorkplaceList();
|
||||||
updateBadges();
|
updateBadges();
|
||||||
showToast('모든 배치가 초기화되었습니다.', 'info');
|
showToast('모든 배치가 초기화되었습니다.', 'info');
|
||||||
} catch { showToast('초기화 실패', 'error'); }
|
} catch { showToast('초기화 실패', 'error'); }
|
||||||
|
|||||||
Reference in New Issue
Block a user