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 from __future__ import annotations
import calendar as _calendar
import logging import logging
from datetime import date
from typing import Any from typing import Any
from fastapi import APIRouter, BackgroundTasks, Body, Depends, HTTPException, Request 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 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) can_manage = _can_manage(request, st, user, project_id)
subprojects = [p for p in all_projects if p.get("parent_id") == 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( return render_template(
request, request,
"project/project.html", "project/project.html",
@@ -224,7 +329,13 @@ async def project_page(request: Request, project_id: int) -> HTMLResponse:
"subprojects": subprojects, "subprojects": subprojects,
"priority_labels": store.PRIORITY_LABELS, "priority_labels": store.PRIORITY_LABELS,
"color_palette": list(store.COLOR_PALETTE), "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" %} {% extends "erp_base.html" %}
{% block head_extra %} {% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260625a" /> <link rel="stylesheet" href="/static/project.css?v=20260625b" />
<!-- FullCalendar (달력) · vis-timeline (타임라인) — 스켈레톤 단계는 CDN, 추후 self-host --> <!-- 달력은 휴가 모듈과 동일한 서버 렌더 그리드(project.css). 타임라인만 vis(CDN, 추후 self-host) -->
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.3/styles/vis-timeline-graph2d.min.css" rel="stylesheet" /> <link href="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.3/styles/vis-timeline-graph2d.min.css" rel="stylesheet" />
{% endblock %} {% endblock %}
@@ -78,9 +77,49 @@
{% endif %} {% endif %}
</div> </div>
<!-- 달력 --> <!-- 달력 (휴가 모듈과 동일한 월간 그리드) -->
<div class="pj-view" data-view="calendar"> <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>
<!-- 타임라인 --> <!-- 타임라인 -->
<div class="pj-view" data-view="timeline" hidden> <div class="pj-view" data-view="timeline" hidden>
@@ -160,7 +199,6 @@
} }
</script> </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="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 %} {% endblock %}
+36 -3
View File
@@ -100,10 +100,43 @@
.pj-table td { padding: 10px; border-bottom: 1px solid #f1f2f4; } .pj-table td { padding: 10px; border-bottom: 1px solid #f1f2f4; }
.pj-table tr.is-done td { color: #9aa1a9; text-decoration: line-through; } .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; } #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; } .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 members = data.members || [];
const priorityLabels = data.priorityLabels || {}; const priorityLabels = data.priorityLabels || {};
let calendar = null;
let timeline = null; let timeline = null;
let currentView = "calendar"; let currentView = "calendar";
@@ -121,45 +120,26 @@
}); });
function renderView(view) { function renderView(view) {
if (view === "calendar") renderCalendar(); if (view === "calendar") wireCalendar();
else if (view === "timeline") renderTimeline(); else if (view === "timeline") renderTimeline();
else if (view === "board") renderBoard(); else if (view === "board") renderBoard();
else if (view === "list") renderList(); else if (view === "list") renderList();
} }
// ── 달력 (FullCalendar) ── // ── 달력 (휴가식 서버 렌더 그리드) ──
function taskEvents() { // bar 는 서버가 그려둠. 클릭하면 업무 모달 열기(관리 권한 시).
return tasks.filter(function (t) { return t.due_date || t.start_date; }) let calendarWired = false;
.map(function (t) { function wireCalendar() {
const start = t.start_date || t.due_date; if (calendarWired) return;
// FullCalendar end 는 배타적 → 마감일 포함 위해 +1일은 생략(단일일 표시) calendarWired = true;
return { document.querySelectorAll(".pj-bar[data-task-id]").forEach(function (bar) {
id: String(t.id), bar.addEventListener("click", function (e) {
title: t.title, e.preventDefault();
start: start, if (!canManage) return;
end: t.due_date && t.due_date !== start ? t.due_date : undefined, const t = tasks.find(function (x) { return String(x.id) === bar.dataset.taskId; });
backgroundColor: t.completed_at ? "#9aa5b1" : (t.project_color || "#4573d2"), if (t) openTaskModal(t);
borderColor: "transparent",
extendedProps: { task: 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) ── // ── 타임라인 (vis-timeline) ──
@@ -281,8 +261,9 @@
} }
function refreshActiveView() { 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) { function escapeHtml(s) {