From 78f212fffd8cc9027878615f33f82b7d4d0e163c Mon Sep 17 00:00:00 2001 From: king Date: Thu, 25 Jun 2026 12:00:48 +0900 Subject: [PATCH] =?UTF-8?q?feat(project):=20=EB=8B=AC=EB=A0=A5=EC=9D=84=20?= =?UTF-8?q?=ED=9C=B4=EA=B0=80=20=EB=AA=A8=EB=93=88=EC=8B=9D=20=EC=9B=94?= =?UTF-8?q?=EA=B0=84=20=EA=B7=B8=EB=A6=AC=EB=93=9C=EB=A1=9C=20=EA=B5=90?= =?UTF-8?q?=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FullCalendar 제거. 휴가 모듈과 동일한 서버 렌더 월간 달력(구글식 bar 레인 배치)로 변경. 업무 start~due 기간을 bar 로 표시, 클릭 시 모달. ?y=&m= 로 월 이동, 프로젝트 색상 반영, 완료 업무는 회색+취소선. Co-Authored-By: Claude Opus 4.8 --- app/modules/project/router.py | 113 +++++++++++++++++- .../project/templates/project/project.html | 52 ++++++-- app/static/project.css | 39 +++++- app/static/project.js | 55 +++------ 4 files changed, 211 insertions(+), 48 deletions(-) diff --git a/app/modules/project/router.py b/app/modules/project/router.py index 401cf74..79672c5 100644 --- a/app/modules/project/router.py +++ b/app/modules/project/router.py @@ -14,7 +14,9 @@ from __future__ import annotations +import calendar as _calendar import logging +from datetime import date from typing import Any from fastapi import APIRouter, BackgroundTasks, Body, Depends, HTTPException, Request @@ -132,6 +134,96 @@ def _build_tree(projects: list[dict[str, Any]]) -> list[dict[str, Any]]: return roots +# ──────────────────────────────────────────────────────────── +# 월간 달력 레이아웃 (휴가 모듈과 동일한 구글식 bar 레인 배치) +# ──────────────────────────────────────────────────────────── +def _assign_lanes(bars: list[dict[str, Any]]) -> int: + """주(week) 내 bar 들에 겹치지 않는 lane(행)을 그리디 배정. 사용 lane 수 반환.""" + bars.sort(key=lambda b: (b["start_col"], -b["span"])) + lane_end: list[int] = [] # lane 별 마지막 점유 col(포함) + for b in bars: + placed = False + for li, end_col in enumerate(lane_end): + if b["start_col"] > end_col: + b["lane"] = li + lane_end[li] = b["start_col"] + b["span"] - 1 + placed = True + break + if not placed: + b["lane"] = len(lane_end) + lane_end.append(b["start_col"] + b["span"] - 1) + return len(lane_end) + + +def _task_span(task: dict[str, Any]) -> tuple[date, date] | None: + """업무의 달력 표시 기간(start..due). 둘 중 하나만 있으면 단일일. 없으면 None.""" + s = task.get("start_date") + d = task.get("due_date") + try: + sd = date.fromisoformat(s[:10]) if s else None + dd = date.fromisoformat(d[:10]) if d else None + except (ValueError, TypeError): + return None + if sd and dd: + return (sd, dd) if sd <= dd else (dd, sd) + if sd: + return (sd, sd) + if dd: + return (dd, dd) + return None + + +def _build_task_calendar( + tasks: list[dict[str, Any]], year: int, month: int +) -> list[dict[str, Any]]: + """업무 리스트 → 월간 달력 주(week) 데이터(휴가 _build_calendar 와 동일 구조).""" + cal = _calendar.Calendar(firstweekday=6) # 일요일 시작 + weeks_dates = cal.monthdatescalendar(year, month) + today = today_kst() + + spans: list[tuple[date, date, dict[str, Any]]] = [] + for t in tasks: + sp = _task_span(t) + if sp: + spans.append((sp[0], sp[1], t)) + + weeks: list[dict[str, Any]] = [] + for week in weeks_dates: + w_start, w_end = week[0], week[-1] + days = [ + { + "date": d.isoformat(), + "day": d.day, + "in_month": d.month == month, + "is_today": d == today, + "is_sunday": d.weekday() == 6, + "is_saturday": d.weekday() == 5, + } + for d in week + ] + bars: list[dict[str, Any]] = [] + for rs, re_, t in spans: + if re_ < w_start or rs > w_end: + continue + seg_start = max(rs, w_start) + seg_end = min(re_, w_end) + bars.append( + { + "id": t["id"], + "label": t["title"], + "color": t.get("project_color") or "#4573d2", + "completed": bool(t.get("completed_at")), + "start_col": (seg_start - w_start).days, + "span": (seg_end - seg_start).days + 1, + "continues_left": rs < w_start, + "continues_right": re_ > w_end, + } + ) + lane_count = _assign_lanes(bars) + weeks.append({"days": days, "bars": bars, "lane_count": lane_count}) + return weeks + + # ──────────────────────────────────────────────────────────── # 페이지 # ──────────────────────────────────────────────────────────── @@ -206,6 +298,19 @@ async def project_page(request: Request, project_id: int) -> HTMLResponse: can_manage = _can_manage(request, st, user, project_id) subprojects = [p for p in all_projects if p.get("parent_id") == project_id] + # 달력(휴가식 월간 그리드) — ?y=&m= 로 월 이동, 기본 이번 달 + today = today_kst() + try: + year = int(request.query_params.get("y") or today.year) + month = int(request.query_params.get("m") or today.month) + if not (1 <= month <= 12): + year, month = today.year, today.month + except (TypeError, ValueError): + year, month = today.year, today.month + weeks = _build_task_calendar(tasks, year, month) + prev_y, prev_m = (year - 1, 12) if month == 1 else (year, month - 1) + next_y, next_m = (year + 1, 1) if month == 12 else (year, month + 1) + return render_template( request, "project/project.html", @@ -224,7 +329,13 @@ async def project_page(request: Request, project_id: int) -> HTMLResponse: "subprojects": subprojects, "priority_labels": store.PRIORITY_LABELS, "color_palette": list(store.COLOR_PALETTE), - "today": today_kst().isoformat(), + "today": today.isoformat(), + "year": year, + "month": month, + "prev_y": prev_y, "prev_m": prev_m, + "next_y": next_y, "next_m": next_m, + "weekdays": ["일", "월", "화", "수", "목", "금", "토"], + "weeks": weeks, }, ) diff --git a/app/modules/project/templates/project/project.html b/app/modules/project/templates/project/project.html index 53699e7..bcbf42a 100644 --- a/app/modules/project/templates/project/project.html +++ b/app/modules/project/templates/project/project.html @@ -1,9 +1,8 @@ {% extends "erp_base.html" %} {% block head_extra %} - - - + + {% endblock %} @@ -78,9 +77,49 @@ {% endif %} - +
-
+
+ +

{{ year }}년 {{ month }}월

+ + 오늘 +
+ +
+
+ {% for wd in weekdays %} +
{{ wd }}
+ {% endfor %} +
+ + {% for week in weeks %} +
+
+ {% for cell in week.days %} +
+ {{ cell.day }} +
+ {% endfor %} +
+ +
+ {% for bar in week.bars %} + + {{ bar.label }} + + {% endfor %} +
+
+ {% endfor %} +