commit ef9439a164c9c94895e6353cb1dab37ca9599360 Author: user01 Date: Tue May 12 15:32:34 2026 +0900 Initial commit: MapinDrag - 근무 배치 시스템 Flask + Leaflet 기반 지도 드래그앤드롭 근무 배치 시스템 - 엑셀 업로드/파싱 - Leaflet 지도에 근무지/근무자 표시 - 드래그앤드롭으로 근무자 배치 - 엑셀 내보내기 diff --git a/.~lock.용산구_선거참관인_배치.xlsx# b/.~lock.용산구_선거참관인_배치.xlsx# new file mode 100644 index 0000000..ef65522 --- /dev/null +++ b/.~lock.용산구_선거참관인_배치.xlsx# @@ -0,0 +1 @@ +,gerd,vivoboxx,12.05.2026 15:27,file:///home/gerd/.config/libreoffice/4; \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..ad16006 --- /dev/null +++ b/app.py @@ -0,0 +1,207 @@ +import os +import io +import random +import requests +import openpyxl +from flask import Flask, render_template, request, jsonify, send_file + +app = Flask(__name__) +app.config['UPLOAD_FOLDER'] = 'uploads' +app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 +os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) + +YONGSAN_CENTER = (37.5326, 126.9906) + +store = { + 'workers': [], + 'workplaces': [], + 'assignments': {}, + 'filename': None, +} + + +def geocode(address): + try: + url = 'https://nominatim.openstreetmap.org/search' + params = {'q': address.strip(), 'format': 'json', 'limit': 1} + headers = {'User-Agent': 'MapinDrag/1.0'} + resp = requests.get(url, params=params, headers=headers, timeout=10) + if resp.ok and resp.json(): + r = resp.json()[0] + return float(r['lat']), float(r['lon']) + except Exception: + pass + return None, None + + +def geocode_with_fallback(address): + lat, lng = geocode(f'{address}, 대한민국') + if lat is not None: + return lat, lng + lat, lng = geocode(f'서울특별시 용산구 {address}') + if lat is not None: + return lat, lng + lat, lng = geocode(f'용산구 {address}') + if lat is not None: + return lat, lng + jitter = random.uniform(-0.005, 0.005) + return YONGSAN_CENTER[0] + jitter, YONGSAN_CENTER[1] + jitter + + +@app.route('/') +def index(): + return render_template('index.html') + + +@app.route('/upload', methods=['POST']) +def upload(): + file = request.files.get('file') + if not file: + return jsonify({'error': '파일이 없습니다.'}), 400 + + path = os.path.join(app.config['UPLOAD_FOLDER'], file.filename) + file.save(path) + + wb = openpyxl.load_workbook(path, data_only=True) + + workers = [] + workplaces = [] + + 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() + hope = str(row[idx.get('희망사항', 1)] or '').strip() + addr = str(row[idx.get('주소', 2)] or '').strip() + dob = str(row[idx.get('생년월일', 3)] or '').strip() + phone = str(row[idx.get('연락처', 4)] or '').strip() + if not name: + continue + lat, lng = geocode_with_fallback(addr) + workers.append({ + 'id': f'w{len(workers)}', + 'name': name, + 'hope': hope, + 'address': addr, + 'dob': dob, + 'phone': phone, + 'lat': lat, + 'lng': lng, + }) + + 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() + addr = str(row[idx.get('주소', 1)] or '').strip() + if not name: + continue + lat, lng = geocode_with_fallback(addr) + workplaces.append({ + 'id': f'p{len(workplaces)}', + 'name': name, + 'address': addr, + 'lat': lat, + 'lng': lng, + }) + + store['workers'] = workers + store['workplaces'] = workplaces + store['assignments'] = {} + store['filename'] = file.filename + + return jsonify({ + 'workers': workers, + 'workplaces': workplaces, + 'assignments': {}, + }) + + +@app.route('/data') +def get_data(): + return jsonify({ + 'workers': store['workers'], + 'workplaces': store['workplaces'], + 'assignments': store['assignments'], + }) + + +@app.route('/assign', methods=['POST']) +def assign(): + body = request.get_json() + worker_id = body.get('workerId') + workplace_id = body.get('workplaceId') + if not worker_id or not workplace_id: + return jsonify({'error': '잘못된 요청입니다.'}), 400 + store['assignments'][worker_id] = workplace_id + return jsonify({'assignments': store['assignments']}) + + +@app.route('/unassign', methods=['POST']) +def unassign(): + body = request.get_json() + worker_id = body.get('workerId') + store['assignments'].pop(worker_id, None) + return jsonify({'assignments': store['assignments']}) + + +@app.route('/reset', methods=['POST']) +def reset(): + store['assignments'] = {} + return jsonify({'assignments': {}}) + + +@app.route('/export') +def export(): + wb = openpyxl.Workbook() + ws1 = wb.active + ws1.title = '배치 현황' + ws1.append(['이름', '연락처', '생년월일', '희망사항', '주소', '배치근무지', '근무지주소']) + + wp_lookup = {wp['id']: wp for wp in store['workplaces']} + for w in store['workers']: + assigned = store['assignments'].get(w['id']) + wp_name = '' + wp_addr = '' + if assigned and assigned in wp_lookup: + wp_name = wp_lookup[assigned]['name'] + wp_addr = wp_lookup[assigned]['address'] + ws1.append([w['name'], w['phone'], w['dob'], w['hope'], w['address'], wp_name, wp_addr]) + + ws2 = wb.create_sheet('근무지별 배치') + ws2.append(['근무지명', '주소', '배치인원', '배치된근무자']) + + for wp in store['workplaces']: + assigned_workers = [ + w['name'] for w in store['workers'] + if store['assignments'].get(w['id']) == wp['id'] + ] + count = len(assigned_workers) + ws2.append([wp['name'], wp['address'], count, ', '.join(assigned_workers)]) + + buf = io.BytesIO() + wb.save(buf) + buf.seek(0) + + name = store.get('filename', 'export.xlsx') + base, ext = os.path.splitext(name) + download_name = f'{base}_배치결과{ext}' + + return send_file( + buf, + as_attachment=True, + download_name=download_name, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ) + + +if __name__ == '__main__': + app.run(debug=True) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..bb70cce --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +Flask==3.1.1 +openpyxl==3.1.5 +requests==2.32.3 diff --git a/sample_용산구.xlsx b/sample_용산구.xlsx new file mode 100644 index 0000000..85f4be6 Binary files /dev/null and b/sample_용산구.xlsx differ diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..de42f09 --- /dev/null +++ b/static/style.css @@ -0,0 +1,117 @@ +* { margin: 0; padding: 0; box-sizing: border-box; } +body { font-family: 'Segoe UI', 'Malgun Gothic', sans-serif; height: 100vh; overflow: hidden; display: flex; flex-direction: column; background: #f0f2f5; } + +#header { + display: flex; justify-content: space-between; align-items: center; + padding: 10px 20px; background: #fff; border-bottom: 1px solid #e0e0e0; + box-shadow: 0 1px 3px rgba(0,0,0,0.08); z-index: 1000; min-height: 56px; gap: 12px; flex-wrap: wrap; +} +.header-left { display: flex; align-items: center; gap: 12px; } +.header-left h1 { font-size: 20px; color: #1a73e8; display: flex; align-items: center; gap: 8px; } +.header-subtitle { color: #666; font-size: 13px; } +.header-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } + +.btn { + display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; + border: none; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 500; + transition: all 0.2s; white-space: nowrap; +} +.btn-primary { background: #1a73e8; color: #fff; } +.btn-primary:hover { background: #1557b0; } +.btn-success { background: #10b981; color: #fff; } +.btn-success:hover { background: #059669; } +.btn-danger { background: #ef4444; color: #fff; } +.btn-danger:hover { background: #dc2626; } +.btn-small { padding: 4px 10px; font-size: 11px; border: none; border-radius: 4px; cursor: pointer; margin-top: 4px; } + +.status-badge { + padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 600; + background: #e5e7eb; color: #6b7280; min-width: 80px; text-align: center; +} +.status-badge.partial { background: #fef3c7; color: #d97706; } +.status-badge.complete { background: #d1fae5; color: #059669; } + +#main { display: flex; flex: 1; overflow: hidden; } + +#sidebar { + width: 340px; min-width: 340px; background: #fff; border-right: 1px solid #e0e0e0; + display: flex; flex-direction: column; overflow: hidden; +} +.sidebar-header { + display: flex; justify-content: space-between; align-items: center; + padding: 14px 16px; border-bottom: 1px solid #e0e0e0; +} +.sidebar-header h2 { font-size: 15px; display: flex; align-items: center; gap: 6px; color: #333; } +.count-badge { + background: #1a73e8; color: #fff; padding: 2px 10px; border-radius: 10px; font-size: 12px; font-weight: 600; +} +.worker-list { + flex: 1; overflow-y: auto; padding: 8px; +} + +.worker-card { + display: flex; gap: 10px; padding: 10px 12px; margin-bottom: 6px; + background: #f8f9fa; border: 1px solid #e5e7eb; border-radius: 8px; + cursor: grab; transition: all 0.2s; user-select: none; +} +.worker-card:hover { border-color: #1a73e8; box-shadow: 0 2px 8px rgba(26,115,232,0.12); } +.worker-card:active { cursor: grabbing; } +.worker-card.dragging { opacity: 0.4; } +.worker-card.assigned { background: #ecfdf5; border-color: #10b981; } + +.worker-card-avatar { + width: 40px; height: 40px; border-radius: 50%; background: #e5e7eb; + display: flex; align-items: center; justify-content: center; color: #6b7280; + flex-shrink: 0; font-size: 16px; +} +.worker-card-avatar.assigned { background: #d1fae5; color: #059669; } + +.worker-card-info { flex: 1; min-width: 0; } +.worker-card-name { font-weight: 600; font-size: 14px; color: #111; } +.worker-card-detail { font-size: 12px; color: #6b7280; margin-top: 1px; } +.worker-card-address { font-size: 11px; color: #9ca3af; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; } +.worker-card-hope { font-size: 11px; color: #d97706; margin-top: 2px; display: flex; align-items: center; gap: 3px; } +.worker-card-assignment { margin-top: 4px; } + +.tag-assigned { font-size: 11px; color: #059669; font-weight: 500; display: flex; align-items: center; gap: 3px; } +.tag-unassigned { font-size: 11px; color: #9ca3af; } + +#mapContainer { + flex: 1; position: relative; overflow: hidden; +} +#map { width: 100%; height: 100%; } + +#dropOverlay { + position: absolute; top: 10px; left: 10px; right: 10px; bottom: 10px; + border: 3px dashed #1a73e8; border-radius: 12px; background: rgba(26,115,232,0.06); + display: none; align-items: center; justify-content: center; + font-size: 18px; color: #1a73e8; font-weight: 600; pointer-events: none; z-index: 500; +} +#dropOverlay.active { display: flex; } +#dropOverlay::before { content: '\f279'; font-family: 'Font Awesome 6 Free'; font-weight: 900; margin-right: 8px; } + +.marker-icon { + display: flex; align-items: center; justify-content: center; border-radius: 50%; + box-shadow: 0 2px 8px rgba(0,0,0,0.2); border: 2px solid #fff; +} +.worker-marker { background: #f59e0b; color: #fff; font-size: 14px; } +.worker-assigned-marker { background: #10b981; color: #fff; font-size: 14px; } +.workplace-marker { + background: #1a73e8; color: #fff; font-size: 18px; border-radius: 8px; width: 42px; height: 42px; + display: flex; align-items: center; justify-content: center; +} + +.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; } + +.toast { + position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); + padding: 12px 24px; border-radius: 8px; color: #fff; font-size: 14px; font-weight: 500; + z-index: 9999; transition: all 0.3s; opacity: 1; box-shadow: 0 4px 12px rgba(0,0,0,0.15); + white-space: nowrap; +} +.toast.hidden { opacity: 0; transform: translateX(-50%) translateY(10px); pointer-events: none; } +.toast.success { background: #10b981; } +.toast.error { background: #ef4444; } +.toast.info { background: #1a73e8; } +.toast.warning { background: #f59e0b; color: #333; } diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..2b6eab6 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,364 @@ + + + + + +MapinDrag - 근무 배치 시스템 + + + + + + + +
+ + +
+
+
여기에 드롭하여 배치
+
+
+ + + + + + + diff --git a/uploads/sample_용산구.xlsx b/uploads/sample_용산구.xlsx new file mode 100644 index 0000000..85f4be6 Binary files /dev/null and b/uploads/sample_용산구.xlsx differ diff --git a/용산구_선거참관인_배치.xlsx b/용산구_선거참관인_배치.xlsx new file mode 100644 index 0000000..49b9a9f Binary files /dev/null and b/용산구_선거참관인_배치.xlsx differ