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,
},
)
@@ -1,9 +1,8 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260625a" />
<!-- FullCalendar (달력) · vis-timeline (타임라인) — 스켈레톤 단계는 CDN, 추후 self-host -->
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.css" rel="stylesheet" />
<link rel="stylesheet" href="/static/project.css?v=20260625b" />
<!-- 달력은 휴가 모듈과 동일한 서버 렌더 그리드(project.css). 타임라인만 vis(CDN, 추후 self-host) -->
<link href="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.3/styles/vis-timeline-graph2d.min.css" rel="stylesheet" />
{% endblock %}
@@ -78,9 +77,49 @@
{% endif %}
</div>
<!-- 달력 -->
<!-- 달력 (휴가 모듈과 동일한 월간 그리드) -->
<div class="pj-view" data-view="calendar">
<div id="pj-calendar"></div>
<div class="pj-cal-head">
<a class="pj-btn pj-nav-btn" href="/project/p/{{ project.id }}?y={{ prev_y }}&m={{ prev_m }}"></a>
<h2 class="pj-cal-title">{{ year }}년 {{ month }}월</h2>
<a class="pj-btn pj-nav-btn" href="/project/p/{{ project.id }}?y={{ next_y }}&m={{ next_m }}"></a>
<a class="pj-btn pj-today-btn" href="/project/p/{{ project.id }}">오늘</a>
</div>
<div class="pj-cal">
<div class="pj-wd-row">
{% for wd in weekdays %}
<div class="pj-wd {% if loop.index0 == 0 %}pj-red{% elif loop.index0 == 6 %}pj-blue{% endif %}">{{ wd }}</div>
{% endfor %}
</div>
{% for week in weeks %}
<div class="pj-week">
<div class="pj-week-days">
{% for cell in week.days %}
<div class="pj-day {% if not cell.in_month %}pj-out{% endif %} {% if cell.is_today %}pj-today{% endif %}">
<span class="pj-day-num
{% if cell.is_sunday %}pj-red{% elif cell.is_saturday %}pj-blue{% endif %}">{{ cell.day }}</span>
</div>
{% endfor %}
</div>
<div class="pj-week-bars">
{% for bar in week.bars %}
<a class="pj-bar {% if bar.completed %}pj-bar-done{% endif %}
{% if bar.continues_left %}pj-bar-l{% endif %}
{% if bar.continues_right %}pj-bar-r{% endif %}"
data-task-id="{{ bar.id }}"
style="grid-column: {{ bar.start_col + 1 }} / span {{ bar.span }}; grid-row: {{ bar.lane + 1 }};
background: {% if bar.completed %}#9aa5b1{% else %}{{ bar.color }}{% endif %};"
title="{{ bar.label }}">
<span class="pj-bar-label">{{ bar.label }}</span>
</a>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
<!-- 타임라인 -->
<div class="pj-view" data-view="timeline" hidden>
@@ -160,7 +199,6 @@
}
</script>
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.3/standalone/umd/vis-timeline-graph2d.min.js"></script>
<script src="/static/project.js?v=20260625a" defer></script>
<script src="/static/project.js?v=20260625b" defer></script>
{% endblock %}
+36 -3
View File
@@ -100,10 +100,43 @@
.pj-table td { padding: 10px; border-bottom: 1px solid #f1f2f4; }
.pj-table tr.is-done td { color: #9aa1a9; text-decoration: line-through; }
/* ── 달력/타임라인 컨테이너 ── */
#pj-calendar { background: #fff; border-radius: 12px; padding: 10px; }
/* ── 달력 (휴가 모듈과 동일한 월간 그리드 룩) ── */
.pj-cal-head { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.pj-cal-title { font-size: 18pt; font-weight: 600; margin: 0; letter-spacing: -0.45px; }
.pj-nav-btn { padding: 2px 12px; font-size: 18pt; line-height: 1; }
.pj-today-btn { margin-left: auto; }
.pj-cal { border: 1px solid #e5e5e5; border-radius: 10px; overflow: hidden; display: flex; flex-direction: column; }
.pj-wd-row { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); background: #f2f2f2; border-bottom: 1px solid #e5e5e5; }
.pj-wd { text-align: center; padding: 8px 0; font-size: 15px; font-weight: 600; color: #0a0a0a; border-right: 1px solid #e5e5e5; }
.pj-wd:last-child { border-right: none; }
/* 주(week) — 날짜 셀 위에 bar 레이어 오버레이. 6주가 높이 균등 분할. */
.pj-week { position: relative; border-bottom: 1px solid #e5e5e5; flex: 1 1 0; min-height: 92px; display: flex; flex-direction: column; }
.pj-week:last-child { border-bottom: none; }
.pj-week-days { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); flex: 1 1 auto; min-height: 0; }
.pj-day { height: 100%; border-right: 1px solid #e5e5e5; padding: 6px 8px; box-sizing: border-box; }
.pj-day:last-child { border-right: none; }
.pj-day-num { font-size: 20px; font-weight: 600; color: #0a0a0a; }
.pj-out { background: #fafafa; }
.pj-out .pj-day-num { color: #bbb; }
.pj-today { background: #f5f5f5; }
.pj-today .pj-day-num { background: #0a0a0a; color: #fff !important; border-radius: 9999px; padding: 2px 9px; }
.pj-today .pj-day-num.pj-red { background: #c22b10; }
.pj-today .pj-day-num.pj-blue { background: #1d4ed8; }
.pj-red { color: #c22b10 !important; }
.pj-blue { color: #1d4ed8 !important; }
/* bar 오버레이 — 날짜 숫자 아래(top)부터 7열 그리드로 겹쳐 그림 */
.pj-week-bars { position: absolute; left: 0; right: 0; top: 38px; bottom: 4px; display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); grid-auto-rows: 22px; row-gap: 3px; pointer-events: none; }
.pj-bar { margin: 0 3px; padding: 0 8px; height: 20px; line-height: 20px; border-radius: 6px; font-size: 13px; color: #fff; text-decoration: none; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; align-self: center; pointer-events: auto; cursor: pointer; }
.pj-bar-label { pointer-events: none; }
.pj-bar-l { border-top-left-radius: 0; border-bottom-left-radius: 0; margin-left: 0; }
.pj-bar-r { border-top-right-radius: 0; border-bottom-right-radius: 0; margin-right: 0; }
.pj-bar-done { opacity: .75; text-decoration: line-through; }
/* ── 타임라인 컨테이너 ── */
#pj-timeline { background: #fff; border-radius: 12px; padding: 6px; }
.fc { font-size: 13px; }
/* ── 모달 ── */
.pj-modal { position: fixed; inset: 0; background: rgba(20,22,26,.46); display: flex; align-items: center; justify-content: center; z-index: 1000; }
+18 -37
View File
@@ -100,7 +100,6 @@
const members = data.members || [];
const priorityLabels = data.priorityLabels || {};
let calendar = null;
let timeline = null;
let currentView = "calendar";
@@ -121,45 +120,26 @@
});
function renderView(view) {
if (view === "calendar") renderCalendar();
if (view === "calendar") wireCalendar();
else if (view === "timeline") renderTimeline();
else if (view === "board") renderBoard();
else if (view === "list") renderList();
}
// ── 달력 (FullCalendar) ──
function taskEvents() {
return tasks.filter(function (t) { return t.due_date || t.start_date; })
.map(function (t) {
const start = t.start_date || t.due_date;
// FullCalendar end 는 배타적 → 마감일 포함 위해 +1일은 생략(단일일 표시)
return {
id: String(t.id),
title: t.title,
start: start,
end: t.due_date && t.due_date !== start ? t.due_date : undefined,
backgroundColor: t.completed_at ? "#9aa5b1" : (t.project_color || "#4573d2"),
borderColor: "transparent",
extendedProps: { task: t },
};
// ── 달력 (휴가식 서버 렌더 그리드) ──
// bar 는 서버가 그려둠. 클릭하면 업무 모달 열기(관리 권한 시).
let calendarWired = false;
function wireCalendar() {
if (calendarWired) return;
calendarWired = true;
document.querySelectorAll(".pj-bar[data-task-id]").forEach(function (bar) {
bar.addEventListener("click", function (e) {
e.preventDefault();
if (!canManage) return;
const t = tasks.find(function (x) { return String(x.id) === bar.dataset.taskId; });
if (t) openTaskModal(t);
});
}
function renderCalendar() {
const node = el("pj-calendar");
if (calendar) { calendar.removeAllEvents(); calendar.addEventSource(taskEvents()); calendar.render(); return; }
calendar = new FullCalendar.Calendar(node, {
initialView: "dayGridMonth",
locale: "ko",
height: "auto",
headerToolbar: { left: "prev,next today", center: "title", right: "dayGridMonth,dayGridWeek" },
events: taskEvents(),
eventClick: function (info) {
info.jsEvent.preventDefault();
if (canManage) openTaskModal(info.event.extendedProps.task);
},
});
calendar.render();
}
// ── 타임라인 (vis-timeline) ──
@@ -281,8 +261,9 @@
}
function refreshActiveView() {
if (currentView === "calendar" && calendar) { calendar.removeAllEvents(); calendar.addEventSource(taskEvents()); }
else renderView(currentView);
// 달력은 서버 렌더라 변경 반영 위해 새로고침. 그 외 뷰는 즉시 재렌더.
if (currentView === "calendar") { location.reload(); return; }
renderView(currentView);
}
// ── 업무 모달 ──
@@ -382,8 +363,8 @@
});
});
// 최초 렌더
renderCalendar();
// 최초 렌더 — 달력은 서버 렌더, 클릭 핸들러만 연결
wireCalendar();
}
function escapeHtml(s) {