feat(project): 달력을 휴가 모듈식 월간 그리드로 교체

FullCalendar 제거. 휴가 모듈과 동일한 서버 렌더 월간 달력(구글식 bar
레인 배치)로 변경. 업무 start~due 기간을 bar 로 표시, 클릭 시 모달.
?y=&m= 로 월 이동, 프로젝트 색상 반영, 완료 업무는 회색+취소선.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 12:00:48 +09:00
parent c192d61c71
commit 78f212fffd
4 changed files with 211 additions and 48 deletions
+112 -1
View File
@@ -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,
},
)