SOY NV AI commited on
Commit
1eafd18
·
1 Parent(s): 9764e5c

?뱁댆 留덉씪?ㅽ넠 湲곕뒫 媛쒖꽑 諛??좎쭨 ?좏떥由ы떚 異붽?

Browse files
EXAONE_설치_가이드.md CHANGED
@@ -186,6 +186,8 @@ tokenizer = AutoTokenizer.from_pretrained("LGAI-EXAONE/EXAONE-3.0-7.8B-Instruct"
186
 
187
 
188
 
 
 
189
 
190
 
191
 
 
186
 
187
 
188
 
189
+
190
+
191
 
192
 
193
 
add_exaone_model.py CHANGED
@@ -181,6 +181,8 @@ if __name__ == "__main__":
181
 
182
 
183
 
 
 
184
 
185
 
186
 
 
181
 
182
 
183
 
184
+
185
+
186
 
187
 
188
 
app/__init__.py CHANGED
@@ -302,9 +302,38 @@ def create_app() -> Flask:
302
  path = request.path or ''
303
  if not (path.startswith('/static') or path == '/favicon.ico'):
304
  return _db_error_response()
 
 
 
 
 
305
  logger.info(f"[요청] {request.method} {request.path} - IP: {request.remote_addr}")
306
  if request.args:
307
  logger.debug(f"[요청 파라미터] {dict(request.args)}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
308
 
309
  @app.after_request
310
  def log_response_info(response):
 
302
  path = request.path or ''
303
  if not (path.startswith('/static') or path == '/favicon.ico'):
304
  return _db_error_response()
305
+
306
+ # 민감한 정보 제외하고 로깅
307
+ if request.path.endswith('.json') or request.path.endswith('.ico') or request.path.startswith('/static'):
308
+ return
309
+
310
  logger.info(f"[요청] {request.method} {request.path} - IP: {request.remote_addr}")
311
  if request.args:
312
  logger.debug(f"[요청 파라미터] {dict(request.args)}")
313
+
314
+ # POST 요청 바디 로깅 (JSON인 경우만, 너무 길면 자름)
315
+ if request.method == 'POST' and request.is_json:
316
+ try:
317
+ body = request.get_json()
318
+ body_str = str(body)
319
+ if len(body_str) > 1000:
320
+ body_str = body_str[:1000] + "..."
321
+ logger.info(f"[요청 본문] {body_str}")
322
+ except:
323
+ pass
324
+
325
+ @app.errorhandler(Exception)
326
+ def handle_exception(e):
327
+ """처리되지 않은 모든 예외 로깅"""
328
+ import traceback
329
+ err_trace = traceback.format_exc()
330
+ logger.error(f"[Unhandled Exception] {str(e)}\n{err_trace}")
331
+
332
+ # API 요청인 경우 JSON 응답
333
+ if request.path.startswith('/api/'):
334
+ return jsonify({'error': str(e), 'trace': err_trace}), 500
335
+
336
+ return str(e), 500
337
 
338
  @app.after_request
339
  def log_response_info(response):
app/routes.py CHANGED
@@ -24,6 +24,7 @@ from datetime import date as date_type
24
  import uuid
25
  import re
26
  import json
 
27
 
28
  main_bp = Blueprint('main', __name__)
29
 
@@ -83,6 +84,7 @@ def get_default_admin_menu():
83
  {"label": "마일스톤 기본 데이터 세팅", "endpoint": "main.admin_webtoon_milestone_settings", "roles": ["admin", "webtoon_pm"]},
84
  {"label": "작품별 마일스톤 세팅", "endpoint": "main.admin_webtoon_milestones", "roles": ["admin", "webtoon_pm"]},
85
  {"label": "마일스톤 확인", "endpoint": "main.admin_webtoon_milestone_latest_result", "roles": ["admin", "webtoon_pm"]},
 
86
  ]
87
  },
88
  {
@@ -541,15 +543,25 @@ def admin_required(f):
541
  @wraps(f)
542
  @login_required
543
  def decorated_function(*args, **kwargs):
544
- # admin 또는 webtoon_pm 허용 (기본 관리자 페이지 접근)
545
- user_role = getattr(current_user, 'role', 'user')
546
- if not (current_user.is_admin or user_role in ['admin', 'webtoon_pm']):
547
- # API 요청인 경우 JSON 응답 반환
 
 
 
 
 
 
 
 
 
 
 
 
548
  if request.path.startswith('/api/'):
549
- return jsonify({'error': '관리자 권한 필요합니다.'}), 403
550
- flash('관리자 권한이 필요합니다.', 'error')
551
- return redirect(url_for('main.index'))
552
- return f(*args, **kwargs)
553
  return decorated_function
554
 
555
  # Ollama 기본 URL (환경 변수로 설정 가능)
@@ -6483,18 +6495,53 @@ def create_webtoon_milestone():
6483
  """웹소설 파일에 대한 마일스톤 생성 (순차 진행 역산)"""
6484
  try:
6485
  data = request.json
 
 
 
 
 
 
 
 
 
 
6486
  file_id = data.get('file_id')
6487
  basis = data.get('basis', 'median')
6488
- cadence_days = int(data.get('cadence_days', 7))
6489
- ep0_days = int(data.get('ep0_days', 21))
6490
- plan_days = int(data.get('plan_days', 7))
6491
  launch_date_s = data.get('launch_date')
 
6492
 
6493
  if not file_id or not launch_date_s:
6494
  return jsonify({'error': '필수 파라미터가 누락되었습니다.'}), 400
6495
 
6496
  launch_date = datetime.strptime(launch_date_s, '%Y-%m-%d').date()
6497
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6498
  def _median_int(vals):
6499
  if not vals: return 0
6500
  vals = sorted(vals)
@@ -6641,14 +6688,50 @@ def create_webtoon_milestone():
6641
 
6642
  d20 = episode_total_days.get(20, 0)
6643
  ep20_end = launch_date
6644
- ep20_start = ep20_end - timedelta(days=d20 - 1) if d20 > 0 else ep20_end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6645
  start_date = ep20_start - timedelta(days=(20 - 1) * cadence_days)
6646
- proj_start_date = start_date - timedelta(days=ep0_days) if ep0_days > 0 else start_date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6647
 
6648
  episodes = []
6649
  if ep0_days > 0:
6650
- ep0_start = proj_start_date
6651
- ep0_end = ep0_start + timedelta(days=ep0_days - 1)
 
 
 
 
 
 
6652
  episodes.append({
6653
  'episode_num': 0,
6654
  'start_date': ep0_start.isoformat(),
@@ -6663,53 +6746,92 @@ def create_webtoon_milestone():
6663
  stages_out = []
6664
 
6665
  if basis == 'settings':
6666
- # 위에서 계산한 로직과 동일하게 날짜 배정
6667
- sim_starts = []
6668
- sim_ends = []
 
6669
  for i, stage in enumerate(tpl):
6670
  dur = stage['days']
6671
  s_key = stage['stage_key']
6672
 
6673
- prev_end = sim_ends[i-1] if i > 0 else 0
6674
- my_start_offset = prev_end + 1
6675
-
 
 
 
 
 
 
 
 
6676
  if i > 0:
6677
  prev_key = tpl[i-1]['stage_key']
6678
  ov_info = settings_overlaps.get(str(s_key))
6679
  if ov_info and str(ov_info.get('a')) == str(prev_key):
6680
  ov_days = float(ov_info.get('overlap_days') or 0)
6681
- target_start = prev_end - ov_days + 1
6682
- # 이 단계 시작보다는 같거나 뒤여야 논리적임 (sim_starts[i-1])
6683
- # (Gantt직과 유사하게)
6684
- my_start_offset = max(sim_starts[i-1], target_start)
6685
- my_start_offset = max(1, my_start_offset)
6686
-
6687
- sim_starts.append(my_start_offset)
6688
- sim_ends.append(my_start_offset + dur - 1)
6689
 
6690
- # 실제 날짜 변환 (ep_start가 Day 1)
6691
- s_date = ep_start + timedelta(days=my_start_offset - 1)
6692
- e_date = ep_start + timedelta(days=my_start_offset + dur - 1 - 1) # -1 due to inclusive
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6693
 
6694
  stages_out.append({
6695
  'stage_key': s_key,
6696
  'duration_days': dur,
6697
- 'start_date': s_date.isoformat(),
6698
- 'end_date': e_date.isoformat()
6699
  })
6700
 
6701
  else:
 
6702
  cur = ep_start
 
 
 
 
 
6703
  for t in tpl:
6704
  d = t['days']
6705
- en = cur + timedelta(days=d - 1)
 
 
6706
  stages_out.append({
6707
  'stage_key': t['stage_key'],
6708
  'duration_days': d,
6709
  'start_date': cur.isoformat(),
6710
  'end_date': en.isoformat()
6711
  })
 
 
6712
  cur = en + timedelta(days=1)
 
 
 
6713
 
6714
  if stages_out:
6715
  ep_end = datetime.strptime(stages_out[-1]['end_date'], '%Y-%m-%d').date()
@@ -6735,17 +6857,86 @@ def create_webtoon_milestone():
6735
  launch_date=launch_date,
6736
  schedule_json=json.dumps(result, ensure_ascii=False)
6737
  )
 
 
 
 
 
 
 
6738
  db.session.add(milestone)
6739
  db.session.commit()
6740
- return jsonify({'message': '마일스톤 생성 완료', 'id': milestone.id}), 200
6741
  except Exception as e:
6742
  db.session.rollback()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6743
  return jsonify({'error': str(e)}), 500
6744
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6745
  @main_bp.route('/admin/webtoons/milestones/result/<int:milestone_id>')
6746
  @admin_required
6747
  def admin_webtoon_milestone_result(milestone_id):
6748
  ms = WebtoonMilestone.query.get_or_404(milestone_id)
 
 
 
 
 
6749
  # 좌측 프로젝트 목록: 마일스톤이 생성된 프로젝트만 노출
6750
  files = (
6751
  UploadedFile.query
@@ -6755,7 +6946,6 @@ def admin_webtoon_milestone_result(milestone_id):
6755
  .order_by(UploadedFile.uploaded_at.desc())
6756
  .all()
6757
  )
6758
- schedule_data = json.loads(ms.schedule_json) if ms.schedule_json else {}
6759
  return render_template('admin_webtoon_milestone_detail.html', milestone=ms, files=files, schedule=schedule_data)
6760
 
6761
 
@@ -6769,9 +6959,23 @@ def admin_webtoon_milestone_latest_result():
6769
  latest = WebtoonMilestone.query.order_by(WebtoonMilestone.created_at.desc()).first()
6770
  if not latest:
6771
  return redirect(url_for('main.admin_webtoon_milestones'))
 
6772
  return redirect(url_for('main.admin_webtoon_milestone_result', milestone_id=latest.id))
6773
 
6774
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6775
  @main_bp.route('/admin/webtoons/milestones/result/<int:milestone_id>/export-xlsx')
6776
  @admin_required
6777
  def admin_webtoon_milestone_export_xlsx(milestone_id):
@@ -6892,15 +7096,7 @@ def admin_webtoon_milestone_export_xlsx(milestone_id):
6892
  for i, w in enumerate(widths, start=1):
6893
  ws2.column_dimensions[openpyxl.utils.get_column_letter(i)].width = w
6894
 
6895
- # 3) 간트 시트 (옵션 A: 셀 배경색으간트 형태 생성)
6896
- ws3 = wb.create_sheet("간트")
6897
- ws3.freeze_panes = "C2"
6898
-
6899
- gantt_header_fill = PatternFill("solid", fgColor="EEF2FF")
6900
- gantt_group_fill = PatternFill("solid", fgColor="D2E3FC") # 회차(상위)
6901
- gantt_stage_fill = PatternFill("solid", fgColor="BFC5CC") # 작업 단계(하위)
6902
-
6903
- # 간트 데이터 구성: 상위(회차) + 하위(작업 단계)
6904
  gantt_rows = []
6905
  for ep in (schedule_data.get("episodes") or []):
6906
  ep_num = ep.get("episode_num")
@@ -6929,79 +7125,95 @@ def admin_webtoon_milestone_export_xlsx(milestone_id):
6929
  "is_group": False,
6930
  })
6931
 
6932
- # 날짜 범위 계산
6933
  if gantt_rows:
6934
  min_d = min(r["start"] for r in gantt_rows)
6935
  max_d = max(r["end"] for r in gantt_rows)
6936
  else:
6937
  min_d, max_d = None, None
6938
 
6939
- def _fmt_md(d):
6940
- return f"{d.month}/{d.day}"
6941
-
6942
- # 헤더 작성
6943
- ws3.cell(row=1, column=1, value="회차").font = header_font
6944
- ws3.cell(row=1, column=2, value="작업 단계").font = header_font
6945
- ws3.cell(row=1, column=1).fill = gantt_header_fill
6946
- ws3.cell(row=1, column=2).fill = gantt_header_fill
6947
- ws3.cell(row=1, column=1).alignment = Alignment(horizontal="center", vertical="center")
6948
- ws3.cell(row=1, column=2).alignment = Alignment(horizontal="center", vertical="center")
6949
- ws3.column_dimensions["A"].width = 20
6950
- ws3.column_dimensions["B"].width = 28
6951
-
6952
- # 기간이 길면 주 단위로 자동 전환(컬럼 폭 폭발 방지)
6953
- timeline = []
6954
- mode = "day"
6955
- if min_d and max_d:
6956
- total_days = (max_d - min_d).days + 1
6957
- if total_days > 120:
6958
- mode = "week"
6959
- # 해당 주의 월요일로 정렬
6960
- start_week = min_d - timedelta(days=min_d.weekday())
6961
- end_week = max_d - timedelta(days=max_d.weekday())
6962
- cur = start_week
6963
- while cur <= end_week:
6964
- timeline.append(cur)
6965
- cur += timedelta(days=7)
6966
- else:
6967
- cur = min_d
6968
- while cur <= max_d:
6969
- timeline.append(cur)
6970
- cur += timedelta(days=1)
6971
-
6972
- # 타임라인 컬럼 생성
6973
- base_col = 3
6974
- for i, d in enumerate(timeline):
6975
- c = base_col + i
6976
- ws3.cell(row=1, column=c, value=_fmt_md(d)).font = header_font
6977
- ws3.cell(row=1, column=c).fill = gantt_header_fill
6978
- ws3.cell(row=1, column=c).alignment = Alignment(horizontal="center", vertical="center", text_rotation=90)
6979
- ws3.column_dimensions[openpyxl.utils.get_column_letter(c)].width = 3
6980
-
6981
- # 간트 바 채우기
6982
- r_idx = 2
6983
- for gr in gantt_rows:
6984
- ws3.cell(row=r_idx, column=1, value=gr["episode"])
6985
- ws3.cell(row=r_idx, column=2, value=gr["stage"])
6986
- # 상위 행은 굵게
6987
- if gr["is_group"]:
6988
- ws3.cell(row=r_idx, column=1).font = Font(bold=True)
6989
- fill = gantt_group_fill if gr["is_group"] else gantt_stage_fill
6990
-
6991
- if timeline:
6992
- for i, td in enumerate(timeline):
6993
- col = base_col + i
6994
- if mode == "day":
6995
- cell_date = td
6996
- if gr["start"] <= cell_date <= gr["end"]:
6997
- ws3.cell(row=r_idx, column=col).fill = fill
6998
- else:
6999
- # week: td는 week start (월요일), 범위는 7일
7000
- week_start = td
7001
- week_end = td + timedelta(days=6)
7002
- if not (gr["end"] < week_start or gr["start"] > week_end):
7003
- ws3.cell(row=r_idx, column=col).fill = fill
7004
- r_idx += 1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7005
 
7006
  # 파일명
7007
  base = getattr(getattr(ms, "file", None), "original_filename", "") or ""
 
24
  import uuid
25
  import re
26
  import json
27
+ from app.utils.date_utils import add_business_days, is_holiday, get_holidays_in_range
28
 
29
  main_bp = Blueprint('main', __name__)
30
 
 
84
  {"label": "마일스톤 기본 데이터 세팅", "endpoint": "main.admin_webtoon_milestone_settings", "roles": ["admin", "webtoon_pm"]},
85
  {"label": "작품별 마일스톤 세팅", "endpoint": "main.admin_webtoon_milestones", "roles": ["admin", "webtoon_pm"]},
86
  {"label": "마일스톤 확인", "endpoint": "main.admin_webtoon_milestone_latest_result", "roles": ["admin", "webtoon_pm"]},
87
+ {"label": "마일스톤 확인(휴일)", "endpoint": "main.admin_webtoon_milestone_latest_result_holidays", "roles": ["admin", "webtoon_pm"]},
88
  ]
89
  },
90
  {
 
543
  @wraps(f)
544
  @login_required
545
  def decorated_function(*args, **kwargs):
546
+ try:
547
+ # admin 또는 webtoon_pm 허용 (기본 관리자 페이지 접근)
548
+ # current_user 속성 접근 DB 에러가 발생할 수 있음 (스키마 불일치 등)
549
+ user_role = getattr(current_user, 'role', 'user')
550
+ if not (current_user.is_admin or user_role in ['admin', 'webtoon_pm']):
551
+ # API 요청인 경우 JSON 응답 반환
552
+ if request.path.startswith('/api/'):
553
+ return jsonify({'error': '관리자 권한이 필요합니다.'}), 403
554
+ flash('관리자 권한이 필요합니다.', 'error')
555
+ return redirect(url_for('main.index'))
556
+ return f(*args, **kwargs)
557
+ except Exception as e:
558
+ # 데코레이터 내부 에러 로깅
559
+ import traceback
560
+ err_trace = traceback.format_exc()
561
+ current_app.logger.error(f"[admin_required Error] {str(e)}\n{err_trace}")
562
  if request.path.startswith('/api/'):
563
+ return jsonify({'error': '권한 확인 중 오류 발생', 'details': str(e)}), 500
564
+ raise e
 
 
565
  return decorated_function
566
 
567
  # Ollama 기본 URL (환경 변수로 설정 가능)
 
6495
  """웹소설 파일에 대한 마일스톤 생성 (순차 진행 역산)"""
6496
  try:
6497
  data = request.json
6498
+ current_app.logger.info(f"[create_webtoon_milestone] Request data: {data}")
6499
+ current_app.logger.info("[DEBUG] New Logic Applied - Loop Removed")
6500
+
6501
+ def safe_int(v, default):
6502
+ try:
6503
+ if v is None or str(v).strip() == '': return default
6504
+ return int(v)
6505
+ except:
6506
+ return default
6507
+
6508
  file_id = data.get('file_id')
6509
  basis = data.get('basis', 'median')
6510
+ cadence_days = safe_int(data.get('cadence_days'), 7)
6511
+ ep0_days = safe_int(data.get('ep0_days'), 21)
6512
+ plan_days = safe_int(data.get('plan_days'), 7)
6513
  launch_date_s = data.get('launch_date')
6514
+ apply_holidays = bool(data.get('apply_holidays', False))
6515
 
6516
  if not file_id or not launch_date_s:
6517
  return jsonify({'error': '필수 파라미터가 누락되었습니다.'}), 400
6518
 
6519
  launch_date = datetime.strptime(launch_date_s, '%Y-%m-%d').date()
6520
 
6521
+ # --- 휴일 관련 유틸 ---
6522
+ # from app.utils.date_utils import add_business_days, is_holiday
6523
+
6524
+ def _add_days(start: datetime.date, d: int) -> datetime.date:
6525
+ """
6526
+ d일 '작업' 후의 날짜를 계산.
6527
+ apply_holidays=True 이면 업무일 기준 d일 후 (주말/휴일 건너뜀)
6528
+ apply_holidays=False 이면 단순 d일 후
6529
+ (단, d=duration이므로 1일 작업이면 같은 날짜가 되어야 하므로
6530
+ 실제로는 (d-1)일을 더하는 로직 등에서 사용됨)
6531
+ """
6532
+ if apply_holidays:
6533
+ # 업무일 기준 d일 '후'가 아니라, d일치 작업을 수행한 후의 날짜?
6534
+ # 보통 start_date 포함 d일 작업 -> start_date + (d-1)일 후
6535
+ return add_business_days(start, d)
6536
+ else:
6537
+ return start + timedelta(days=d)
6538
+
6539
+ def _sub_days(start: datetime.date, d: int) -> datetime.date:
6540
+ if apply_holidays:
6541
+ return add_business_days(start, -d)
6542
+ else:
6543
+ return start - timedelta(days=d)
6544
+
6545
  def _median_int(vals):
6546
  if not vals: return 0
6547
  vals = sorted(vals)
 
6688
 
6689
  d20 = episode_total_days.get(20, 0)
6690
  ep20_end = launch_date
6691
+
6692
+ # 20화 시작일 역산
6693
+ # ep20_start = ep20_end - (d20 - 1)
6694
+ if d20 > 0:
6695
+ ep20_start = _sub_days(ep20_end, d20 - 1)
6696
+ else:
6697
+ ep20_start = ep20_end
6698
+
6699
+ # 전체 시작일(start_date) 역산 (1~19화 간격 포함)
6700
+ # start_date = ep20_start - (19 * cadence_days)
6701
+ # cadence도 휴일 적용? 보통 착수 간격은 달력일 기준이 많으나, 여기선 '휴일 반영' 요청이므로 업무일로 처리할 수도 있음.
6702
+ # 하지만 착수 간격(Cadence)은 보통 '매주 금요일' 처럼 캘린더 기준이 강함.
6703
+ # 사용자 요청 "주말 반영"이 작업 기간에 대한 것인지 일정 전체인지 모호하나, 작업일(Duration) 위주로 적용하는게 일반적.
6704
+ # Cadence는 달력일(timedelta)로 유지하고, 작업 종료일(Duration) 계산에만 휴일을 적용하는 것이 안전.
6705
+ # -> 라고 생각했으나 "베트남 휴무 일정 반영"이면 공장 가동일 기준일 수 있음.
6706
+ # 우선 Cadence는 달력일로 유지하되, 착수일이 휴일이면 미루는 정도?
6707
+ # 여기서는 단순화를 위해 Cadence는 달력일로 유지. (기존 로직 유지)
6708
  start_date = ep20_start - timedelta(days=(20 - 1) * cadence_days)
6709
+
6710
+ # 0화/기획 기간 역산
6711
+ if ep0_days > 0:
6712
+ # proj_start_date = start_date - ep0_days
6713
+ # ep0_days도 작업일이므로 _sub_days 사용
6714
+ proj_start_date = _sub_days(start_date, ep0_days) # 0화 기간만큼 전
6715
+ # 다만 바로 이어지므로 하루 더 빼야 하나?
6716
+ # 아니, start_date가 1화 시작일. 0화 종료일 = start_date - 1일?
6717
+ # 보통 0화 끝난 다음날 1화 시작.
6718
+ # 0화 종료 = start_date - 1 (달력일? 업무일?)
6719
+ # 복잡성을 피하기 위해: 0화 종료일 = start_date - 1 (달력) -> 그로부터 ep0_days 전 (업무)
6720
+ # 그냥 start_date 기준으로 ep0_days 업무일 전을 0화 시작일로 잡고 계산
6721
+ pass
6722
+ else:
6723
+ proj_start_date = start_date
6724
 
6725
  episodes = []
6726
  if ep0_days > 0:
6727
+ # 0화 날짜 계산 (정방향)
6728
+ # 0화 시작일이 proj_start_date라고 가정하면...
6729
+ # 역산이 꼬일 수 있으므로, 1화 시작일(start_date) 기준으로 0화 종료일을 맞춤
6730
+ # 0화 종료일 = start_date 하루 전 (달력일)
6731
+ ep0_end = start_date - timedelta(days=1)
6732
+ # 0화 시작일 = 0화 종료일 - (ep0_days - 1) (업무일)
6733
+ ep0_start = _sub_days(ep0_end, ep0_days - 1)
6734
+
6735
  episodes.append({
6736
  'episode_num': 0,
6737
  'start_date': ep0_start.isoformat(),
 
6746
  stages_out = []
6747
 
6748
  if basis == 'settings':
6749
+ # 날짜 기반 시뮬레이션
6750
+ real_starts = [] # index -> date
6751
+ real_ends = [] # index -> date
6752
+
6753
  for i, stage in enumerate(tpl):
6754
  dur = stage['days']
6755
  s_key = stage['stage_key']
6756
 
6757
+ # 1. 순차 시작일 계산 (이전 단계 종료 다음 날)
6758
+ # 이전 단계가 없으면 ep_start
6759
+ if i == 0:
6760
+ base_start = ep_start
6761
+ else:
6762
+ base_start = real_ends[i-1] + timedelta(days=1) # 달력일 다음날
6763
+
6764
+ # 2. Overlap 적용
6765
+ # settings_overlaps[s_key] -> {a: prev_key, overlap_days: N}
6766
+ # 앞당겨질 날짜 계산
6767
+ target_start = base_start
6768
  if i > 0:
6769
  prev_key = tpl[i-1]['stage_key']
6770
  ov_info = settings_overlaps.get(str(s_key))
6771
  if ov_info and str(ov_info.get('a')) == str(prev_key):
6772
  ov_days = float(ov_info.get('overlap_days') or 0)
6773
+ # overlap_days만큼 "이전 단계 종료일"에서 앞으로 당김
6774
+ # overlap업무일 기준? 달력기준? -> 보통 달력일 (기간 겹침)
6775
+ # 하지만 여기선 "작업일수" 기준이므
6776
+ # (이전종료 - ov_days) 날짜를 구해야 함.
6777
+ # 단순하게 달력일로 뺌.
6778
+ prev_end_date = real_ends[i-1]
6779
+ target_start = prev_end_date - timedelta(days=int(ov_days)) + timedelta(days=1)
 
6780
 
6781
+ # 3. 최종 시작일 결정
6782
+ # 순차 시작일(base_start)보다 target_start가 빠르면 당겨짐.
6783
+ # 단, 이전 단계 시작일(real_starts[i-1])보다는 같거나 뒤여야 함.
6784
+ final_start = target_start
6785
+ if i > 0:
6786
+ # 논리적 하한선: 이전 단계 시작일
6787
+ if final_start < real_starts[i-1]:
6788
+ final_start = real_starts[i-1]
6789
+
6790
+ # 시작일이 휴일이면 다음 업무일로 밀어야 함?
6791
+ # "작업 시작"은 업무일에 하는 게 맞음.
6792
+ if apply_holidays:
6793
+ while is_holiday(final_start):
6794
+ final_start += timedelta(days=1)
6795
+
6796
+ # 4. 종료일 계산 (Duration 반영)
6797
+ # final_start 포함 dur일 소요
6798
+ final_end = _add_days(final_start, dur - 1)
6799
+
6800
+ real_starts.append(final_start)
6801
+ real_ends.append(final_end)
6802
 
6803
  stages_out.append({
6804
  'stage_key': s_key,
6805
  'duration_days': dur,
6806
+ 'start_date': final_start.isoformat(),
6807
+ 'end_date': final_end.isoformat()
6808
  })
6809
 
6810
  else:
6811
+ # basis != settings (기존 로직 + 휴일 적용)
6812
  cur = ep_start
6813
+ # 시작일이 휴일이면 미룸
6814
+ if apply_holidays:
6815
+ while is_holiday(cur):
6816
+ cur += timedelta(days=1)
6817
+
6818
  for t in tpl:
6819
  d = t['days']
6820
+ # cur부터 d 작업
6821
+ en = _add_days(cur, d - 1)
6822
+
6823
  stages_out.append({
6824
  'stage_key': t['stage_key'],
6825
  'duration_days': d,
6826
  'start_date': cur.isoformat(),
6827
  'end_date': en.isoformat()
6828
  })
6829
+ # 다음 단계는 종료일 다음날(달력)부터? 아니면 다음 업무일부터?
6830
+ # 보통 종료 다음날부터 시작. 그게 휴일이면 미룸.
6831
  cur = en + timedelta(days=1)
6832
+ if apply_holidays:
6833
+ while is_holiday(cur):
6834
+ cur += timedelta(days=1)
6835
 
6836
  if stages_out:
6837
  ep_end = datetime.strptime(stages_out[-1]['end_date'], '%Y-%m-%d').date()
 
6857
  launch_date=launch_date,
6858
  schedule_json=json.dumps(result, ensure_ascii=False)
6859
  )
6860
+ # apply_holidays 정보도 저장하고 싶지만 모델 스키마 변경 필요.
6861
+ # 일단 schedule_json 안에 포함시키는 것으로 충분. (result 객체 안에 들어감)
6862
+ if apply_holidays:
6863
+ # result 딕셔너리에 추가
6864
+ result['apply_holidays'] = True
6865
+ milestone.schedule_json = json.dumps(result, ensure_ascii=False)
6866
+
6867
  db.session.add(milestone)
6868
  db.session.commit()
6869
+ return jsonify({'message': '마일스톤 생성 완료', 'id': milestone.id, 'apply_holidays': apply_holidays}), 200
6870
  except Exception as e:
6871
  db.session.rollback()
6872
+ import traceback
6873
+ err_trace = traceback.format_exc()
6874
+ current_app.logger.error(f"[create_webtoon_milestone Error] {str(e)}\n{err_trace}")
6875
+ return jsonify({'error': str(e), 'trace': err_trace}), 500
6876
+
6877
+ @main_bp.route('/api/webtoons/milestones/<int:milestone_id>/holidays')
6878
+ @admin_required
6879
+ def get_webtoon_milestone_holidays(milestone_id):
6880
+ """마일스톤 기간 내의 휴일 목록 반환 (JSON)"""
6881
+ ms = WebtoonMilestone.query.get_or_404(milestone_id)
6882
+ try:
6883
+ data = json.loads(ms.schedule_json)
6884
+ episodes = data.get('episodes', [])
6885
+ if not episodes:
6886
+ return jsonify({'holidays': []})
6887
+
6888
+ # 전체 기간 파악
6889
+ # (생성 로직상 에피소드들이 정렬되어 있다고 가정)
6890
+ # 하지만 0화, 1화 등 순서가 다를 수 있음. min/max 찾기.
6891
+ all_dates = []
6892
+ for ep in episodes:
6893
+ if ep.get('start_date'): all_dates.append(ep['start_date'][:10])
6894
+ if ep.get('end_date'): all_dates.append(ep['end_date'][:10])
6895
+
6896
+ if not all_dates:
6897
+ return jsonify({'holidays': []})
6898
+
6899
+ min_s = min(all_dates)
6900
+ max_s = max(all_dates)
6901
+
6902
+ from app.utils.date_utils import get_holidays_in_range
6903
+ from datetime import datetime
6904
+
6905
+ d1 = datetime.strptime(min_s, '%Y-%m-%d').date()
6906
+ d2 = datetime.strptime(max_s, '%Y-%m-%d').date()
6907
+
6908
+ holidays_list = get_holidays_in_range(d1, d2)
6909
+ # ISO 문자열로 변환
6910
+ return jsonify({'holidays': [d.isoformat() for d in holidays_list]})
6911
+ except Exception as e:
6912
  return jsonify({'error': str(e)}), 500
6913
 
6914
+ @main_bp.route('/admin/webtoons/milestones/result-holidays/<int:milestone_id>')
6915
+ @admin_required
6916
+ def admin_webtoon_milestone_result_holidays(milestone_id):
6917
+ """휴일 적용 마일스톤 상세 결과 (휴일 표시 기능 추가)"""
6918
+ ms = WebtoonMilestone.query.get_or_404(milestone_id)
6919
+ files = (
6920
+ UploadedFile.query
6921
+ .join(WebtoonMilestone, UploadedFile.id == WebtoonMilestone.file_id)
6922
+ .filter(UploadedFile.is_public == True, UploadedFile.parent_file_id == None)
6923
+ .distinct()
6924
+ .order_by(UploadedFile.uploaded_at.desc())
6925
+ .all()
6926
+ )
6927
+ schedule_data = json.loads(ms.schedule_json) if ms.schedule_json else {}
6928
+ return render_template('admin_webtoon_milestone_detail_holidays.html', milestone=ms, files=files, schedule=schedule_data)
6929
+
6930
+
6931
  @main_bp.route('/admin/webtoons/milestones/result/<int:milestone_id>')
6932
  @admin_required
6933
  def admin_webtoon_milestone_result(milestone_id):
6934
  ms = WebtoonMilestone.query.get_or_404(milestone_id)
6935
+
6936
+ # schedule_json에서 apply_holidays 여부 확인
6937
+ schedule_data = json.loads(ms.schedule_json) if ms.schedule_json else {}
6938
+ # 리다이렉트 로직 제거: apply_holidays여도 사용자가 원하면 기본 뷰로 볼 수 있게 함
6939
+
6940
  # 좌측 프로젝트 목록: 마일스톤이 생성된 프로젝트만 노출
6941
  files = (
6942
  UploadedFile.query
 
6946
  .order_by(UploadedFile.uploaded_at.desc())
6947
  .all()
6948
  )
 
6949
  return render_template('admin_webtoon_milestone_detail.html', milestone=ms, files=files, schedule=schedule_data)
6950
 
6951
 
 
6959
  latest = WebtoonMilestone.query.order_by(WebtoonMilestone.created_at.desc()).first()
6960
  if not latest:
6961
  return redirect(url_for('main.admin_webtoon_milestones'))
6962
+
6963
  return redirect(url_for('main.admin_webtoon_milestone_result', milestone_id=latest.id))
6964
 
6965
 
6966
+ @main_bp.route('/admin/webtoons/milestones/result-holidays/latest')
6967
+ @admin_required
6968
+ def admin_webtoon_milestone_latest_result_holidays():
6969
+ """
6970
+ 메뉴 진입용: 최신 마일스톤 결과(휴일 뷰)로 이동.
6971
+ """
6972
+ latest = WebtoonMilestone.query.order_by(WebtoonMilestone.created_at.desc()).first()
6973
+ if not latest:
6974
+ return redirect(url_for('main.admin_webtoon_milestones'))
6975
+
6976
+ return redirect(url_for('main.admin_webtoon_milestone_result_holidays', milestone_id=latest.id))
6977
+
6978
+
6979
  @main_bp.route('/admin/webtoons/milestones/result/<int:milestone_id>/export-xlsx')
6980
  @admin_required
6981
  def admin_webtoon_milestone_export_xlsx(milestone_id):
 
7096
  for i, w in enumerate(widths, start=1):
7097
  ws2.column_dimensions[openpyxl.utils.get_column_letter(i)].width = w
7098
 
7099
+ # 3) 간트 시트 (함수분리)
 
 
 
 
 
 
 
 
7100
  gantt_rows = []
7101
  for ep in (schedule_data.get("episodes") or []):
7102
  ep_num = ep.get("episode_num")
 
7125
  "is_group": False,
7126
  })
7127
 
 
7128
  if gantt_rows:
7129
  min_d = min(r["start"] for r in gantt_rows)
7130
  max_d = max(r["end"] for r in gantt_rows)
7131
  else:
7132
  min_d, max_d = None, None
7133
 
7134
+ def _create_gantt_sheet(sheet_name, view_mode):
7135
+ """
7136
+ view_mode: 'day' or 'week'
7137
+ """
7138
+ ws = wb.create_sheet(sheet_name)
7139
+ ws.freeze_panes = "C2"
7140
+
7141
+ gantt_header_fill = PatternFill("solid", fgColor="EEF2FF")
7142
+ gantt_group_fill = PatternFill("solid", fgColor="D2E3FC")
7143
+ gantt_stage_fill = PatternFill("solid", fgColor="BFC5CC")
7144
+
7145
+ def _fmt_md(d):
7146
+ return f"{d.month}/{d.day}"
7147
+
7148
+ # 헤더
7149
+ ws.cell(row=1, column=1, value="회차").font = header_font
7150
+ ws.cell(row=1, column=2, value="작업 단계").font = header_font
7151
+ ws.cell(row=1, column=1).fill = gantt_header_fill
7152
+ ws.cell(row=1, column=2).fill = gantt_header_fill
7153
+ ws.cell(row=1, column=1).alignment = Alignment(horizontal="center", vertical="center")
7154
+ ws.cell(row=1, column=2).alignment = Alignment(horizontal="center", vertical="center")
7155
+ ws.column_dimensions["A"].width = 20
7156
+ ws.column_dimensions["B"].width = 28
7157
+
7158
+ # 타임라인
7159
+ timeline = []
7160
+ if min_d and max_d:
7161
+ if view_mode == "week":
7162
+ # 해당 주의 월요일로 정렬
7163
+ start_week = min_d - timedelta(days=min_d.weekday())
7164
+ end_week = max_d - timedelta(days=max_d.weekday())
7165
+ cur = start_week
7166
+ while cur <= end_week:
7167
+ timeline.append(cur)
7168
+ cur += timedelta(days=7)
7169
+ else:
7170
+ # day
7171
+ cur = min_d
7172
+ while cur <= max_d:
7173
+ timeline.append(cur)
7174
+ cur += timedelta(days=1)
7175
+
7176
+ # 타임라인 컬럼
7177
+ base_col = 3
7178
+ for i, d in enumerate(timeline):
7179
+ c = base_col + i
7180
+ ws.cell(row=1, column=c, value=_fmt_md(d)).font = header_font
7181
+ ws.cell(row=1, column=c).fill = gantt_header_fill
7182
+ ws.cell(row=1, column=c).alignment = Alignment(horizontal="center", vertical="center", text_rotation=90)
7183
+ ws.column_dimensions[openpyxl.utils.get_column_letter(c)].width = 3
7184
+
7185
+ # 데이터 바
7186
+ r_idx = 2
7187
+ for gr in gantt_rows:
7188
+ ws.cell(row=r_idx, column=1, value=gr["episode"])
7189
+ ws.cell(row=r_idx, column=2, value=gr["stage"])
7190
+ if gr["is_group"]:
7191
+ ws.cell(row=r_idx, column=1).font = Font(bold=True)
7192
+
7193
+ fill = gantt_group_fill if gr["is_group"] else gantt_stage_fill
7194
+
7195
+ if timeline:
7196
+ for i, td in enumerate(timeline):
7197
+ col = base_col + i
7198
+ if view_mode == "day":
7199
+ cell_date = td
7200
+ if gr["start"] <= cell_date <= gr["end"]:
7201
+ ws.cell(row=r_idx, column=col).fill = fill
7202
+ else:
7203
+ # week: td는 week start (월요일), 범위는 7일
7204
+ week_start = td
7205
+ week_end = td + timedelta(days=6)
7206
+ if not (gr["end"] < week_start or gr["start"] > week_end):
7207
+ ws.cell(row=r_idx, column=col).fill = fill
7208
+ r_idx += 1
7209
+
7210
+ # 두 가지 모드로 각각 생성 (요청사항: 주간과 함께 일 자료도 같이)
7211
+ # 1) 간트(일)
7212
+ # 데이터 양이 많으면 엑셀 컬럼 한계(16384)가 있으나, 마일스톤이 수십년이 아닌 이상 보통 OK.
7213
+ _create_gantt_sheet("간트(일)", "day")
7214
+
7215
+ # 2) 간트(주)
7216
+ _create_gantt_sheet("간트(주)", "week")
7217
 
7218
  # 파일명
7219
  base = getattr(getattr(ms, "file", None), "original_filename", "") or ""
app/utils/date_utils.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import holidays
2
+ from datetime import date, timedelta
3
+
4
+ # 베트남 휴일 객체 생성 (캐싱)
5
+ try:
6
+ vn_holidays = holidays.VN()
7
+ except Exception as e:
8
+ print(f"[date_utils] 휴일 정보 로드 실패: {e}")
9
+ vn_holidays = {}
10
+
11
+ def is_holiday(d: date) -> bool:
12
+ """
13
+ 해당 날짜가 주말(토,일)이거나 베트남 공휴일인지 확인
14
+ """
15
+ # 주말 체크 (5: 토요일, 6: 일요일)
16
+ if d.weekday() >= 5:
17
+ return True
18
+
19
+ # 공휴일 체크
20
+ if d in vn_holidays:
21
+ return True
22
+
23
+ return False
24
+
25
+ def add_business_days(start_date: date, days: int) -> date:
26
+ """
27
+ start_date로부터 days만큼의 업무일(Business Days)을 더하거나 뺀 날짜를 반환
28
+ - days > 0: 미래로 이동
29
+ - days < 0: 과거로 이동
30
+ - days == 0: start_date가 휴일이면 다음 업무일(미래 방향) 반환, 아니면 그대로 반환
31
+ """
32
+ current_date = start_date
33
+
34
+ # 0일인 경우: 시작일이 휴일이면 평일이 나올 때까지 전진
35
+ if days == 0:
36
+ while is_holiday(current_date):
37
+ current_date += timedelta(days=1)
38
+ return current_date
39
+
40
+ # 이동 방향 설정
41
+ step = 1 if days > 0 else -1
42
+ days_left = abs(days)
43
+
44
+ while days_left > 0:
45
+ current_date += timedelta(days=step)
46
+ # 휴일이면 카운트하지 않음 (건너뜀)
47
+ if not is_holiday(current_date):
48
+ days_left -= 1
49
+
50
+ return current_date
51
+
52
+ def get_holidays_in_range(start_date: date, end_date: date) -> list:
53
+ """
54
+ start_date ~ end_date (inclusive) 기간 내의 휴일 목록(date 객체) 반환
55
+ """
56
+ holidays_list = []
57
+ # 날짜 순서 보정
58
+ if start_date > end_date:
59
+ start_date, end_date = end_date, start_date
60
+
61
+ curr = start_date
62
+ while curr <= end_date:
63
+ if is_holiday(curr):
64
+ holidays_list.append(curr)
65
+ curr += timedelta(days=1)
66
+ return holidays_list
check_milestone_table.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ import os
3
+ # Add project root to path
4
+ sys.path.append(os.getcwd())
5
+
6
+ from sqlalchemy import create_engine, inspect
7
+ try:
8
+ from app.core.config import Config
9
+ except ImportError:
10
+ # Fallback if app module not found or dependencies missing
11
+ print("Could not import app.core.config. Using direct path.")
12
+ Config = None
13
+
14
+ if Config:
15
+ db_uri = Config.SQLALCHEMY_DATABASE_URI
16
+ else:
17
+ # Direct guess
18
+ db_uri = f'sqlite:///{os.path.join(os.getcwd(), "instance", "finance_analysis.db")}'
19
+
20
+ print(f"Checking DB: {db_uri}")
21
+
22
+ try:
23
+ engine = create_engine(db_uri)
24
+ insp = inspect(engine)
25
+ if insp.has_table('webtoon_milestone'):
26
+ print("Table 'webtoon_milestone' exists.")
27
+ cols = [c['name'] for c in insp.get_columns('webtoon_milestone')]
28
+ print(f"Columns: {cols}")
29
+ else:
30
+ print("Table 'webtoon_milestone' NOT found.")
31
+ except Exception as e:
32
+ print(f"Error: {e}")
33
+
migrate_fix_user_schema.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sqlite3
2
+ import os
3
+ from pathlib import Path
4
+
5
+ def migrate_db(db_path):
6
+ print(f"Checking {db_path}...")
7
+ try:
8
+ conn = sqlite3.connect(db_path)
9
+ cursor = conn.cursor()
10
+
11
+ # Check if user table exists
12
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='user'")
13
+ if not cursor.fetchone():
14
+ print(" - 'user' table not found.")
15
+ conn.close()
16
+ return
17
+
18
+ # Check columns
19
+ cursor.execute("PRAGMA table_info(user)")
20
+ columns = [row[1] for row in cursor.fetchall()]
21
+ print(f" - Current columns: {columns}")
22
+
23
+ # Add role column
24
+ if 'role' not in columns:
25
+ print(" - Adding 'role' column...")
26
+ try:
27
+ cursor.execute("ALTER TABLE user ADD COLUMN role VARCHAR(20) DEFAULT 'user' NOT NULL")
28
+ conn.commit()
29
+ print(" - 'role' added.")
30
+ except Exception as e:
31
+ print(f" - Failed to add 'role': {e}")
32
+ else:
33
+ print(" - 'role' column already exists.")
34
+
35
+ # Add must_change_password column
36
+ if 'must_change_password' not in columns:
37
+ print(" - Adding 'must_change_password' column...")
38
+ try:
39
+ cursor.execute("ALTER TABLE user ADD COLUMN must_change_password BOOLEAN DEFAULT 0 NOT NULL")
40
+ conn.commit()
41
+ print(" - 'must_change_password' added.")
42
+ except Exception as e:
43
+ print(f" - Failed to add 'must_change_password': {e}")
44
+ else:
45
+ print(" - 'must_change_password' column already exists.")
46
+
47
+ conn.close()
48
+ except Exception as e:
49
+ print(f" - Error: {e}")
50
+
51
+ if __name__ == "__main__":
52
+ base_dir = Path("instance")
53
+ if not base_dir.exists():
54
+ print("instance directory not found.")
55
+ else:
56
+ for f in base_dir.glob("*.db"):
57
+ migrate_db(str(f))
58
+
requirements.txt CHANGED
@@ -19,4 +19,5 @@ openpyxl==3.1.5
19
  psycopg2-binary # PostgreSQL support for external database
20
  # Ollama와 파이썬을 연결하려면 아래 패키지가 보통 필요합니다.
21
  ollama
 
22
 
 
19
  psycopg2-binary # PostgreSQL support for external database
20
  # Ollama와 파이썬을 연결하려면 아래 패키지가 보통 필요합니다.
21
  ollama
22
+ holidays
23
 
reset_admin_menu.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app import create_app, db
2
+ from app.models.models import SystemConfig
3
+
4
+ app = create_app()
5
+
6
+ with app.app_context():
7
+ ADMIN_MENU_CONFIG_KEY = "admin_menu_config_v1"
8
+ config = SystemConfig.query.filter_by(key=ADMIN_MENU_CONFIG_KEY).first()
9
+ if config:
10
+ db.session.delete(config)
11
+ db.session.commit()
12
+ print(f"[{ADMIN_MENU_CONFIG_KEY}] 메뉴 설정이 삭제되었습니다. 기본 메뉴가 다시 로드됩니다.")
13
+ else:
14
+ print(f"[{ADMIN_MENU_CONFIG_KEY}] 저장된 메뉴 설정이 없습니다.")
15
+
templates/admin_webtoon_milestone_detail_holidays.html ADDED
@@ -0,0 +1,515 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>마일스톤 상세 (휴일 반영) - SOY NV AI</title>
7
+ <!-- Fonts & Icons -->
8
+ <link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
10
+ <!-- Frappe Gantt -->
11
+ <script src="https://cdn.jsdelivr.net/npm/frappe-gantt@0.6.1/dist/frappe-gantt.min.js"></script>
12
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/frappe-gantt@0.6.1/dist/frappe-gantt.css">
13
+
14
+ <style>
15
+ /* 기본 스타일 및 초기화 */
16
+ :root{
17
+ --bg:#f6f7fb;--card:#fff;--text:#202124;--muted:#5f6368;--border:#e5e7eb;--border-strong:#dadce0;
18
+ --primary:#1a73e8;--primary-hover:#1557b0;
19
+ --danger:#c5221f;
20
+
21
+ /* 차트 레이아웃 상수 */
22
+ --header-height: 60px;
23
+ --row-height: 40px;
24
+ --side-top-offset: 0px;
25
+ }
26
+ *{margin:0;padding:0;box-sizing:border-box}
27
+ body{
28
+ font-family:'Inter', -apple-system, BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
29
+ background: var(--bg);
30
+ color:var(--text);
31
+ overflow-x:hidden;
32
+ height: 100vh;
33
+ display: flex;
34
+ flex-direction: column;
35
+ }
36
+
37
+ .layout-container { display: flex; flex: 1; overflow: hidden; }
38
+ .sidebar { width: 260px; background: #fff; border-right: 1px solid var(--border); display: flex; flex-direction: column; z-index: 50; }
39
+ .sidebar-header { padding: 16px 24px; font-weight: 600; font-size: 14px; color: var(--muted); border-bottom: 1px solid var(--border); }
40
+ .sidebar-content { flex: 1; overflow-y: auto; padding: 12px 0; }
41
+
42
+ .main-content { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
43
+ .content-scroll { flex: 1; overflow-y: auto; padding: 24px; }
44
+
45
+ .sidebar-item { padding: 10px 24px; cursor: pointer; display: flex; align-items: center; justify-content: space-between; text-decoration: none; color: var(--text); font-size: 14px; }
46
+ .sidebar-item:hover { background: #f8f9fa; }
47
+ .sidebar-item.active { background: #e8f0fe; color: var(--primary); font-weight: 600; }
48
+
49
+ .card { background: var(--card); border: 1px solid var(--border); border-radius: 8px; margin-bottom: 24px; display: flex; flex-direction: column; }
50
+ .card-header { padding: 16px 24px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; }
51
+ .card-title { font-size: 16px; font-weight: 700; }
52
+
53
+ .btn { padding: 8px 14px; border: 1px solid var(--border); border-radius: 6px; font-size: 13px; font-weight: 600; cursor: pointer; background: #fff; color: var(--text); text-decoration: none; display: inline-block; text-align: center;}
54
+ .btn-primary { background: var(--primary); border-color: var(--primary); color: #fff; }
55
+ .btn.active { background: var(--primary); color: #fff; border-color: var(--primary); }
56
+
57
+ .gantt-wrapper {
58
+ display: flex;
59
+ border: 1px solid var(--border);
60
+ border-radius: 8px;
61
+ background: #fff;
62
+ height: 600px;
63
+ overflow: hidden;
64
+ position: relative;
65
+ }
66
+
67
+ .gantt-side {
68
+ width: 220px;
69
+ background: #fff;
70
+ border-right: 1px solid #e0e0e0;
71
+ display: flex;
72
+ flex-direction: column;
73
+ flex-shrink: 0;
74
+ z-index: 20;
75
+ }
76
+ .gantt-side-header {
77
+ height: var(--header-height);
78
+ border-bottom: 1px solid #e0e0e0;
79
+ background: #f8f9fa;
80
+ display: flex;
81
+ align-items: center;
82
+ justify-content: center;
83
+ font-weight: 600;
84
+ font-size: 13px;
85
+ color: #5f6368;
86
+ box-sizing: border-box;
87
+ flex-shrink: 0;
88
+ }
89
+ .gantt-side-list {
90
+ flex: 1;
91
+ overflow: hidden;
92
+ background: #fff;
93
+ padding-top: var(--side-top-offset);
94
+ }
95
+ .gantt-side-item {
96
+ height: var(--row-height);
97
+ line-height: var(--row-height);
98
+ padding: 0 16px;
99
+ font-size: 13px;
100
+ color: #374151;
101
+ white-space: nowrap;
102
+ overflow: hidden;
103
+ text-overflow: ellipsis;
104
+ border-bottom: 1px solid #f1f3f4;
105
+ box-sizing: border-box;
106
+ }
107
+ .gantt-side-item.group {
108
+ font-weight: 700;
109
+ background-color: #f8f9fa;
110
+ color: #1a73e8;
111
+ }
112
+ .gantt-side-item.holiday {
113
+ color: var(--danger);
114
+ font-style: italic;
115
+ background-color: #fff5f5;
116
+ }
117
+ .gantt-side-item:not(.group) {
118
+ padding-left: 32px;
119
+ }
120
+
121
+ .gantt-right-col {
122
+ flex: 1;
123
+ display: flex;
124
+ flex-direction: column;
125
+ overflow: hidden;
126
+ position: relative;
127
+ }
128
+
129
+ .gantt-chart-container {
130
+ flex: 1;
131
+ overflow: auto;
132
+ position: relative;
133
+ }
134
+
135
+ .gantt .grid-header { fill: #f8f9fa; }
136
+ .gantt .grid-row { fill: #fff; }
137
+ .gantt .grid-row:nth-child(even) { fill: #fbfbff; }
138
+ .gantt .grid-row.group-row { fill: #eef4ff !important; }
139
+ .gantt .bar-label { fill: #202124; font-size: 12px; font-weight: 600; }
140
+ .gantt .bar-wrapper.group .bar { fill: #d2e3fc; stroke: #8ab4f8; opacity: 1; }
141
+
142
+ /* 휴일 바 스타일 */
143
+ .gantt .bar-wrapper.holiday .bar { fill: #ffcdd2; stroke: #ef9a9a; stroke-dasharray: 4,2; }
144
+ .gantt .bar-wrapper.holiday .bar-label { fill: #c62828; font-style: italic; }
145
+
146
+ .info-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 24px; }
147
+ .info-item { background: #fff; padding: 16px; border-radius: 8px; border: 1px solid var(--border); }
148
+ .info-label { font-size: 12px; color: var(--muted); margin-bottom: 6px; }
149
+ .info-value { font-size: 15px; font-weight: 600; color: #202124; }
150
+
151
+ .holiday-badge {
152
+ display: inline-block;
153
+ padding: 2px 8px;
154
+ border-radius: 4px;
155
+ background: #ffebee;
156
+ color: #c62828;
157
+ font-size: 11px;
158
+ font-weight: 600;
159
+ margin-left: 8px;
160
+ border: 1px solid #ffcdd2;
161
+ }
162
+ </style>
163
+ </head>
164
+ <body>
165
+ {% set admin_nav_title = '프로젝트 관리' %}
166
+ {% include '_admin_nav.html' %}
167
+
168
+ <div class="layout-container">
169
+ <!-- 사이드바 -->
170
+ <div class="sidebar">
171
+ <div class="sidebar-header">프로젝트 목록</div>
172
+ <div class="sidebar-content">
173
+ {% for f in files %}
174
+ <a href="{% if f.milestones %}/admin/webtoons/milestones/result/{{ f.milestones[-1].id }}{% else %}#{% endif %}"
175
+ class="sidebar-item {% if f.id == milestone.file_id %}active{% endif %}"
176
+ {% if not f.milestones %}onclick="alert('생성된 마일스톤이 없습니다.'); return false;"{% endif %}>
177
+ <span>{{ f.original_filename }}</span>
178
+ {% if f.milestones %}<span style="font-size:10px; color:var(--primary);">●</span>{% endif %}
179
+ </a>
180
+ {% endfor %}
181
+ </div>
182
+ <div style="padding: 16px; border-top:1px solid var(--border);">
183
+ <a href="/admin/webtoons/milestones" class="btn btn-primary" style="display:block; width:100%;">새 마일스톤 생성</a>
184
+ </div>
185
+ </div>
186
+
187
+ <!-- 메인 컨텐츠 -->
188
+ <div class="main-content">
189
+ <div class="content-scroll">
190
+ <div style="margin-bottom:24px; display:flex; justify-content:space-between; align-items:flex-end;">
191
+ <div>
192
+ <h1 style="font-size:22px; font-weight:700; margin-bottom:8px;">
193
+ {{ milestone.file.original_filename }}
194
+ <span class="holiday-badge">베트남 휴일 반영됨</span>
195
+ </h1>
196
+ <div style="font-size:13px; color:var(--muted);">
197
+ 마일스톤 상세 ({{ milestone.created_at.strftime('%Y-%m-%d') }})
198
+ </div>
199
+ </div>
200
+ <div>
201
+ <a class="btn" href="/admin/webtoons/milestones/result/{{ milestone.id }}/export-xlsx">엑셀 저장</a>
202
+ </div>
203
+ </div>
204
+
205
+ <div class="info-grid">
206
+ <div class="info-item"><div class="info-label">입력 기준</div><div class="info-value">{{ milestone.basis }}</div></div>
207
+ <div class="info-item"><div class="info-label">회차 착수 간격</div><div class="info-value">{{ milestone.cadence_days }}일</div></div>
208
+ <div class="info-item"><div class="info-label">0화 / 기획 소요</div><div class="info-value">{{ milestone.ep0_days }}일 / {{ milestone.plan_days }}일</div></div>
209
+ <div class="info-item"><div class="info-label">런칭 예정일</div><div class="info-value">{{ milestone.launch_date }}</div></div>
210
+ </div>
211
+
212
+ <div class="card" style="height: calc(100vh - 350px); min-height: 500px;">
213
+ <div class="card-header">
214
+ <div class="card-title">제작 공정표 (Gantt) - 휴일/주말 포함</div>
215
+ <div class="view-controls" style="display:flex; gap:8px; align-items:center;">
216
+ <button class="btn active" data-mode="Day" onclick="changeViewMode('Day')">Day</button>
217
+ <button class="btn" data-mode="Week" onclick="changeViewMode('Week')">Week</button>
218
+ <button class="btn" data-mode="Month" onclick="changeViewMode('Month')">Month</button>
219
+ </div>
220
+ </div>
221
+ <div class="gantt-wrapper">
222
+ <div class="gantt-side">
223
+ <div class="gantt-side-header">Task Name</div>
224
+ <div class="gantt-side-list" id="ganttSideList"></div>
225
+ </div>
226
+ <div class="gantt-right-col">
227
+ <div class="gantt-chart-container" id="ganttContainer">
228
+ <svg id="gantt"></svg>
229
+ </div>
230
+ </div>
231
+ </div>
232
+ </div>
233
+ </div>
234
+ </div>
235
+ </div>
236
+
237
+ <script id="scheduleDataJson" type="application/json">{{ schedule | tojson }}</script>
238
+ <script>
239
+ const scheduleDataEl = document.getElementById('scheduleDataJson');
240
+ const scheduleData = scheduleDataEl && scheduleDataEl.textContent
241
+ ? JSON.parse(scheduleDataEl.textContent)
242
+ : {};
243
+ let gantt = null;
244
+ let stageKeyMap = {};
245
+ let currentViewMode = 'Day';
246
+ let translationInterval = null;
247
+ let holidayDates = [];
248
+
249
+ const HEADER_HEIGHT = 60;
250
+ const BAR_HEIGHT = 25;
251
+ const PADDING = 15;
252
+
253
+ async function init() {
254
+ try {
255
+ // 1. 단계키 매핑 로드
256
+ const resKeys = await fetch('/api/webtoons/stage-keys');
257
+ const dataKeys = await resKeys.json();
258
+ if(dataKeys.mappings) {
259
+ dataKeys.mappings.forEach(m => { stageKeyMap[m.stage_key] = m.label; });
260
+ }
261
+
262
+ // 2. 휴일 목록 로드
263
+ const resHolidays = await fetch(`/api/webtoons/milestones/{{ milestone.id }}/holidays`);
264
+ const dataHolidays = await resHolidays.json();
265
+ holidayDates = dataHolidays.holidays || [];
266
+
267
+ } catch(e) {
268
+ console.error('Data load failed', e);
269
+ }
270
+ renderGanttChart();
271
+ }
272
+
273
+ function getStageName(key) {
274
+ if (key === '00') return '0화 분석 및 기획';
275
+ if (key === '01') return '01. 기획/각색';
276
+ return stageKeyMap[key] || key;
277
+ }
278
+
279
+ function renderGanttChart() {
280
+ if (!scheduleData || !scheduleData.episodes) return;
281
+
282
+ const tasks = [];
283
+ scheduleData.episodes.forEach(ep => {
284
+ const epId = `ep-${ep.episode_num}`;
285
+ const epName = ep.episode_num === 0 ? '0화 (Pre-production)' : `${ep.episode_num}화`;
286
+
287
+ tasks.push({
288
+ id: epId,
289
+ name: epName,
290
+ start: ep.start_date,
291
+ end: ep.end_date,
292
+ progress: 0,
293
+ custom_class: 'group',
294
+ level: 'group'
295
+ });
296
+
297
+ // 해당 회차 기간 내의 휴일 찾기
298
+ const epStart = new Date(ep.start_date);
299
+ const epEnd = new Date(ep.end_date);
300
+
301
+ // 휴일들을 연속된 블록으로 묶기
302
+ let currentHolidayBlock = null;
303
+
304
+ const epHolidays = holidayDates.filter(dStr => {
305
+ const d = new Date(dStr);
306
+ return d >= epStart && d <= epEnd;
307
+ }).sort();
308
+
309
+ epHolidays.forEach((hStr, idx) => {
310
+ const d = new Date(hStr);
311
+ if (!currentHolidayBlock) {
312
+ currentHolidayBlock = { start: hStr, end: hStr };
313
+ } else {
314
+ const prevEnd = new Date(currentHolidayBlock.end);
315
+ const diff = (d - prevEnd) / (1000 * 3600 * 24);
316
+ if (diff <= 1.1) { // 연속된 날짜 (또는 거의 연속)
317
+ currentHolidayBlock.end = hStr;
318
+ } else {
319
+ // 블록 종료 및 새 블록 시작
320
+ tasks.push({
321
+ id: `${epId}-h-${idx}`,
322
+ name: '휴일 (주말/공휴일)',
323
+ start: currentHolidayBlock.start,
324
+ end: currentHolidayBlock.end,
325
+ progress: 0,
326
+ custom_class: 'holiday',
327
+ level: 'holiday'
328
+ });
329
+ currentHolidayBlock = { start: hStr, end: hStr };
330
+ }
331
+ }
332
+ });
333
+ if (currentHolidayBlock) {
334
+ tasks.push({
335
+ id: `${epId}-h-last`,
336
+ name: '휴일 (주말/공휴일)',
337
+ start: currentHolidayBlock.start,
338
+ end: currentHolidayBlock.end,
339
+ progress: 0,
340
+ custom_class: 'holiday',
341
+ level: 'holiday'
342
+ });
343
+ }
344
+
345
+ if (ep.stages) {
346
+ ep.stages.forEach((st, idx) => {
347
+ tasks.push({
348
+ id: `${epId}-s-${idx}`,
349
+ name: getStageName(st.stage_key),
350
+ start: st.start_date,
351
+ end: st.end_date,
352
+ progress: 0,
353
+ custom_class: 'stage',
354
+ level: 'item'
355
+ });
356
+ });
357
+ }
358
+ });
359
+
360
+ // 정렬: group -> item -> holiday 순으로 보이면 좋겠지만 Frappe Gantt는 입력 순서 유지
361
+ // 회차 내에서 item -> holiday 순으로 나오게 함 (위에서 item을 뒤에 넣음)
362
+
363
+ gantt = new Gantt("#gantt", tasks, {
364
+ header_height: HEADER_HEIGHT,
365
+ column_width: currentViewMode === 'Day' ? 32 : 38,
366
+ step: 24,
367
+ view_modes: ['Quarter Day', 'Half Day', 'Day', 'Week', 'Month'],
368
+ bar_height: BAR_HEIGHT,
369
+ bar_corner_radius: 4,
370
+ arrow_curve: 5,
371
+ padding: PADDING,
372
+ view_mode: currentViewMode,
373
+ date_format: 'YYYY-MM-DD',
374
+ popup_trigger: 'click'
375
+ });
376
+
377
+ setupLayout();
378
+
379
+ const container = document.getElementById('ganttContainer');
380
+ const sideList = document.getElementById('ganttSideList');
381
+ container.onscroll = function() {
382
+ sideList.scrollTop = container.scrollTop;
383
+ };
384
+ }
385
+
386
+ function setupLayout() {
387
+ const svg = document.getElementById('gantt');
388
+ if(!svg) return;
389
+
390
+ svg.style.marginTop = '0px';
391
+ syncSideMetricsFromSvg(svg);
392
+ applyGroupRowBackground(svg);
393
+ expandGroupBarsToRow(svg);
394
+
395
+ const list = document.getElementById('ganttSideList');
396
+ list.innerHTML = '';
397
+
398
+ gantt.tasks.forEach(t => {
399
+ const div = document.createElement('div');
400
+ div.className = `gantt-side-item ${t.level === 'group' ? 'group' : ''} ${t.level === 'holiday' ? 'holiday' : ''}`;
401
+ div.textContent = t.name;
402
+ div.title = t.name;
403
+ list.appendChild(div);
404
+ });
405
+
406
+ startTranslationLoop();
407
+ }
408
+
409
+ function applyGroupRowBackground(svg) {
410
+ try {
411
+ const rows = svg.querySelectorAll('.grid-row');
412
+ if (!rows || !rows.length || !gantt || !gantt.tasks) return;
413
+ const n = Math.min(rows.length, gantt.tasks.length);
414
+ for (let i = 0; i < n; i++) {
415
+ const isGroup = gantt.tasks[i] && gantt.tasks[i].level === 'group';
416
+ rows[i].classList.toggle('group-row', !!isGroup);
417
+ }
418
+ } catch (e) {}
419
+ }
420
+
421
+ function expandGroupBarsToRow(svg) {
422
+ try {
423
+ const rows = svg.querySelectorAll('.grid-row');
424
+ if (!rows || !rows.length || !gantt || !gantt.tasks) return;
425
+ const n = Math.min(rows.length, gantt.tasks.length);
426
+
427
+ for (let i = 0; i < n; i++) {
428
+ const task = gantt.tasks[i];
429
+ if (!task || task.level !== 'group') continue;
430
+
431
+ const row = rows[i];
432
+ const rowY = parseFloat(row.getAttribute('y') || '0');
433
+ const rowH = parseFloat(row.getAttribute('height') || '0');
434
+ if (!rowH || Number.isNaN(rowY) || Number.isNaN(rowH)) continue;
435
+
436
+ const wrapper = svg.querySelector(`.bar-wrapper[data-id="${task.id}"]`);
437
+ if (!wrapper) continue;
438
+
439
+ const targetY = rowY + 1;
440
+ const targetH = Math.max(1, rowH - 2);
441
+
442
+ const bar = wrapper.querySelector('rect.bar');
443
+ if (bar) { bar.setAttribute('y', String(targetY)); bar.setAttribute('height', String(targetH)); }
444
+ const label = wrapper.querySelector('text.bar-label');
445
+ if (label) { label.setAttribute('dominant-baseline', 'middle'); label.setAttribute('y', String(rowY + rowH / 2)); }
446
+ }
447
+ } catch (e) {}
448
+ }
449
+
450
+ function syncSideMetricsFromSvg(svg) {
451
+ try {
452
+ const firstGridRow = svg.querySelector('.grid-row');
453
+ if (!firstGridRow) return;
454
+ const h = parseFloat(firstGridRow.getAttribute('height') || 0);
455
+ if (h) document.documentElement.style.setProperty('--row-height', `${h}px`);
456
+ const y = parseFloat(firstGridRow.getAttribute('y') || 0);
457
+ if (!Number.isNaN(y)) {
458
+ const offset = Math.max(0, y - HEADER_HEIGHT);
459
+ document.documentElement.style.setProperty('--side-top-offset', `${offset}px`);
460
+ }
461
+ } catch (e) {}
462
+ }
463
+
464
+ function startTranslationLoop() {
465
+ if (translationInterval) clearInterval(translationInterval);
466
+ const svg = document.getElementById('gantt');
467
+ translateHeader(svg);
468
+ let count = 0;
469
+ translationInterval = setInterval(() => {
470
+ translateHeader(document.getElementById('gantt'));
471
+ count++;
472
+ if(count > 20) clearInterval(translationInterval);
473
+ }, 200);
474
+ }
475
+
476
+ function translateHeader(targetSvg) {
477
+ if(!targetSvg) return;
478
+ const texts = targetSvg.querySelectorAll('.grid-header text, g.date text, .date text');
479
+ const monthMap = {
480
+ 'January': '1월', 'February': '2월', 'March': '3월', 'April': '4월', 'May': '5월', 'June': '6월',
481
+ 'July': '7월', 'August': '8월', 'September': '9월', 'October': '10월', 'November': '11월', 'December': '12월',
482
+ 'Jan': '1월', 'Feb': '2월', 'Mar': '3월', 'Apr': '4월', 'Jun': '6월', 'Jul': '7월', 'Aug': '8월', 'Sep': '9월', 'Oct': '10월', 'Nov': '11월', 'Dec': '12월'
483
+ };
484
+ texts.forEach(t => {
485
+ let txt = t.textContent.trim();
486
+ if(!txt) return;
487
+ txt = txt.replace(/^(\d{1,2})\s*([a-zA-Z]+)$/, (m, d, mon) => {
488
+ const monKey = Object.keys(monthMap).find(x => mon.toLowerCase().startsWith(x.slice(0,3).toLowerCase()));
489
+ return monKey ? `${monthMap[monKey]} ${parseInt(d, 10)}일` : m;
490
+ });
491
+ txt = txt.replace(/^([a-zA-Z]+)\s*(\d{4})$/, (m, mon, y) => {
492
+ const monKey = Object.keys(monthMap).find(x => mon.toLowerCase().startsWith(x.slice(0,3).toLowerCase()));
493
+ return monKey ? `${y}년 ${monthMap[monKey]}` : m;
494
+ });
495
+ Object.keys(monthMap).forEach(en => { if(txt === en) txt = monthMap[en]; });
496
+ txt = txt.replace(/^\d{1,2}$/, (m) => `${parseInt(m, 10)}일`);
497
+ if (t.textContent !== txt) t.textContent = txt;
498
+ });
499
+ }
500
+
501
+ function changeViewMode(mode) {
502
+ if(!gantt) return;
503
+ currentViewMode = mode;
504
+ gantt.change_view_mode(mode);
505
+ document.querySelectorAll('.view-controls .btn').forEach(b => b.classList.remove('active'));
506
+ const activeBtn = document.querySelector(`.view-controls .btn[data-mode="${mode}"]`);
507
+ if (activeBtn) activeBtn.classList.add('active');
508
+ setupLayout();
509
+ }
510
+
511
+ init();
512
+ </script>
513
+ </body>
514
+ </html>
515
+
templates/admin_webtoon_milestones.html CHANGED
@@ -47,28 +47,36 @@
47
  <table>
48
  <thead>
49
  <tr>
50
- <th style="width: 60px;">ID</th>
51
- <th>제목 (파일명)</th>
52
- <th style="width: 150px;">업로드 일시</th>
53
- <th style="width: 120px;">작업</th>
 
54
  </tr>
55
  </thead>
56
  <tbody>
57
  {% for file in files %}
58
  <tr>
59
- <td>{{ file.id }}</td>
60
  <td style="font-weight:500;">{{ file.original_filename }}</td>
61
- <td style="color:var(--muted);">{{ file.uploaded_at.strftime('%Y-%m-%d %H:%M') }}</td>
62
- <td>
 
 
 
 
63
  {% if file.milestones %}
64
- <button class="btn btn-secondary" onclick="location.href='/admin/webtoons/milestones/result/{{ file.milestones[-1].id }}'">확인</button>
 
 
 
 
65
  {% endif %}
66
- <button class="btn btn-primary" onclick="openCreateModal({{ file.id }}, '{{ file.original_filename }}')">생성</button>
67
  </td>
68
  </tr>
69
  {% else %}
70
  <tr>
71
- <td colspan="4">
72
  <div class="empty-state">
73
  공개 설정된 웹소설이 없습니다.<br>
74
  <small>웹소설 관리 메뉴에서 공개 여부를 확인해주세요.</small>
@@ -85,7 +93,7 @@
85
  <div id="createModal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.5); z-index:10000; align-items:center; justify-content:center;">
86
  <div style="background:#fff; width:90%; max-width:500px; border-radius:12px; padding:24px; box-shadow:0 10px 25px rgba(0,0,0,0.2);">
87
  <div style="display:flex; justify-content:space-between; margin-bottom:20px;">
88
- <h3 style="margin:0; font-size:18px;">마일스톤 생성</h3>
89
  <button onclick="closeModal()" style="background:none; border:none; font-size:20px; cursor:pointer;">&times;</button>
90
  </div>
91
 
@@ -128,8 +136,9 @@
128
  </div>
129
 
130
  <div style="margin-top:24px; display:flex; justify-content:flex-end; gap:8px;">
 
131
  <button onclick="closeModal()" class="btn" style="background:#f1f3f4; color:#333;">취소</button>
132
- <button onclick="submitCreate()" class="btn btn-primary">생성하기</button>
133
  </div>
134
  </div>
135
  </div>
@@ -142,6 +151,9 @@
142
  const planInput = document.getElementById('planInput');
143
  const launchDateInput = document.getElementById('launchDateInput');
144
 
 
 
 
145
  // 초기 설정값 로드
146
  async function loadDefaults() {
147
  try {
@@ -161,9 +173,19 @@
161
  }
162
  loadDefaults();
163
 
164
- function openCreateModal(fileId, fileName) {
165
  fileIdInput.value = fileId;
166
  fileNameInput.value = fileName;
 
 
 
 
 
 
 
 
 
 
167
  modal.style.display = 'flex';
168
  }
169
 
@@ -182,7 +204,8 @@
182
  cadence_days: document.getElementById('cadenceInput').value,
183
  ep0_days: ep0Input.value,
184
  plan_days: planInput.value,
185
- launch_date: launchDateInput.value
 
186
  };
187
 
188
  if (!payload.launch_date) {
@@ -200,7 +223,12 @@
200
  if (!res.ok) throw new Error(data.error || '생성 실패');
201
 
202
  alert('마일스톤이 생성되었습니다.');
203
- window.location.href = `/admin/webtoons/milestones/result/${data.id}`;
 
 
 
 
 
204
  } catch (e) {
205
  alert('오류: ' + e.message);
206
  }
 
47
  <table>
48
  <thead>
49
  <tr>
50
+ <th style="width: 60px; text-align: center;">ID</th>
51
+ <th style="text-align: left;">제목 (파일명)</th>
52
+ <th style="width: 150px; text-align: center;">업로드 일시</th>
53
+ <th style="width: 200px; text-align: center;">작업</th>
54
+ <th style="width: 180px; text-align: center;">확인</th>
55
  </tr>
56
  </thead>
57
  <tbody>
58
  {% for file in files %}
59
  <tr>
60
+ <td style="text-align: center;">{{ file.id }}</td>
61
  <td style="font-weight:500;">{{ file.original_filename }}</td>
62
+ <td style="text-align: center; color:var(--muted);">{{ file.uploaded_at.strftime('%Y-%m-%d %H:%M') }}</td>
63
+ <td style="text-align: center;">
64
+ <button class="btn btn-primary" onclick="openCreateModal({{ file.id }}, '{{ file.original_filename }}')">생성</button>
65
+ <button class="btn" style="background:#e8f0fe; color:#1a73e8; margin-left:4px;" onclick="openCreateModal({{ file.id }}, '{{ file.original_filename }}', true)">생성(휴일)</button>
66
+ </td>
67
+ <td style="text-align: center;">
68
  {% if file.milestones %}
69
+ {% set latest_ms = file.milestones|sort(attribute='created_at', reverse=True)|first %}
70
+ <a href="{{ url_for('main.admin_webtoon_milestone_result', milestone_id=latest_ms.id) }}" class="btn" style="background:#e6f4ea; color:#1e8e3e; padding:6px 10px; font-size:12px; display:inline-block;">확인</a>
71
+ <a href="{{ url_for('main.admin_webtoon_milestone_result_holidays', milestone_id=latest_ms.id) }}" class="btn" style="background:#fce8e6; color:#d93025; padding:6px 10px; font-size:12px; margin-left:4px; display:inline-block;">확인(휴일)</a>
72
+ {% else %}
73
+ <span style="color:#bdc1c6; font-size:12px;">-</span>
74
  {% endif %}
 
75
  </td>
76
  </tr>
77
  {% else %}
78
  <tr>
79
+ <td colspan="5">
80
  <div class="empty-state">
81
  공개 설정된 웹소설이 없습니다.<br>
82
  <small>웹소설 관리 메뉴에서 공개 여부를 확인해주세요.</small>
 
93
  <div id="createModal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.5); z-index:10000; align-items:center; justify-content:center;">
94
  <div style="background:#fff; width:90%; max-width:500px; border-radius:12px; padding:24px; box-shadow:0 10px 25px rgba(0,0,0,0.2);">
95
  <div style="display:flex; justify-content:space-between; margin-bottom:20px;">
96
+ <h3 class="modal-title" style="margin:0; font-size:18px;">마일스톤 생성</h3>
97
  <button onclick="closeModal()" style="background:none; border:none; font-size:20px; cursor:pointer;">&times;</button>
98
  </div>
99
 
 
136
  </div>
137
 
138
  <div style="margin-top:24px; display:flex; justify-content:flex-end; gap:8px;">
139
+ <input type="hidden" id="applyHolidaysInput" value="false">
140
  <button onclick="closeModal()" class="btn" style="background:#f1f3f4; color:#333;">취소</button>
141
+ <button onclick="submitCreate()" class="btn btn-primary" id="submitBtn">생성하기</button>
142
  </div>
143
  </div>
144
  </div>
 
151
  const planInput = document.getElementById('planInput');
152
  const launchDateInput = document.getElementById('launchDateInput');
153
 
154
+ const submitBtn = document.getElementById('submitBtn');
155
+ const applyHolidaysInput = document.getElementById('applyHolidaysInput');
156
+
157
  // 초기 설정값 로드
158
  async function loadDefaults() {
159
  try {
 
173
  }
174
  loadDefaults();
175
 
176
+ function openCreateModal(fileId, fileName, useHolidays = false) {
177
  fileIdInput.value = fileId;
178
  fileNameInput.value = fileName;
179
+
180
+ applyHolidaysInput.value = useHolidays ? 'true' : 'false';
181
+ if (useHolidays) {
182
+ submitBtn.textContent = '생성하기 (휴일적용)';
183
+ document.querySelector('.modal-title').textContent = '마일스톤 생성 (베트남 휴일 반영)';
184
+ } else {
185
+ submitBtn.textContent = '생성하기';
186
+ document.querySelector('.modal-title').textContent = '마일스톤 생성';
187
+ }
188
+
189
  modal.style.display = 'flex';
190
  }
191
 
 
204
  cadence_days: document.getElementById('cadenceInput').value,
205
  ep0_days: ep0Input.value,
206
  plan_days: planInput.value,
207
+ launch_date: launchDateInput.value,
208
+ apply_holidays: applyHolidaysInput.value === 'true'
209
  };
210
 
211
  if (!payload.launch_date) {
 
223
  if (!res.ok) throw new Error(data.error || '생성 실패');
224
 
225
  alert('마일스톤이 생성되었습니다.');
226
+
227
+ let redirectUrl = `/admin/webtoons/milestones/result/${data.id}`;
228
+ if (data.apply_holidays) {
229
+ redirectUrl = `/admin/webtoons/milestones/result-holidays/${data.id}`;
230
+ }
231
+ window.location.href = redirectUrl;
232
  } catch (e) {
233
  alert('오류: ' + e.message);
234
  }