Feat: 근무처 주소 중복 시 표시/내보내기 통합, 근무자 클릭 시 지도 이동
This commit is contained in:
267
app.py
267
app.py
@@ -450,8 +450,12 @@ def download_from_google_drive(file_id, timeout=30):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def process_excel_file(wb, filename):
|
def process_excel_file(wb, filename, force_type=None):
|
||||||
"""엑셀 파일을 처리하고 근무자/근무처 데이터를 반환합니다."""
|
"""엑셀 파일을 처리하고 근무자/근무처 데이터를 반환합니다.
|
||||||
|
force_type: 'worker' - 모든 시트를 근무자로 처리
|
||||||
|
'workplace' - 모든 시트를 근무처로 처리
|
||||||
|
None - 자동 감지
|
||||||
|
"""
|
||||||
workers, workplaces, all_addrs = [], [], set()
|
workers, workplaces, all_addrs = [], [], set()
|
||||||
|
|
||||||
for sheet_name in wb.sheetnames:
|
for sheet_name in wb.sheetnames:
|
||||||
@@ -461,6 +465,11 @@ def process_excel_file(wb, filename):
|
|||||||
update_progress('엑셀 파싱', f'시트 처리 중: {sheet_name}')
|
update_progress('엑셀 파싱', f'시트 처리 중: {sheet_name}')
|
||||||
|
|
||||||
hope_idx = find_header_idx(headers, ['희망사항', '희망', '배치희망'])
|
hope_idx = find_header_idx(headers, ['희망사항', '희망', '배치희망'])
|
||||||
|
if force_type == 'worker':
|
||||||
|
is_worker_sheet = True
|
||||||
|
elif force_type == 'workplace':
|
||||||
|
is_worker_sheet = False
|
||||||
|
else:
|
||||||
is_worker_sheet = is_likely_worker_sheet(sheet_name, headers)
|
is_worker_sheet = is_likely_worker_sheet(sheet_name, headers)
|
||||||
|
|
||||||
name_idx = find_header_idx(headers, ['이름', '성명', '명칭', '근무지명', '투표소명'])
|
name_idx = find_header_idx(headers, ['이름', '성명', '명칭', '근무지명', '투표소명'])
|
||||||
@@ -547,6 +556,11 @@ def process_excel_file(wb, filename):
|
|||||||
headers = [str(c.value or '').strip() for c in ws[1]]
|
headers = [str(c.value or '').strip() for c in ws[1]]
|
||||||
|
|
||||||
hope_idx = find_header_idx(headers, ['희망사항', '희망', '배치희망'])
|
hope_idx = find_header_idx(headers, ['희망사항', '희망', '배치희망'])
|
||||||
|
if force_type == 'worker':
|
||||||
|
is_worker_sheet = True
|
||||||
|
elif force_type == 'workplace':
|
||||||
|
is_worker_sheet = False
|
||||||
|
else:
|
||||||
is_worker_sheet = is_likely_worker_sheet(sheet_name, headers)
|
is_worker_sheet = is_likely_worker_sheet(sheet_name, headers)
|
||||||
|
|
||||||
name_idx = find_header_idx(headers, ['이름', '성명', '명칭', '근무지명', '투표소명'])
|
name_idx = find_header_idx(headers, ['이름', '성명', '명칭', '근무지명', '투표소명'])
|
||||||
@@ -596,187 +610,81 @@ def process_excel_file(wb, filename):
|
|||||||
return workers, workplaces
|
return workers, workplaces
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/upload', methods=['POST'])
|
def _handle_upload(tid, file, force_type):
|
||||||
def upload():
|
"""공통 업로드 처리 로직"""
|
||||||
tid = request.form.get('tab') or store['active_tab']
|
|
||||||
t = store['tabs'].get(tid)
|
t = store['tabs'].get(tid)
|
||||||
if not t:
|
if not t:
|
||||||
return jsonify({'error': '탭 없음'}), 400
|
return None, jsonify({'error': '탭 없음'}), 400
|
||||||
|
|
||||||
file = request.files.get('file')
|
|
||||||
if not file:
|
|
||||||
return jsonify({'error': '파일이 없습니다.'}), 400
|
|
||||||
|
|
||||||
upload_progress.update({
|
upload_progress.update({
|
||||||
'active': True,
|
'active': True, 'stage': '시작', 'total': 0, 'current': 0, 'message': '', 'logs': [],
|
||||||
'stage': '시작',
|
|
||||||
'total': 0,
|
|
||||||
'current': 0,
|
|
||||||
'message': '',
|
|
||||||
'logs': [],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
update_progress('파일 로딩', f'파일을 읽는 중: {file.filename}')
|
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'=== {"근무자" if force_type == "worker" else "근무처"} 엑셀 업로드 시작: {file.filename} ===')
|
||||||
logger.info(f'시트 목록: {wb.sheetnames}')
|
logger.info(f'시트 목록: {wb.sheetnames}')
|
||||||
|
|
||||||
workers, workplaces, all_addrs = [], [], set()
|
workers, workplaces = process_excel_file(wb, file.filename, force_type=force_type)
|
||||||
|
|
||||||
for sheet_name in wb.sheetnames:
|
|
||||||
ws = wb[sheet_name]
|
|
||||||
headers = [str(c.value or '').strip() for c in ws[1]]
|
|
||||||
logger.info(f' 시트: {sheet_name}, 헤더: {headers}')
|
|
||||||
update_progress('엑셀 파싱', f'시트 처리 중: {sheet_name}')
|
|
||||||
|
|
||||||
hope_idx = find_header_idx(headers, ['희망사항', '희망', '배치희망'])
|
|
||||||
is_worker_sheet = is_likely_worker_sheet(sheet_name, headers)
|
|
||||||
|
|
||||||
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):
|
|
||||||
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}')
|
|
||||||
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
|
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 = is_likely_worker_sheet(sheet_name, headers)
|
|
||||||
|
|
||||||
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({
|
|
||||||
'id': f'w{len(workers)}', 'name': name,
|
|
||||||
'hope': str(row[hope_idx] or '').strip(),
|
|
||||||
'address': addr,
|
|
||||||
'dob': str(row[dob_idx] or '').strip(),
|
|
||||||
'phone': str(row[phone_idx] or '').strip(),
|
|
||||||
'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
|
|
||||||
|
|
||||||
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)))
|
|
||||||
workplaces.append({
|
|
||||||
'id': f'p{len(workplaces)}', 'name': name,
|
|
||||||
'address': addr, 'lat': lat, 'lng': lng,
|
|
||||||
})
|
|
||||||
|
|
||||||
logger.info(f'=== 업로드 완료: 근무자 {len(workers)}명, 근무처 {len(workplaces)}개 ===')
|
|
||||||
|
|
||||||
t['workers'] = workers
|
|
||||||
t['workplaces'] = workplaces
|
|
||||||
t['assignments'] = {}
|
|
||||||
store['filename'] = file.filename
|
store['filename'] = file.filename
|
||||||
|
|
||||||
update_progress('완료', f'처리 완료: 근무자 {len(workers)}명, 근무처 {len(workplaces)}개')
|
return (workers, workplaces), None, None
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/upload-workers', methods=['POST'])
|
||||||
|
def upload_workers():
|
||||||
|
tid = request.form.get('tab') or store['active_tab']
|
||||||
|
file = request.files.get('file')
|
||||||
|
if not file:
|
||||||
|
return jsonify({'error': '파일이 없습니다.'}), 400
|
||||||
|
|
||||||
|
result, err, status = _handle_upload(tid, file, force_type='worker')
|
||||||
|
if err:
|
||||||
|
return err, status
|
||||||
|
|
||||||
|
workers, _ = result
|
||||||
|
t = store['tabs'].get(tid)
|
||||||
|
t['workers'] = workers
|
||||||
|
t['assignments'] = {}
|
||||||
|
|
||||||
|
update_progress('완료', f'근무자 업로드 완료: {len(workers)}명')
|
||||||
upload_progress['active'] = False
|
upload_progress['active'] = False
|
||||||
|
|
||||||
return jsonify({'workers': workers, 'workplaces': workplaces, 'assignments': {}})
|
return jsonify({'workers': workers, 'workplaces': t['workplaces'], 'assignments': {}})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/upload-workplaces', methods=['POST'])
|
||||||
|
def upload_workplaces():
|
||||||
|
tid = request.form.get('tab') or store['active_tab']
|
||||||
|
file = request.files.get('file')
|
||||||
|
if not file:
|
||||||
|
return jsonify({'error': '파일이 없습니다.'}), 400
|
||||||
|
|
||||||
|
result, err, status = _handle_upload(tid, file, force_type='workplace')
|
||||||
|
if err:
|
||||||
|
return err, status
|
||||||
|
|
||||||
|
_, new_workplaces = result
|
||||||
|
t = store['tabs'].get(tid)
|
||||||
|
|
||||||
|
# 기존 근무처에 추가 (append) — ID 중복 방지
|
||||||
|
existing = t['workplaces']
|
||||||
|
offset = len(existing)
|
||||||
|
for wp in new_workplaces:
|
||||||
|
wp['id'] = f'p{offset}'
|
||||||
|
offset += 1
|
||||||
|
t['workplaces'] = existing + new_workplaces
|
||||||
|
|
||||||
|
total = len(t['workplaces'])
|
||||||
|
update_progress('완료', f'근무처 업로드 완료 (총 {total}개, 이번 {len(new_workplaces)}개)')
|
||||||
|
upload_progress['active'] = False
|
||||||
|
|
||||||
|
return jsonify({'workers': t['workers'], 'workplaces': t['workplaces'], 'assignments': {}})
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/google-drive-upload', methods=['POST'])
|
@app.route('/api/google-drive-upload', methods=['POST'])
|
||||||
@@ -784,6 +692,7 @@ def google_drive_upload():
|
|||||||
"""구글 드라이브에서 엑셀 파일을 다운로드하여 처리합니다."""
|
"""구글 드라이브에서 엑셀 파일을 다운로드하여 처리합니다."""
|
||||||
body = request.get_json() or {}
|
body = request.get_json() or {}
|
||||||
file_id = body.get('fileId', '').strip()
|
file_id = body.get('fileId', '').strip()
|
||||||
|
upload_type = body.get('type', 'auto') # 'worker', 'workplace', 'auto'
|
||||||
tid = body.get('tab') or store['active_tab']
|
tid = body.get('tab') or store['active_tab']
|
||||||
|
|
||||||
if not file_id:
|
if not file_id:
|
||||||
@@ -815,24 +724,31 @@ def google_drive_upload():
|
|||||||
update_progress('파일 파싱', '엑셀 파일을 읽는 중입니다...')
|
update_progress('파일 파싱', '엑셀 파일을 읽는 중입니다...')
|
||||||
wb = openpyxl.load_workbook(file_content, data_only=True)
|
wb = openpyxl.load_workbook(file_content, data_only=True)
|
||||||
|
|
||||||
logger.info(f'=== 구글 드라이브 엑셀 업로드 시작: {file_id} ===')
|
force_type = upload_type if upload_type != 'auto' else None
|
||||||
|
type_label = {'worker': '근무자', 'workplace': '근무처', 'auto': '자동감지'}.get(upload_type, '자동감지')
|
||||||
|
logger.info(f'=== 구글 드라이브 엑셀 업로드 시작: {file_id} (유형: {type_label}) ===')
|
||||||
logger.info(f'시트 목록: {wb.sheetnames}')
|
logger.info(f'시트 목록: {wb.sheetnames}')
|
||||||
|
|
||||||
# 엑셀 파일 처리
|
# 엑셀 파일 처리
|
||||||
workers, workplaces = process_excel_file(wb, f'GoogleDrive_{file_id}.xlsx')
|
workers, workplaces = process_excel_file(wb, f'GoogleDrive_{file_id}.xlsx', force_type=force_type)
|
||||||
|
|
||||||
# 탭에 데이터 저장
|
# 탭에 데이터 저장
|
||||||
|
if upload_type == 'worker':
|
||||||
|
t['workers'] = workers
|
||||||
|
elif upload_type == 'workplace':
|
||||||
|
t['workplaces'] = workplaces
|
||||||
|
else:
|
||||||
t['workers'] = workers
|
t['workers'] = workers
|
||||||
t['workplaces'] = workplaces
|
t['workplaces'] = workplaces
|
||||||
t['assignments'] = {}
|
t['assignments'] = {}
|
||||||
store['filename'] = f'GoogleDrive_{file_id}'
|
store['filename'] = f'GoogleDrive_{file_id}'
|
||||||
|
|
||||||
update_progress('완료', f'처리 완료: 근무자 {len(workers)}명, 근무처 {len(workplaces)}개')
|
update_progress('완료', f'처리 완료: 근무자 {len(t["workers"])}명, 근무처 {len(t["workplaces"])}개')
|
||||||
upload_progress['active'] = False
|
upload_progress['active'] = False
|
||||||
|
|
||||||
logger.info(f'=== 구글 드라이브 업로드 완료: 근무자 {len(workers)}명, 근무처 {len(workplaces)}개 ===')
|
logger.info(f'=== 구글 드라이브 업로드 완료: 근무자 {len(t["workers"])}명, 근무처 {len(t["workplaces"])}개 ===')
|
||||||
|
|
||||||
return jsonify({'workers': workers, 'workplaces': workplaces, 'assignments': {}})
|
return jsonify({'workers': t['workers'], 'workplaces': t['workplaces'], 'assignments': {}})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'엑셀 파일 처리 중 오류: {e}')
|
logger.error(f'엑셀 파일 처리 중 오류: {e}')
|
||||||
@@ -899,9 +815,18 @@ def export():
|
|||||||
|
|
||||||
ws2 = wb.create_sheet('근무지별 배치')
|
ws2 = wb.create_sheet('근무지별 배치')
|
||||||
ws2.append(['근무지명', '주소', '배치인원', '배치된근무자'])
|
ws2.append(['근무지명', '주소', '배치인원', '배치된근무자'])
|
||||||
|
# 같은 주소의 근무처는 통합하여 표시
|
||||||
|
addr_groups = {}
|
||||||
for wp in t['workplaces']:
|
for wp in t['workplaces']:
|
||||||
aw = [w['name'] for w in t['workers'] if t['assignments'].get(w['id']) == wp['id']]
|
key = wp['address']
|
||||||
ws2.append([wp['name'], wp['address'], len(aw), ', '.join(aw)])
|
if key not in addr_groups:
|
||||||
|
addr_groups[key] = {'names': [], 'workplace_ids': []}
|
||||||
|
addr_groups[key]['names'].append(wp['name'])
|
||||||
|
addr_groups[key]['workplace_ids'].append(wp['id'])
|
||||||
|
for addr, g in addr_groups.items():
|
||||||
|
combined_name = ', '.join(g['names'])
|
||||||
|
aw = [w['name'] for w in t['workers'] if t['assignments'].get(w['id']) in g['workplace_ids']]
|
||||||
|
ws2.append([combined_name, addr, len(aw), ', '.join(aw)])
|
||||||
|
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
wb.save(buf)
|
wb.save(buf)
|
||||||
|
|||||||
BIN
sample_용산구.xlsx
BIN
sample_용산구.xlsx
Binary file not shown.
@@ -15,9 +15,13 @@
|
|||||||
<span class="header-subtitle">서울특별시 용산구 근무 배치 시스템</span>
|
<span class="header-subtitle">서울특별시 용산구 근무 배치 시스템</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<form id="uploadForm" enctype="multipart/form-data">
|
<form id="workerUploadForm" enctype="multipart/form-data">
|
||||||
<label for="fileInput" class="btn btn-primary"><i class="fas fa-upload"></i> 로컬 업로드</label>
|
<label for="workerFileInput" class="btn btn-primary"><i class="fas fa-users"></i> 근무자 업로드</label>
|
||||||
<input type="file" id="fileInput" accept=".xlsx,.xls" hidden />
|
<input type="file" id="workerFileInput" accept=".xlsx,.xls" hidden />
|
||||||
|
</form>
|
||||||
|
<form id="workplaceUploadForm" enctype="multipart/form-data">
|
||||||
|
<label for="workplaceFileInput" class="btn" style="background:#4a6cf7;color:#fff;"><i class="fas fa-building"></i> 근무처 업로드</label>
|
||||||
|
<input type="file" id="workplaceFileInput" accept=".xlsx,.xls" hidden />
|
||||||
</form>
|
</form>
|
||||||
<button class="btn btn-info" onclick="openGoogleDriveModal()"><i class="fab fa-google-drive"></i> 구글 드라이브</button>
|
<button class="btn btn-info" onclick="openGoogleDriveModal()"><i class="fab fa-google-drive"></i> 구글 드라이브</button>
|
||||||
<button class="btn btn-success" onclick="exportExcel()"><i class="fas fa-download"></i> 내보내기</button>
|
<button class="btn btn-success" onclick="exportExcel()"><i class="fas fa-download"></i> 내보내기</button>
|
||||||
@@ -81,6 +85,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div style="margin-bottom: 16px;">
|
<div style="margin-bottom: 16px;">
|
||||||
|
<label style="display: block; margin-bottom: 8px; font-weight: bold;">데이터 유형</label>
|
||||||
|
<div style="display: flex; gap: 16px; margin-bottom: 16px;">
|
||||||
|
<label style="cursor:pointer;"><input type="radio" name="gdType" value="worker" checked /> <i class="fas fa-users"></i> 근무자</label>
|
||||||
|
<label style="cursor:pointer;"><input type="radio" name="gdType" value="workplace" /> <i class="fas fa-building"></i> 근무처</label>
|
||||||
|
</div>
|
||||||
<label style="display: block; margin-bottom: 8px; font-weight: bold;">파일 ID 또는 공유 링크</label>
|
<label style="display: block; margin-bottom: 8px; font-weight: bold;">파일 ID 또는 공유 링크</label>
|
||||||
<input type="text" id="googleDriveInput" placeholder="파일 ID 또는 https://drive.google.com/file/d/FILE_ID/view"
|
<input type="text" id="googleDriveInput" placeholder="파일 ID 또는 https://drive.google.com/file/d/FILE_ID/view"
|
||||||
style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; box-sizing: border-box;" />
|
style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; box-sizing: border-box;" />
|
||||||
@@ -321,7 +330,8 @@ async function init() {
|
|||||||
doAssignWorker(workerId, nearest.id);
|
doAssignWorker(workerId, nearest.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('fileInput').addEventListener('change', uploadExcel);
|
document.getElementById('workerFileInput').addEventListener('change', uploadWorkerExcel);
|
||||||
|
document.getElementById('workplaceFileInput').addEventListener('change', uploadWorkplaceExcel);
|
||||||
loadTabs();
|
loadTabs();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,12 +389,12 @@ async function pollUploadProgress() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadExcel(e) {
|
async function uploadWorkerExcel(e) {
|
||||||
const file = e.target.files[0];
|
const file = e.target.files[0];
|
||||||
if (!file || !activeTabId) return;
|
if (!file || !activeTabId) return;
|
||||||
|
|
||||||
showLoadingModal();
|
showLoadingModal();
|
||||||
updateLoadingModal('파일 업로드 준비 중...');
|
updateLoadingModal('근무자 파일 업로드 준비 중...');
|
||||||
|
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append('file', file);
|
form.append('file', file);
|
||||||
@@ -401,7 +411,7 @@ async function uploadExcel(e) {
|
|||||||
}
|
}
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
const r = await api('/api/upload', { method: 'POST', body: form });
|
const r = await api('/api/upload-workers', { method: 'POST', body: form });
|
||||||
|
|
||||||
if (pollInterval) {
|
if (pollInterval) {
|
||||||
clearInterval(pollInterval);
|
clearInterval(pollInterval);
|
||||||
@@ -410,22 +420,69 @@ async function uploadExcel(e) {
|
|||||||
|
|
||||||
await pollUploadProgress();
|
await pollUploadProgress();
|
||||||
|
|
||||||
console.log('[업로드 결과] 근무자:', r.workers?.length || 0, '명, 근무처:', r.workplaces?.length || 0, '개');
|
console.log('[근무자 업로드 결과] 근무자:', r.workers?.length || 0, '명');
|
||||||
console.log('[근무처 목록]', r.workplaces);
|
|
||||||
console.log('[근무자 목록]', r.workers);
|
|
||||||
tabDataCache[activeTabId] = r;
|
tabDataCache[activeTabId] = r;
|
||||||
render();
|
render();
|
||||||
await loadTabs();
|
await loadTabs();
|
||||||
updateLoadingModal('완료!');
|
updateLoadingModal('완료!');
|
||||||
setTimeout(hideLoadingModal, 500);
|
setTimeout(hideLoadingModal, 500);
|
||||||
showToast(`파일 로드 완료: 근무자 ${r.workers?.length || 0}명, 근무처 ${r.workplaces?.length || 0}개`, 'success');
|
showToast(`근무자 업로드 완료: ${r.workers?.length || 0}명`, 'success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (pollInterval) {
|
if (pollInterval) {
|
||||||
clearInterval(pollInterval);
|
clearInterval(pollInterval);
|
||||||
}
|
}
|
||||||
console.error('[업로드 실패]', err);
|
console.error('[근무자 업로드 실패]', err);
|
||||||
hideLoadingModal();
|
hideLoadingModal();
|
||||||
showToast('업로드 실패: ' + err.message, 'error');
|
showToast('근무자 업로드 실패: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
e.target.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadWorkplaceExcel(e) {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file || !activeTabId) return;
|
||||||
|
|
||||||
|
showLoadingModal();
|
||||||
|
updateLoadingModal('근무처 파일 업로드 준비 중...');
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
form.append('tab', activeTabId);
|
||||||
|
|
||||||
|
let pollInterval = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
pollInterval = setInterval(async () => {
|
||||||
|
const active = await pollUploadProgress();
|
||||||
|
if (!active) {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
pollInterval = null;
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
const r = await api('/api/upload-workplaces', { method: 'POST', body: form });
|
||||||
|
|
||||||
|
if (pollInterval) {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
pollInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await pollUploadProgress();
|
||||||
|
|
||||||
|
console.log('[근무처 업로드 결과] 근무처:', r.workplaces?.length || 0, '개');
|
||||||
|
tabDataCache[activeTabId] = r;
|
||||||
|
render();
|
||||||
|
await loadTabs();
|
||||||
|
updateLoadingModal('완료!');
|
||||||
|
setTimeout(hideLoadingModal, 500);
|
||||||
|
showToast(`근무처 업로드 완료: ${r.workplaces?.length || 0}개`, 'success');
|
||||||
|
} catch (err) {
|
||||||
|
if (pollInterval) {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
}
|
||||||
|
console.error('[근무처 업로드 실패]', err);
|
||||||
|
hideLoadingModal();
|
||||||
|
showToast('근무처 업로드 실패: ' + err.message, 'error');
|
||||||
}
|
}
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
}
|
}
|
||||||
@@ -474,6 +531,8 @@ async function downloadFromGoogleDrive() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const gdType = document.querySelector('input[name="gdType"]:checked').value;
|
||||||
|
|
||||||
closeGoogleDriveModal();
|
closeGoogleDriveModal();
|
||||||
showLoadingModal();
|
showLoadingModal();
|
||||||
updateLoadingModal('구글 드라이브에서 파일을 다운로드 중...');
|
updateLoadingModal('구글 드라이브에서 파일을 다운로드 중...');
|
||||||
@@ -492,7 +551,7 @@ async function downloadFromGoogleDrive() {
|
|||||||
const r = await api('/api/google-drive-upload', {
|
const r = await api('/api/google-drive-upload', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ fileId: fileId, tab: activeTabId }),
|
body: JSON.stringify({ fileId: fileId, tab: activeTabId, type: gdType }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (pollInterval) {
|
if (pollInterval) {
|
||||||
@@ -502,13 +561,15 @@ async function downloadFromGoogleDrive() {
|
|||||||
|
|
||||||
await pollUploadProgress();
|
await pollUploadProgress();
|
||||||
|
|
||||||
console.log('[구글 드라이브 업로드 결과] 근무자:', r.workers?.length || 0, '명, 근무처:', r.workplaces?.length || 0, '개');
|
const label = gdType === 'worker' ? '근무자' : '근무처';
|
||||||
|
const count = gdType === 'worker' ? (r.workers?.length || 0) + '명' : (r.workplaces?.length || 0) + '개';
|
||||||
|
console.log(`[구글 드라이브 업로드 결과] ${label}:`, count);
|
||||||
tabDataCache[activeTabId] = r;
|
tabDataCache[activeTabId] = r;
|
||||||
render();
|
render();
|
||||||
await loadTabs();
|
await loadTabs();
|
||||||
updateLoadingModal('완료!');
|
updateLoadingModal('완료!');
|
||||||
setTimeout(hideLoadingModal, 500);
|
setTimeout(hideLoadingModal, 500);
|
||||||
showToast(`파일 로드 완료: 근무자 ${r.workers?.length || 0}명, 근무처 ${r.workplaces?.length || 0}개`, 'success');
|
showToast(`${label} 로드 완료: ${count}`, 'success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (pollInterval) {
|
if (pollInterval) {
|
||||||
clearInterval(pollInterval);
|
clearInterval(pollInterval);
|
||||||
@@ -537,11 +598,33 @@ function clearMap() {
|
|||||||
markers = { workers: {}, workplaces: {} };
|
markers = { workers: {}, workplaces: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getWorkplaceGroups() {
|
||||||
|
const d = currentData();
|
||||||
|
const groups = {};
|
||||||
|
for (const wp of d.workplaces) {
|
||||||
|
const key = wp.address;
|
||||||
|
if (!groups[key]) groups[key] = { address: key, names: [], ids: [], lat: wp.lat, lng: wp.lng };
|
||||||
|
groups[key].names.push(wp.name);
|
||||||
|
groups[key].ids.push(wp.id);
|
||||||
|
}
|
||||||
|
return Object.values(groups);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWorkplaceAddress(wpId) {
|
||||||
|
const wp = currentData().workplaces.find(p => p.id === wpId);
|
||||||
|
return wp ? wp.address : null;
|
||||||
|
}
|
||||||
|
|
||||||
function getWorkplaceAssigneeCount(wpId) {
|
function getWorkplaceAssigneeCount(wpId) {
|
||||||
const d = currentData();
|
const d = currentData();
|
||||||
return d.workers.filter(w => d.assignments[w.id] === wpId).length;
|
return d.workers.filter(w => d.assignments[w.id] === wpId).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getGroupAssigneeCount(ids) {
|
||||||
|
const d = currentData();
|
||||||
|
return d.workers.filter(w => ids.includes(d.assignments[w.id])).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);
|
||||||
@@ -550,20 +633,31 @@ function getWorkplaceAssigneesHtml(wpId) {
|
|||||||
assigned.map(w => `<li>${escHtml(w.name)}</li>`).join('') + '</ul></div>';
|
assigned.map(w => `<li>${escHtml(w.name)}</li>`).join('') + '</ul></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getGroupAssigneesHtml(ids) {
|
||||||
|
const d = currentData();
|
||||||
|
const assigned = d.workers.filter(w => ids.includes(d.assignments[w.id]));
|
||||||
|
if (assigned.length === 0) return '<div class="wp-assignees" style="color:#9ca3af;">배정된 근무자 없음</div>';
|
||||||
|
return '<div class="wp-assignees"><strong>배정된 근무자</strong><ul style="margin:4px 0 0 16px;">' +
|
||||||
|
assigned.map(w => `<li>${escHtml(w.name)}</li>`).join('') + '</ul></div>';
|
||||||
|
}
|
||||||
|
|
||||||
function renderWorkplaceMarkers() {
|
function renderWorkplaceMarkers() {
|
||||||
const d = currentData();
|
const d = currentData();
|
||||||
const bounds = [];
|
const bounds = [];
|
||||||
for (const wp of d.workplaces) {
|
const groups = getWorkplaceGroups();
|
||||||
const cnt = getWorkplaceAssigneeCount(wp.id);
|
for (const g of groups) {
|
||||||
const marker = L.marker([wp.lat, wp.lng], { icon: makeWorkplaceIcon(cnt) })
|
const cnt = getGroupAssigneeCount(g.ids);
|
||||||
|
const namesHtml = escHtml(g.names.join(', '));
|
||||||
|
const marker = L.marker([g.lat, g.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> ${namesHtml}</strong><br>
|
||||||
<small>${escHtml(wp.address)}</small>
|
<small>${escHtml(g.address)}</small>
|
||||||
${getWorkplaceAssigneesHtml(wp.id)}
|
${g.ids.length > 1 ? `<div style="font-size:11px;color:#6b7280;margin:2px 0;">(${g.ids.length}개 투표소)</div>` : ''}
|
||||||
|
${getGroupAssigneesHtml(g.ids)}
|
||||||
</div>`);
|
</div>`);
|
||||||
markers.workplaces[wp.id] = marker;
|
markers.workplaces[g.address] = marker;
|
||||||
bounds.push([wp.lat, wp.lng]);
|
bounds.push([g.lat, g.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 });
|
||||||
}
|
}
|
||||||
@@ -684,6 +778,11 @@ function renderWorkerList() {
|
|||||||
${isAssigned ? `<span class="tag-assigned"><i class="fas fa-check-circle"></i> ${escHtml(getWorkplaceName(isAssigned))}</span>` : '<span class="tag-unassigned">미배치</span>'}
|
${isAssigned ? `<span class="tag-assigned"><i class="fas fa-check-circle"></i> ${escHtml(getWorkplaceName(isAssigned))}</span>` : '<span class="tag-unassigned">미배치</span>'}
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
card.addEventListener('click', () => {
|
||||||
|
const pos = getWorkerPos(w);
|
||||||
|
map.setView(pos, 16);
|
||||||
|
if (markers.workers[w.id]) markers.workers[w.id].openPopup();
|
||||||
|
});
|
||||||
card.addEventListener('dragstart', e => { e.dataTransfer.setData('text/plain', w.id); e.dataTransfer.effectAllowed = 'move'; card.classList.add('dragging'); });
|
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'));
|
card.addEventListener('dragend', () => card.classList.remove('dragging'));
|
||||||
list.appendChild(card);
|
list.appendChild(card);
|
||||||
@@ -693,23 +792,27 @@ function renderWorkerList() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateWorkplaceMarker(wpId) {
|
function updateWorkplaceByAddress(addr) {
|
||||||
const d = currentData();
|
const d = currentData();
|
||||||
const marker = markers.workplaces[wpId];
|
const marker = markers.workplaces[addr];
|
||||||
const wp = d.workplaces.find(p => p.id === wpId);
|
if (!marker) return;
|
||||||
if (!marker || !wp) return;
|
const group = getWorkplaceGroups().find(g => g.address === addr);
|
||||||
const cnt = getWorkplaceAssigneeCount(wpId);
|
if (!group) return;
|
||||||
|
const cnt = getGroupAssigneeCount(group.ids);
|
||||||
|
const namesHtml = escHtml(group.names.join(', '));
|
||||||
marker.setIcon(makeWorkplaceIcon(cnt));
|
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> ${namesHtml}</strong><br>
|
||||||
<small>${escHtml(wp.address)}</small>
|
<small>${escHtml(group.address)}</small>
|
||||||
${getWorkplaceAssigneesHtml(wpId)}
|
${group.ids.length > 1 ? `<div style="font-size:11px;color:#6b7280;margin:2px 0;">(${group.ids.length}개 투표소)</div>` : ''}
|
||||||
|
${getGroupAssigneesHtml(group.ids)}
|
||||||
</div>`);
|
</div>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateWorkplacePopup(wpId) {
|
function updateWorkplacePopup(wpId) {
|
||||||
updateWorkplaceMarker(wpId);
|
const addr = getWorkplaceAddress(wpId);
|
||||||
|
if (addr) updateWorkplaceByAddress(addr);
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentSidebarTab = 'workers';
|
let currentSidebarTab = 'workers';
|
||||||
@@ -725,9 +828,11 @@ function renderWorkplaceList() {
|
|||||||
const d = currentData();
|
const d = currentData();
|
||||||
const list = document.getElementById('workplaceList');
|
const list = document.getElementById('workplaceList');
|
||||||
list.innerHTML = '';
|
list.innerHTML = '';
|
||||||
for (const wp of d.workplaces) {
|
const groups = getWorkplaceGroups();
|
||||||
const cnt = getWorkplaceAssigneeCount(wp.id);
|
for (const g of groups) {
|
||||||
const assigned = d.workers.filter(w => d.assignments[w.id] === wp.id);
|
const cnt = getGroupAssigneeCount(g.ids);
|
||||||
|
const assigned = d.workers.filter(w => g.ids.includes(d.assignments[w.id]));
|
||||||
|
const namesHtml = escHtml(g.names.join(', '));
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'workplace-card';
|
card.className = 'workplace-card';
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
@@ -735,20 +840,20 @@ function renderWorkplaceList() {
|
|||||||
<i class="fas fa-building"></i>
|
<i class="fas fa-building"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="workplace-card-info">
|
<div class="workplace-card-info">
|
||||||
<div class="workplace-card-name">${escHtml(wp.name)}</div>
|
<div class="workplace-card-name">${namesHtml}</div>
|
||||||
<div class="workplace-card-address">${escHtml(wp.address)}</div>
|
<div class="workplace-card-address">${escHtml(g.address)}</div>
|
||||||
<div class="workplace-card-assignment">
|
<div class="workplace-card-assignment">
|
||||||
<i class="fas fa-users"></i> ${cnt}명 배치
|
<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>` : ''}
|
${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>
|
||||||
</div>`;
|
</div>`;
|
||||||
card.addEventListener('click', () => {
|
card.addEventListener('click', () => {
|
||||||
map.setView([wp.lat, wp.lng], 16);
|
map.setView([g.lat, g.lng], 16);
|
||||||
if (markers.workplaces[wp.id]) markers.workplaces[wp.id].openPopup();
|
if (markers.workplaces[g.address]) markers.workplaces[g.address].openPopup();
|
||||||
});
|
});
|
||||||
list.appendChild(card);
|
list.appendChild(card);
|
||||||
}
|
}
|
||||||
if (d.workplaces.length === 0) {
|
if (groups.length === 0) {
|
||||||
list.innerHTML = '<div class="empty-state">엑셀 파일을 업로드하세요</div>';
|
list.innerHTML = '<div class="empty-state">엑셀 파일을 업로드하세요</div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -758,7 +863,7 @@ function updateBadges() {
|
|||||||
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;
|
document.getElementById('workplaceCount').textContent = getWorkplaceGroups().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' : ''}`;
|
||||||
|
|||||||
Reference in New Issue
Block a user