Feat: 탭 기능 추가 - 날짜/용도별 독립 작업 공간

- 탭바: 자유로운 이름 지정, 추가/삭제/더블클릭 이름변경
- 각 탭은 독립적인 근무자/근무지/배치 데이터 보유
- 탭 전환 시 해당 데이터로 지도/사이드바 갱신
- 내보내기 시 현재 탭 이름이 파일명에 포함됨
- API 경로 /api/* 로 통일
This commit is contained in:
user01
2026-05-12 16:01:55 +09:00
parent 078f0defe9
commit 916a0a1f57
3 changed files with 467 additions and 288 deletions

254
app.py
View File

@@ -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__':