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 %}
+
+
+
+
+ {% endfor %}
+
@@ -160,7 +199,6 @@
}
-
-
+
{% endblock %}
diff --git a/app/static/project.css b/app/static/project.css
index 0e5b7aa..459f738 100644
--- a/app/static/project.css
+++ b/app/static/project.css
@@ -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; }
diff --git a/app/static/project.js b/app/static/project.js
index 0e7360d..201fae1 100644
--- a/app/static/project.js
+++ b/app/static/project.js
@@ -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) {