diff --git a/app.py b/app.py index ad16006..fd8b799 100644 --- a/app.py +++ b/app.py @@ -1,8 +1,4 @@ -import os -import io -import random -import requests -import openpyxl +import os, io, random, requests, openpyxl from flask import Flask, render_template, request, jsonify, send_file app = Flask(__name__) @@ -13,13 +9,20 @@ os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) YONGSAN_CENTER = (37.5326, 126.9906) store = { - 'workers': [], - 'workplaces': [], - 'assignments': {}, + 'tabs': {}, + 'active_tab': None, + 'next_id': 0, 'filename': None, } +def tab_data(tab_id): + t = store['tabs'].get(tab_id) + if not t: + return None, 404 + return t, None + + def geocode(address): try: url = 'https://nominatim.openstreetmap.org/search' @@ -48,25 +51,105 @@ def geocode_with_fallback(address): return YONGSAN_CENTER[0] + jitter, YONGSAN_CENTER[1] + jitter -@app.route('/') -def index(): - return render_template('index.html') +def empty_tab(name): + return {'name': name, 'workers': [], 'workplaces': [], 'assignments': {}} -@app.route('/upload', methods=['POST']) +# ── Tab management ── + +@app.route('/api/tabs') +def list_tabs(): + tabs = [{'id': tid, 'name': t['name'], + 'workerCount': len(t['workers']), + 'assignCount': len(t['assignments'])} + for tid, t in store['tabs'].items()] + return jsonify({'tabs': tabs, 'activeTab': store['active_tab']}) + + +@app.route('/api/tabs/add', methods=['POST']) +def add_tab(): + body = request.get_json() or {} + name = (body.get('name') or '').strip() or f'탭 {store["next_id"] + 1}' + tid = f'tab_{store["next_id"]}' + store['next_id'] += 1 + store['tabs'][tid] = empty_tab(name) + if store['active_tab'] is None: + store['active_tab'] = tid + return jsonify({'id': tid, 'name': name}) + + +@app.route('/api/tabs/rename', methods=['POST']) +def rename_tab(): + body = request.get_json() or {} + tid = body.get('id') + name = (body.get('name') or '').strip() + if not tid or not name or tid not in store['tabs']: + return jsonify({'error': '잘못된 요청'}), 400 + store['tabs'][tid]['name'] = name + return jsonify({'id': tid, 'name': name}) + + +@app.route('/api/tabs/delete', methods=['POST']) +def delete_tab(): + body = request.get_json() or {} + tid = body.get('id') + if not tid or tid not in store['tabs']: + return jsonify({'error': '잘못된 요청'}), 400 + del store['tabs'][tid] + if store['active_tab'] == tid: + keys = list(store['tabs'].keys()) + store['active_tab'] = keys[0] if keys else None + return jsonify({'deleted': tid}) + + +@app.route('/api/tabs/activate', methods=['POST']) +def activate_tab(): + body = request.get_json() or {} + tid = body.get('id') + if not tid or tid not in store['tabs']: + return jsonify({'error': '탭을 찾을 수 없음'}), 400 + store['active_tab'] = tid + return jsonify({'activeTab': tid}) + + +# ── Data ── + +def get_active(): + t = store['tabs'].get(store['active_tab']) + if not t: + return None, jsonify({'error': '탭 없음'}), 400 + return t, None, None + + +@app.route('/api/data') +def get_data(): + tid = request.args.get('tab') or store['active_tab'] + t = store['tabs'].get(tid) + if not t: + return jsonify({'workers': [], 'workplaces': [], 'assignments': {}}) + return jsonify({ + 'workers': t['workers'], + 'workplaces': t['workplaces'], + 'assignments': t['assignments'], + }) + + +@app.route('/api/upload', methods=['POST']) def upload(): + tid = request.form.get('tab') or store['active_tab'] + t = store['tabs'].get(tid) + if not t: + return jsonify({'error': '탭 없음'}), 400 + file = request.files.get('file') if not file: return jsonify({'error': '파일이 없습니다.'}), 400 path = os.path.join(app.config['UPLOAD_FOLDER'], file.filename) file.save(path) - wb = openpyxl.load_workbook(path, data_only=True) - workers = [] - workplaces = [] - + workers, workplaces = [], [] for sheet_name in wb.sheetnames: ws = wb[sheet_name] headers = [str(c.value or '').strip() for c in ws[1]] @@ -77,22 +160,17 @@ def upload(): if not any(row): continue name = str(row[idx.get('이름', 0)] or '').strip() - hope = str(row[idx.get('희망사항', 1)] or '').strip() - addr = str(row[idx.get('주소', 2)] or '').strip() - dob = str(row[idx.get('생년월일', 3)] or '').strip() - phone = str(row[idx.get('연락처', 4)] or '').strip() if not name: continue - lat, lng = geocode_with_fallback(addr) + lat, lng = geocode_with_fallback(str(row[idx.get('주소', 2)] or '').strip()) workers.append({ 'id': f'w{len(workers)}', 'name': name, - 'hope': hope, - 'address': addr, - 'dob': dob, - 'phone': phone, - 'lat': lat, - 'lng': lng, + 'hope': str(row[idx.get('희망사항', 1)] or '').strip(), + 'address': str(row[idx.get('주소', 2)] or '').strip(), + 'dob': str(row[idx.get('생년월일', 3)] or '').strip(), + 'phone': str(row[idx.get('연락처', 4)] or '').strip(), + 'lat': lat, 'lng': lng, }) if '희망사항' not in headers: @@ -101,106 +179,100 @@ def upload(): if not any(row): continue name = str(row[idx.get('이름', 0)] or '').strip() - addr = str(row[idx.get('주소', 1)] or '').strip() if not name: continue - lat, lng = geocode_with_fallback(addr) + lat, lng = geocode_with_fallback(str(row[idx.get('주소', 1)] or '').strip()) workplaces.append({ 'id': f'p{len(workplaces)}', 'name': name, - 'address': addr, - 'lat': lat, - 'lng': lng, + 'address': str(row[idx.get('주소', 1)] or '').strip(), + 'lat': lat, 'lng': lng, }) - store['workers'] = workers - store['workplaces'] = workplaces - store['assignments'] = {} + t['workers'] = workers + t['workplaces'] = workplaces + t['assignments'] = {} store['filename'] = file.filename - return jsonify({ - 'workers': workers, - 'workplaces': workplaces, - 'assignments': {}, - }) + return jsonify({'workers': workers, 'workplaces': workplaces, 'assignments': {}}) -@app.route('/data') -def get_data(): - return jsonify({ - 'workers': store['workers'], - 'workplaces': store['workplaces'], - 'assignments': store['assignments'], - }) - - -@app.route('/assign', methods=['POST']) +@app.route('/api/assign', methods=['POST']) def assign(): - body = request.get_json() - worker_id = body.get('workerId') - workplace_id = body.get('workplaceId') + body = request.get_json() or {} + tid = body.get('tab') or store['active_tab'] + t = store['tabs'].get(tid) + if not t: + return jsonify({'error': '탭 없음'}), 400 + worker_id, workplace_id = body.get('workerId'), body.get('workplaceId') if not worker_id or not workplace_id: - return jsonify({'error': '잘못된 요청입니다.'}), 400 - store['assignments'][worker_id] = workplace_id - return jsonify({'assignments': store['assignments']}) + return jsonify({'error': '잘못된 요청'}), 400 + t['assignments'][worker_id] = workplace_id + return jsonify({'assignments': t['assignments']}) -@app.route('/unassign', methods=['POST']) +@app.route('/api/unassign', methods=['POST']) def unassign(): - body = request.get_json() + body = request.get_json() or {} + tid = body.get('tab') or store['active_tab'] + t = store['tabs'].get(tid) + if not t: + return jsonify({'error': '탭 없음'}), 400 worker_id = body.get('workerId') - store['assignments'].pop(worker_id, None) - return jsonify({'assignments': store['assignments']}) + t['assignments'].pop(worker_id, None) + return jsonify({'assignments': t['assignments']}) -@app.route('/reset', methods=['POST']) +@app.route('/api/reset', methods=['POST']) def reset(): - store['assignments'] = {} - return jsonify({'assignments': {}}) + body = request.get_json() or {} + tid = body.get('tab') or store['active_tab'] + t = store['tabs'].get(tid) + if t: + t['assignments'] = {} + return jsonify({'assignments': t['assignments'] if t else {}}) -@app.route('/export') +@app.route('/api/export') def export(): + tid = request.args.get('tab') or store['active_tab'] + t = store['tabs'].get(tid) + if not t: + return jsonify({'error': '탭 없음'}), 400 + wb = openpyxl.Workbook() + ws1 = wb.active ws1.title = '배치 현황' ws1.append(['이름', '연락처', '생년월일', '희망사항', '주소', '배치근무지', '근무지주소']) - - wp_lookup = {wp['id']: wp for wp in store['workplaces']} - for w in store['workers']: - assigned = store['assignments'].get(w['id']) - wp_name = '' - wp_addr = '' - if assigned and assigned in wp_lookup: - wp_name = wp_lookup[assigned]['name'] - wp_addr = wp_lookup[assigned]['address'] - ws1.append([w['name'], w['phone'], w['dob'], w['hope'], w['address'], wp_name, wp_addr]) + wp_lookup = {wp['id']: wp for wp in t['workplaces']} + for w in t['workers']: + a_id = t['assignments'].get(w['id']) + wn = wp_lookup[a_id]['name'] if a_id and a_id in wp_lookup else '' + wa = wp_lookup[a_id]['address'] if a_id and a_id in wp_lookup else '' + ws1.append([w['name'], w['phone'], w['dob'], w['hope'], w['address'], wn, wa]) ws2 = wb.create_sheet('근무지별 배치') ws2.append(['근무지명', '주소', '배치인원', '배치된근무자']) - - for wp in store['workplaces']: - assigned_workers = [ - w['name'] for w in store['workers'] - if store['assignments'].get(w['id']) == wp['id'] - ] - count = len(assigned_workers) - ws2.append([wp['name'], wp['address'], count, ', '.join(assigned_workers)]) + for wp in t['workplaces']: + aw = [w['name'] for w in t['workers'] if t['assignments'].get(w['id']) == wp['id']] + ws2.append([wp['name'], wp['address'], len(aw), ', '.join(aw)]) buf = io.BytesIO() wb.save(buf) buf.seek(0) - name = store.get('filename', 'export.xlsx') - base, ext = os.path.splitext(name) - download_name = f'{base}_배치결과{ext}' + tab_name = t['name'] + fname = store.get('filename', 'export.xlsx') + base, ext = os.path.splitext(fname) + dn = f'{base}_{tab_name}_배치결과{ext}' + return send_file(buf, as_attachment=True, download_name=dn, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') - return send_file( - buf, - as_attachment=True, - download_name=download_name, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - ) + +@app.route('/') +def index(): + return render_template('index.html') if __name__ == '__main__': diff --git a/static/style.css b/static/style.css index de42f09..31008f0 100644 --- a/static/style.css +++ b/static/style.css @@ -31,6 +31,39 @@ body { font-family: 'Segoe UI', 'Malgun Gothic', sans-serif; height: 100vh; over .status-badge.partial { background: #fef3c7; color: #d97706; } .status-badge.complete { background: #d1fae5; color: #059669; } +#tabBar { + display: flex; align-items: center; background: #e8eaed; padding: 0 12px; + border-bottom: 1px solid #d0d0d0; min-height: 40px; gap: 2px; z-index: 999; flex-shrink: 0; +} +.tab-list { display: flex; align-items: stretch; flex: 1; overflow-x: auto; gap: 2px; } +.tab-list::-webkit-scrollbar { height: 3px; } +.tab-list::-webkit-scrollbar-thumb { background: #ccc; border-radius: 2px; } +.tab-item { + display: flex; align-items: center; gap: 6px; padding: 6px 10px; + background: #dde0e3; border-radius: 6px 6px 0 0; cursor: pointer; + font-size: 13px; color: #555; white-space: nowrap; user-select: none; + transition: all 0.15s; min-width: 0; +} +.tab-item:hover { background: #d0d3d6; } +.tab-item.active { background: #fff; color: #1a73e8; font-weight: 600; box-shadow: 0 -1px 2px rgba(0,0,0,0.06); } +.tab-name { flex: 1; min-width: 30px; } +.tab-name:hover { text-decoration: underline; } +.tab-badge { font-size: 10px; color: #888; background: #f0f0f0; padding: 1px 6px; border-radius: 8px; } +.tab-item.active .tab-badge { background: #e8f0fe; color: #1a73e8; } +.tab-del { font-size: 16px; color: #999; line-height: 1; padding: 0 2px; } +.tab-del:hover { color: #ef4444; } +.tab-add { + flex-shrink: 0; width: 30px; height: 30px; border: none; background: transparent; + border-radius: 6px; cursor: pointer; color: #666; font-size: 16px; + display: flex; align-items: center; justify-content: center; margin-left: 4px; +} +.tab-add:hover { background: #d0d3d6; color: #1a73e8; } + +.empty-state { + display: flex; align-items: center; justify-content: center; height: 100%; + color: #9ca3af; font-size: 14px; text-align: center; padding: 40px; +} + #main { display: flex; flex: 1; overflow: hidden; } #sidebar { diff --git a/templates/index.html b/templates/index.html index c78e869..b1bc14b 100644 --- a/templates/index.html +++ b/templates/index.html @@ -19,12 +19,17 @@ - - + + +