feat(project): 달력을 휴가 모듈식 월간 그리드로 교체
FullCalendar 제거. 휴가 모듈과 동일한 서버 렌더 월간 달력(구글식 bar 레인 배치)로 변경. 업무 start~due 기간을 bar 로 표시, 클릭 시 모달. ?y=&m= 로 월 이동, 프로젝트 색상 반영, 완료 업무는 회색+취소선. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user