Feat: --port 옵션, VWorld 맵 타일, 구글 드라이브 업로드 추가
This commit is contained in:
285
app.py
285
app.py
@@ -1,4 +1,4 @@
|
|||||||
import os, io, random, requests, openpyxl, concurrent.futures
|
import os, io, random, requests, openpyxl, concurrent.futures, argparse
|
||||||
from flask import Flask, render_template, request, jsonify, send_file
|
from flask import Flask, render_template, request, jsonify, send_file
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@@ -345,6 +345,41 @@ def get_active():
|
|||||||
return t, None, None
|
return t, None, None
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/vworld-api-status')
|
||||||
|
def vworld_api_status():
|
||||||
|
"""VWorld API Key의 유효성을 확인합니다."""
|
||||||
|
if not VWORLD_API_KEY:
|
||||||
|
return jsonify({'valid': False, 'key': None})
|
||||||
|
|
||||||
|
# 간단한 주소로 VWorld API 테스트
|
||||||
|
try:
|
||||||
|
test_address = '서울특별시 용산구'
|
||||||
|
url = 'https://api.vworld.kr/req/address'
|
||||||
|
params = {
|
||||||
|
'service': 'address',
|
||||||
|
'request': 'getcoord',
|
||||||
|
'version': '2.0',
|
||||||
|
'crs': 'EPSG:4326',
|
||||||
|
'address': test_address,
|
||||||
|
'format': 'json',
|
||||||
|
'type': 'PARCEL',
|
||||||
|
'key': VWORLD_API_KEY,
|
||||||
|
}
|
||||||
|
resp = requests.get(url, params=params, timeout=5)
|
||||||
|
|
||||||
|
if resp.ok:
|
||||||
|
data = resp.json()
|
||||||
|
response = data.get('response', {})
|
||||||
|
status = response.get('status')
|
||||||
|
# OK, SUCCESS, FAIL 등의 상태가 반환되면 유효한 API Key
|
||||||
|
if status in ['OK', 'SUCCESS', 'FAIL']:
|
||||||
|
return jsonify({'valid': True, 'key': VWORLD_API_KEY})
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f'VWorld API 테스트 실패: {e}')
|
||||||
|
|
||||||
|
return jsonify({'valid': False, 'key': None})
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/data')
|
@app.route('/api/data')
|
||||||
def get_data():
|
def get_data():
|
||||||
tid = request.args.get('tab') or store['active_tab']
|
tid = request.args.get('tab') or store['active_tab']
|
||||||
@@ -381,6 +416,186 @@ def is_likely_worker_sheet(sheet_name, headers):
|
|||||||
return worker_indicators >= 2
|
return worker_indicators >= 2
|
||||||
|
|
||||||
|
|
||||||
|
def download_from_google_drive(file_id, timeout=30):
|
||||||
|
"""구글 드라이브에서 파일을 다운로드합니다."""
|
||||||
|
url = f'https://drive.google.com/uc?id={file_id}&export=download'
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(f'구글 드라이브에서 파일 다운로드 중: {file_id}')
|
||||||
|
resp = requests.get(url, timeout=timeout, allow_redirects=True, stream=True)
|
||||||
|
|
||||||
|
if not resp.ok:
|
||||||
|
logger.error(f'구글 드라이브 다운로드 실패: 상태코드={resp.status_code}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 파일 크기 확인
|
||||||
|
file_size = int(resp.headers.get('content-length', 0))
|
||||||
|
if file_size == 0:
|
||||||
|
logger.warning('다운로드한 파일의 크기가 0입니다')
|
||||||
|
return None
|
||||||
|
|
||||||
|
if file_size > 16 * 1024 * 1024: # 16MB 이상
|
||||||
|
logger.error(f'파일이 너무 큽니다: {file_size} bytes')
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 파일을 바이너리로 읽기
|
||||||
|
file_content = io.BytesIO(resp.content)
|
||||||
|
logger.info(f'파일 다운로드 완료: {file_size} bytes')
|
||||||
|
return file_content
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
logger.error(f'구글 드라이브 다운로드 타임아웃: {file_id}')
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'구글 드라이브 다운로드 중 오류: {e}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def process_excel_file(wb, filename):
|
||||||
|
"""엑셀 파일을 처리하고 근무자/근무처 데이터를 반환합니다."""
|
||||||
|
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]]
|
||||||
|
logger.info(f' 시트: {sheet_name}, 헤더: {headers}')
|
||||||
|
update_progress('엑셀 파싱', f'시트 처리 중: {sheet_name}')
|
||||||
|
|
||||||
|
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, ['주소', '위치', '소재지'])
|
||||||
|
|
||||||
|
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"]}')
|
||||||
|
|
||||||
|
# 근무자/근무처 데이터 구성
|
||||||
|
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({
|
||||||
|
'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)}개 ===')
|
||||||
|
return workers, workplaces
|
||||||
|
|
||||||
|
|
||||||
@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']
|
||||||
@@ -564,6 +779,69 @@ def upload():
|
|||||||
return jsonify({'workers': workers, 'workplaces': workplaces, 'assignments': {}})
|
return jsonify({'workers': workers, 'workplaces': workplaces, 'assignments': {}})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/google-drive-upload', methods=['POST'])
|
||||||
|
def google_drive_upload():
|
||||||
|
"""구글 드라이브에서 엑셀 파일을 다운로드하여 처리합니다."""
|
||||||
|
body = request.get_json() or {}
|
||||||
|
file_id = body.get('fileId', '').strip()
|
||||||
|
tid = body.get('tab') or store['active_tab']
|
||||||
|
|
||||||
|
if not file_id:
|
||||||
|
return jsonify({'error': '파일 ID가 없습니다.'}), 400
|
||||||
|
|
||||||
|
t = store['tabs'].get(tid)
|
||||||
|
if not t:
|
||||||
|
return jsonify({'error': '탭 없음'}), 400
|
||||||
|
|
||||||
|
upload_progress.update({
|
||||||
|
'active': True,
|
||||||
|
'stage': '시작',
|
||||||
|
'total': 0,
|
||||||
|
'current': 0,
|
||||||
|
'message': '',
|
||||||
|
'logs': [],
|
||||||
|
})
|
||||||
|
|
||||||
|
update_progress('파일 로딩', f'구글 드라이브에서 파일을 다운로드 중입니다...')
|
||||||
|
|
||||||
|
# 구글 드라이브에서 파일 다운로드
|
||||||
|
file_content = download_from_google_drive(file_id)
|
||||||
|
if not file_content:
|
||||||
|
upload_progress['active'] = False
|
||||||
|
return jsonify({'error': '파일을 다운로드할 수 없습니다. 파일 ID를 확인하고 파일이 공유되어 있는지 확인하세요.'}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 파일을 엑셀로 읽기
|
||||||
|
update_progress('파일 파싱', '엑셀 파일을 읽는 중입니다...')
|
||||||
|
wb = openpyxl.load_workbook(file_content, data_only=True)
|
||||||
|
|
||||||
|
logger.info(f'=== 구글 드라이브 엑셀 업로드 시작: {file_id} ===')
|
||||||
|
logger.info(f'시트 목록: {wb.sheetnames}')
|
||||||
|
|
||||||
|
# 엑셀 파일 처리
|
||||||
|
workers, workplaces = process_excel_file(wb, f'GoogleDrive_{file_id}.xlsx')
|
||||||
|
|
||||||
|
# 탭에 데이터 저장
|
||||||
|
t['workers'] = workers
|
||||||
|
t['workplaces'] = workplaces
|
||||||
|
t['assignments'] = {}
|
||||||
|
store['filename'] = f'GoogleDrive_{file_id}'
|
||||||
|
|
||||||
|
update_progress('완료', f'처리 완료: 근무자 {len(workers)}명, 근무처 {len(workplaces)}개')
|
||||||
|
upload_progress['active'] = False
|
||||||
|
|
||||||
|
logger.info(f'=== 구글 드라이브 업로드 완료: 근무자 {len(workers)}명, 근무처 {len(workplaces)}개 ===')
|
||||||
|
|
||||||
|
return jsonify({'workers': workers, 'workplaces': workplaces, 'assignments': {}})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'엑셀 파일 처리 중 오류: {e}')
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
upload_progress['active'] = False
|
||||||
|
return jsonify({'error': f'파일 처리 중 오류: {str(e)}'}), 400
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/assign', methods=['POST'])
|
@app.route('/api/assign', methods=['POST'])
|
||||||
def assign():
|
def assign():
|
||||||
body = request.get_json() or {}
|
body = request.get_json() or {}
|
||||||
@@ -643,4 +921,7 @@ def index():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
app.run(debug=True)
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument('--port', type=int, default=5000, help='Port to run the server on')
|
||||||
|
args = parser.parse_args()
|
||||||
|
app.run(debug=True, port=args.port)
|
||||||
|
|||||||
@@ -16,9 +16,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<form id="uploadForm" enctype="multipart/form-data">
|
<form id="uploadForm" enctype="multipart/form-data">
|
||||||
<label for="fileInput" class="btn btn-primary"><i class="fas fa-upload"></i> 엑셀 업로드</label>
|
<label for="fileInput" class="btn btn-primary"><i class="fas fa-upload"></i> 로컬 업로드</label>
|
||||||
<input type="file" id="fileInput" accept=".xlsx,.xls" hidden />
|
<input type="file" id="fileInput" 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-success" onclick="exportExcel()"><i class="fas fa-download"></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>
|
<button class="btn btn-danger" onclick="resetAssignments()"><i class="fas fa-undo"></i> 초기화</button>
|
||||||
<span id="statusBadge" class="status-badge"></span>
|
<span id="statusBadge" class="status-badge"></span>
|
||||||
@@ -72,6 +73,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="googleDriveModal" class="modal-overlay hidden">
|
||||||
|
<div class="modal-content" style="width: 500px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3><i class="fab fa-google-drive"></i> 구글 드라이브에서 불러오기</h3>
|
||||||
|
<button class="modal-close" onclick="closeGoogleDriveModal()" style="background: none; border: none; font-size: 24px; cursor: pointer; color: #666;">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div style="margin-bottom: 16px;">
|
||||||
|
<label style="display: block; margin-bottom: 8px; font-weight: bold;">파일 ID 또는 공유 링크</label>
|
||||||
|
<input type="text" id="googleDriveInput" placeholder="파일 ID 또는 https://drive.google.com/file/d/FILE_ID/view"
|
||||||
|
style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; box-sizing: border-box;" />
|
||||||
|
<div style="margin-top: 8px; font-size: 12px; color: #666;">
|
||||||
|
<p>• 구글 드라이브에서 공유된 파일의 링크를 붙여넣으세요</p>
|
||||||
|
<p>• 또는 파일 ID만 입력하세요 (예: 1a2b3c4d5e...)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 8px; justify-content: flex-end;">
|
||||||
|
<button onclick="closeGoogleDriveModal()" class="btn" style="background: #ddd; color: #333;">취소</button>
|
||||||
|
<button onclick="downloadFromGoogleDrive()" class="btn btn-primary">다운로드</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
<script 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];
|
||||||
@@ -226,12 +251,60 @@ function renderTabBar() {
|
|||||||
|
|
||||||
// ── Map ──
|
// ── Map ──
|
||||||
|
|
||||||
function init() {
|
let tileLayer = null;
|
||||||
map = L.map('map').setView(YONGSAN, 14);
|
let mapSourceType = 'openstreetmap'; // 'vworld' 또는 'openstreetmap'
|
||||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
let vworldApiKey = null;
|
||||||
maxZoom: 18,
|
|
||||||
|
function setMapTileLayer(useVWorld = false) {
|
||||||
|
if (tileLayer) {
|
||||||
|
map.removeLayer(tileLayer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useVWorld && vworldApiKey) {
|
||||||
|
tileLayer = L.tileLayer(
|
||||||
|
`https://api.vworld.kr/req/wmts/1.0.0/${vworldApiKey}/Base/{z}/{y}/{x}.png`,
|
||||||
|
{
|
||||||
|
maxZoom: 19,
|
||||||
|
minZoom: 1,
|
||||||
|
attribution: '© <a href="http://www.vworld.kr" target="_blank">VWorld</a>',
|
||||||
|
errorTileUrl: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
|
||||||
|
}
|
||||||
|
).addTo(map);
|
||||||
|
mapSourceType = 'vworld';
|
||||||
|
} else {
|
||||||
|
// OpenStreetMap (기본값)
|
||||||
|
tileLayer = L.tileLayer(
|
||||||
|
'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
|
{
|
||||||
|
maxZoom: 19,
|
||||||
|
minZoom: 1,
|
||||||
attribution: '© OpenStreetMap contributors',
|
attribution: '© OpenStreetMap contributors',
|
||||||
}).addTo(map);
|
}
|
||||||
|
).addTo(map);
|
||||||
|
mapSourceType = 'openstreetmap';
|
||||||
|
console.log('[지도] OpenStreetMap 로드됨');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
map = L.map('map').setView(YONGSAN, 14);
|
||||||
|
|
||||||
|
// VWorld API 상태 확인 및 맵 초기화
|
||||||
|
try {
|
||||||
|
const statusResp = await fetch('/api/vworld-api-status');
|
||||||
|
const statusData = await statusResp.json();
|
||||||
|
console.log('[지도] VWorld API 상태:', statusData);
|
||||||
|
|
||||||
|
if (statusData.valid && statusData.key) {
|
||||||
|
vworldApiKey = statusData.key;
|
||||||
|
setMapTileLayer(true); // VWorld 시도
|
||||||
|
} else {
|
||||||
|
setMapTileLayer(false); // OpenStreetMap 사용
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[지도] VWorld API 상태 확인 실패:', e);
|
||||||
|
setMapTileLayer(false); // 에러 시 OpenStreetMap 사용
|
||||||
|
}
|
||||||
|
|
||||||
const mc = document.getElementById('mapContainer');
|
const mc = document.getElementById('mapContainer');
|
||||||
mc.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; document.getElementById('dropOverlay').classList.add('active'); });
|
mc.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; document.getElementById('dropOverlay').classList.add('active'); });
|
||||||
@@ -357,6 +430,95 @@ async function uploadExcel(e) {
|
|||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Google Drive Integration ──
|
||||||
|
|
||||||
|
function openGoogleDriveModal() {
|
||||||
|
if (!activeTabId) {
|
||||||
|
showToast('먼저 탭을 선택하세요', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById('googleDriveModal').classList.remove('hidden');
|
||||||
|
document.getElementById('googleDriveInput').value = '';
|
||||||
|
document.getElementById('googleDriveInput').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeGoogleDriveModal() {
|
||||||
|
document.getElementById('googleDriveModal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractFileId(input) {
|
||||||
|
// 링크에서 파일 ID 추출: https://drive.google.com/file/d/FILE_ID/view
|
||||||
|
let match = input.match(/\/d\/([a-zA-Z0-9-_]+)/);
|
||||||
|
if (match) return match[1];
|
||||||
|
|
||||||
|
// 또는 링크에서: https://drive.google.com/open?id=FILE_ID
|
||||||
|
match = input.match(/id=([a-zA-Z0-9-_]+)/);
|
||||||
|
if (match) return match[1];
|
||||||
|
|
||||||
|
// 파일 ID 자체일 수도 있음
|
||||||
|
if (input.match(/^[a-zA-Z0-9-_]+$/)) return input;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFromGoogleDrive() {
|
||||||
|
const input = document.getElementById('googleDriveInput').value.trim();
|
||||||
|
if (!input) {
|
||||||
|
showToast('파일 ID 또는 링크를 입력하세요', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileId = extractFileId(input);
|
||||||
|
if (!fileId) {
|
||||||
|
showToast('올바르지 않은 파일 ID 또는 링크입니다', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeGoogleDriveModal();
|
||||||
|
showLoadingModal();
|
||||||
|
updateLoadingModal('구글 드라이브에서 파일을 다운로드 중...');
|
||||||
|
|
||||||
|
let pollInterval = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
pollInterval = setInterval(async () => {
|
||||||
|
const active = await pollUploadProgress();
|
||||||
|
if (!active) {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
pollInterval = null;
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
const r = await api('/api/google-drive-upload', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ fileId: fileId, tab: activeTabId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pollInterval) {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
pollInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await pollUploadProgress();
|
||||||
|
|
||||||
|
console.log('[구글 드라이브 업로드 결과] 근무자:', r.workers?.length || 0, '명, 근무처:', r.workplaces?.length || 0, '개');
|
||||||
|
tabDataCache[activeTabId] = r;
|
||||||
|
render();
|
||||||
|
await loadTabs();
|
||||||
|
updateLoadingModal('완료!');
|
||||||
|
setTimeout(hideLoadingModal, 500);
|
||||||
|
showToast(`파일 로드 완료: 근무자 ${r.workers?.length || 0}명, 근무처 ${r.workplaces?.length || 0}개`, 'success');
|
||||||
|
} catch (err) {
|
||||||
|
if (pollInterval) {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
}
|
||||||
|
console.error('[구글 드라이브 다운로드 실패]', err);
|
||||||
|
hideLoadingModal();
|
||||||
|
showToast('다운로드 실패: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Rendering ──
|
// ── Rendering ──
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
|
|||||||
Reference in New Issue
Block a user