feat(project): 달력·타임라인·보드·리스트 4개 뷰를 전체 프로젝트 기준으로 표시

- project_page 가 접근 가능한 모든 프로젝트의 업무를 집계(all_tasks)
- 달력: 전 프로젝트 bar(라벨에 [프로젝트명] 유지)
- 타임라인: 프로젝트별 행(그룹) + 행마다 전체기간 배경 + 업무 막대
- 보드: 단계 '이름' 기준 컬럼으로 전 프로젝트 묶음, 카드에 프로젝트 배지
       드롭 시 해당 업무 프로젝트의 동일 이름 단계로 이동
- 리스트: '프로젝트' 컬럼 추가
- 모달 단계 선택은 해당 업무 프로젝트의 단계로 채움(타 프로젝트 편집 대비)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 16:23:49 +09:00
parent d89aa349eb
commit e36fcb1a34
5 changed files with 115 additions and 103 deletions
+29 -4
View File
@@ -376,15 +376,28 @@ async def project_page(request: Request, project_id: int) -> HTMLResponse:
members = st.list_members(project_id=project_id)
can_manage = _can_manage(request, st, user, project_id)
# 좌측 트리: 프로젝트(최상위) → 그 안의 업무. (서브프로젝트 개념 제거)
# 좌측 트리 + 전체 집계: 4개 뷰(달력/타임라인/보드/리스트)는 '전체 프로젝트'를 보여준다.
# all_tasks : 접근 가능한 모든 프로젝트의 업무(뷰 데이터)
# stages_by_project : 프로젝트별 단계(보드 이름→단계 매핑 + 모달 단계 선택용)
# projects_meta : 프로젝트 메타(타임라인 그룹/보드 카드 배지용)
nav_projects: list[dict[str, Any]] = []
all_tasks: list[dict[str, Any]] = []
stages_by_project: dict[int, list[dict[str, Any]]] = {}
projects_meta: list[dict[str, Any]] = []
for p in all_projects:
if p.get("parent_id"):
continue # 과거 서브프로젝트는 트리에서 제외
p_tasks = (
tasks if p["id"] == project_id
else st.list_tasks(project_id=p["id"], limit=500)
else st.list_tasks(project_id=p["id"], limit=2000)
)
all_tasks.extend(p_tasks)
stages_by_project[p["id"]] = st.list_stages(project_id=p["id"])
projects_meta.append({
"id": p["id"],
"name": p["name"],
"color": p.get("color") or "#4573d2",
})
nav_projects.append({
"id": p["id"],
"name": p["name"],
@@ -397,6 +410,15 @@ async def project_page(request: Request, project_id: int) -> HTMLResponse:
],
})
# 보드 컬럼 = 전체 프로젝트의 단계 '이름' 묶음(기본 4단계 순서를 우선).
_order = {n: i for i, (n, _d) in enumerate(store.DEFAULT_STAGES)}
board_stage_names: list[str] = []
for slist in stages_by_project.values():
for s in slist:
if s["name"] not in board_stage_names:
board_stage_names.append(s["name"])
board_stage_names.sort(key=lambda n: (_order.get(n, 999), n))
# 달력(휴가식 월간 그리드) — ?y=&m= 로 월 이동, 기본 이번 달
today = today_kst()
try:
@@ -406,7 +428,7 @@ async def project_page(request: Request, project_id: int) -> HTMLResponse:
year, month = today.year, today.month
except (TypeError, ValueError):
year, month = today.year, today.month
weeks = _build_task_calendar(tasks, year, month)
weeks = _build_task_calendar(all_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)
@@ -423,8 +445,11 @@ async def project_page(request: Request, project_id: int) -> HTMLResponse:
"project": project,
"nav_projects": nav_projects,
"stages": stages,
"tasks": tasks,
"tasks": all_tasks,
"members": members,
"projects_meta": projects_meta,
"stages_by_project": stages_by_project,
"board_stage_names": board_stage_names,
"priority_labels": store.PRIORITY_LABELS,
"color_palette": list(store.COLOR_PALETTE),
"today": today.isoformat(),
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260625p" />
<link rel="stylesheet" href="/static/project.css?v=20260625q" />
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
{% endblock %}
@@ -116,5 +116,5 @@
<script>
window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }};
</script>
<script src="/static/project.js?v=20260625p" defer></script>
<script src="/static/project.js?v=20260625q" defer></script>
{% endblock %}
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260625p" />
<link rel="stylesheet" href="/static/project.css?v=20260625q" />
<!-- 구글 머티리얼 심볼(담당자 아이콘 등) — self-host -->
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
<!-- 타임라인 vis-timeline — self-host -->
@@ -292,10 +292,13 @@
"stages": {{ stages | tojson }},
"tasks": {{ tasks | tojson }},
"members": {{ members | tojson }},
"priorityLabels": {{ priority_labels | tojson }}
"priorityLabels": {{ priority_labels | tojson }},
"projects": {{ projects_meta | tojson }},
"stagesByProject": {{ stages_by_project | tojson }},
"boardStages": {{ board_stage_names | tojson }}
}
</script>
<script src="/static/vendor/vis-timeline/vis-timeline-graph2d.min.js"></script>
<script src="/static/project.js?v=20260625p" defer></script>
<script src="/static/project.js?v=20260625q" defer></script>
{% endblock %}
+3
View File
@@ -255,6 +255,9 @@
.pj-comment-edit-actions { display: flex; gap: 6px; justify-content: flex-end; margin-top: 6px; }
.pj-comment-edit-actions .pj-btn { padding: 4px 12px; font-size: 12px; }
/* 보드 카드 — 전체 뷰에서 어느 프로젝트인지 배지 표시 */
.pj-card-proj { font-size: 11px; font-weight: 700; margin-bottom: 3px; letter-spacing: -.2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ── 보드 단계 컨트롤 ── */
.pj-col-head { position: relative; }
.pj-col-name { font-weight: 700; }
+75 -94
View File
@@ -99,6 +99,10 @@
let tasks = data.tasks || [];
const members = data.members || [];
const priorityLabels = data.priorityLabels || {};
// 전체 프로젝트 집계 데이터 — 4개 뷰는 전 프로젝트를 보여준다.
const projectsMeta = data.projects || [];
const stagesByProject = data.stagesByProject || {};
const boardStages = data.boardStages || [];
let timeline = null;
let currentView = "calendar";
@@ -224,52 +228,54 @@
});
}
// ── 타임라인 (vis-timeline) ──
// ── 타임라인 (vis-timeline) — 프로젝트별 행(그룹), 행마다 전체 일정 배경 + 업무 막대 ──
function renderTimeline() {
const node = el("pj-timeline");
const proj = data.project || {};
const grp = "P" + projectId; // 프로젝트 행(그룹) id
const projColor = proj.color || "#4573d2";
const items = tasks.filter(function (t) { return t.start_date || t.due_date; })
.map(function (t) {
if (timeline) { timeline.destroy(); timeline = null; }
const dated = tasks.filter(function (t) { return t.start_date || t.due_date; });
if (!dated.length) { node.innerHTML = '<div class="pj-empty">기간이 설정된 업무가 없습니다.</div>'; return; }
// 프로젝트별로 업무를 묶어 그룹(행) 생성
const byProj = {};
dated.forEach(function (t) { (byProj[t.project_id] = byProj[t.project_id] || []).push(t); });
const items = [];
const groupRows = [];
Object.keys(byProj).forEach(function (pid) {
const list = byProj[pid];
const grp = "P" + pid;
const projColor = list[0].project_color || "#4573d2";
groupRows.push({ id: grp, content: list[0].project_name || "프로젝트" });
let minMs = Infinity, maxMs = -Infinity;
list.forEach(function (t) {
const s = t.start_date || t.due_date;
const e = t.due_date || t.start_date;
// 시간 지정 시 date+time, 아니면 종일(날짜만)
const startISO = s + (t.start_time ? "T" + t.start_time : "");
const endISO = e + (t.due_time ? "T" + t.due_time : "");
const isRange = (e !== s) || (!!t.start_time && !!t.due_time && t.start_time !== t.due_time);
return {
id: t.id,
group: grp,
content: t.title, // 프로젝트명 없이 업무명만
start: startISO,
end: isRange ? endISO : undefined,
items.push({
id: t.id, group: grp, content: t.title,
start: startISO, end: isRange ? endISO : undefined,
type: isRange ? "range" : "point",
style: "background-color:" + (t.completed_at ? "#9aa5b1" : projColor) + ";color:#fff;border:none;",
};
});
const sm = new Date(startISO).getTime();
const em = new Date(endISO).getTime();
if (sm < minMs) minMs = sm;
if (em > maxMs) maxMs = em;
});
maxMs += 86400000; // 마지막 날 하루를 덮도록 +1일
items.push({
id: "bg" + pid, group: grp, type: "background",
start: new Date(minMs), end: new Date(maxMs),
style: "background-color:" + projColor + "22;",
});
if (timeline) { timeline.destroy(); timeline = null; }
if (!items.length) { node.innerHTML = '<div class="pj-empty">기간이 설정된 업무가 없습니다.</div>'; return; }
// 프로젝트 전체 일정 = 업무들의 최소~최대 범위를 연한 색으로 배경 칠하기
let minMs = Infinity, maxMs = -Infinity;
items.forEach(function (it) {
const s = new Date(it.start).getTime();
const e = it.end ? new Date(it.end).getTime() : s;
if (s < minMs) minMs = s;
if (e > maxMs) maxMs = e;
});
maxMs += 86400000; // 마지막 날 하루를 덮도록 +1일
const bg = {
id: "pjbg", group: grp, type: "background",
start: new Date(minMs), end: new Date(maxMs),
style: "background-color:" + projColor + "22;",
};
const groups = new vis.DataSet([{ id: grp, content: proj.name || "프로젝트" }]);
const groups = new vis.DataSet(groupRows);
const koWd = function (d) { return ["일", "월", "화", "수", "목", "금", "토"][d.getDay()]; };
const pad2 = function (n) { return ("0" + n).slice(-2); };
timeline = new vis.Timeline(node, new vis.DataSet(items.concat([bg])), groups, {
timeline = new vis.Timeline(node, new vis.DataSet(items), groups, {
stack: true, height: "520px",
horizontalScroll: true, // 마우스 휠 = 좌우 이동(패닝)
verticalScroll: false,
@@ -308,85 +314,42 @@
});
}
// ── 보드 (칸반) + 단계 편집 ──
// ── 보드 (칸반) — 전체 프로젝트를 단계 '이름' 기준 컬럼으로 묶음 ──
function renderBoard() {
const board = el("pj-board");
board.innerHTML = "";
stages.forEach(function (stage, idx) {
// 컬럼 이름 = 서버가 정렬해준 전체 단계 이름 + 단계 미지정 업무용 '미지정'
let names = boardStages.slice();
const hasUnstaged = tasks.some(function (t) { return !t.stage_name; });
if (hasUnstaged) names.push("미지정");
names.forEach(function (name) {
const col = document.createElement("div");
col.className = "pj-col";
col.dataset.stageId = stage.id;
let controls = "";
if (canManage) {
controls =
'<span class="pj-col-ctrls">' +
'<button type="button" class="pj-icon-btn pj-st-move" data-dir="-1" title="왼쪽으로"' + (idx === 0 ? " disabled" : "") + '></button>' +
'<button type="button" class="pj-icon-btn pj-st-move" data-dir="1" title="오른쪽으로"' + (idx === stages.length - 1 ? " disabled" : "") + '></button>' +
'<button type="button" class="pj-icon-btn pj-st-rename" title="이름 변경">✎</button>' +
'<button type="button" class="pj-icon-btn pj-st-done" title="완료단계 토글">✓</button>' +
'<button type="button" class="pj-icon-btn pj-st-del" title="단계 삭제">×</button>' +
"</span>";
}
col.dataset.stageName = name;
col.innerHTML =
'<div class="pj-col-head"><span class="pj-col-name">' + escapeHtml(stage.name) + "</span>" +
(stage.is_done_stage ? ' <span class="pj-chip pj-chip-sm">완료</span>' : "") +
'<span class="pj-col-count"></span>' + controls + "</div>" +
'<div class="pj-col-head"><span class="pj-col-name">' + escapeHtml(name) + "</span>" +
'<span class="pj-col-count"></span></div>' +
'<div class="pj-col-body"></div>';
const body = col.querySelector(".pj-col-body");
const colTasks = tasks.filter(function (t) { return t.stage_id === stage.id; });
const colTasks = tasks.filter(function (t) {
return name === "미지정" ? !t.stage_name : (t.stage_name || "") === name;
});
col.querySelector(".pj-col-count").textContent = colTasks.length;
colTasks.forEach(function (t) { body.appendChild(taskCard(t)); });
if (canManage) {
enableDrop(body, stage.id);
wireStageControls(col, stage, idx);
}
if (canManage && name !== "미지정") enableDropByName(body, name);
board.appendChild(col);
});
}
async function reloadStages() {
const res = await api("GET", "/project/api/projects/" + projectId + "/stages");
stages = res.stages;
}
function wireStageControls(col, stage, idx) {
col.querySelectorAll(".pj-st-move").forEach(function (b) {
b.addEventListener("click", async function () {
const dir = parseInt(b.dataset.dir, 10);
const j = idx + dir;
if (j < 0 || j >= stages.length) return;
const ids = stages.map(function (s) { return s.id; });
const tmp = ids[idx]; ids[idx] = ids[j]; ids[j] = tmp;
try {
const res = await api("PUT", "/project/api/projects/" + projectId + "/stages/order", { ordered_ids: ids });
stages = res.stages; renderBoard();
} catch (e) { alert("이동 실패: " + e.message); }
});
});
col.querySelector(".pj-st-rename").addEventListener("click", async function () {
const name = prompt("단계 이름", stage.name);
if (!name || !name.trim()) return;
try { await api("PUT", "/project/api/projects/" + projectId + "/stages/" + stage.id, { name: name.trim() }); await reloadStages(); renderBoard(); }
catch (e) { alert("변경 실패: " + e.message); }
});
col.querySelector(".pj-st-done").addEventListener("click", async function () {
try { await api("PUT", "/project/api/projects/" + projectId + "/stages/" + stage.id, { is_done_stage: !stage.is_done_stage }); await reloadStages(); renderBoard(); }
catch (e) { alert("변경 실패: " + e.message); }
});
col.querySelector(".pj-st-del").addEventListener("click", async function () {
if (!confirm("이 단계를 삭제할까요? (이 단계의 업무는 단계 미지정으로 남습니다)")) return;
try { await api("DELETE", "/project/api/projects/" + projectId + "/stages/" + stage.id); await reloadStages(); renderBoard(); }
catch (e) { alert("삭제 실패: " + e.message); }
});
}
function taskCard(t) {
const card = document.createElement("div");
card.className = "pj-card" + (t.completed_at ? " is-done" : "");
card.draggable = canManage;
card.dataset.taskId = t.id;
card.innerHTML =
(t.project_name
? '<div class="pj-card-proj" style="color:' + (t.project_color || "#4573d2") + '">● ' + escapeHtml(t.project_name) + "</div>"
: "") +
'<div class="pj-card-title">' + escapeHtml(t.title) + "</div>" +
'<div class="pj-card-meta">' +
(t.priority && t.priority !== "normal"
@@ -409,7 +372,9 @@
return card;
}
function enableDrop(body, stageId) {
// 보드 드롭 — 컬럼 이름(단계명)으로 옮긴다. 업무가 속한 '그 프로젝트'의
// 동일 이름 단계 id 를 찾아 적용(프로젝트마다 단계 id 가 다르므로).
function enableDropByName(body, name) {
body.addEventListener("dragover", function (e) { e.preventDefault(); body.classList.add("is-over"); });
body.addEventListener("dragleave", function () { body.classList.remove("is-over"); });
body.addEventListener("drop", async function (e) {
@@ -417,10 +382,15 @@
body.classList.remove("is-over");
const taskId = parseInt(e.dataTransfer.getData("text/plain"), 10);
const t = tasks.find(function (x) { return x.id === taskId; });
if (!t || t.stage_id === stageId) return;
if (!t || (t.stage_name || "") === name) return;
const plist = stagesByProject[t.project_id] || [];
const target = plist.find(function (s) { return s.name === name; });
if (!target) { alert("'" + (t.project_name || "이 프로젝트") + "' 에는 '" + name + "' 단계가 없습니다."); return; }
try {
const res = await api("PUT", "/project/api/tasks/" + taskId, { stage_id: stageId });
const res = await api("PUT", "/project/api/tasks/" + taskId, { stage_id: target.id });
mergeTask(res.task);
const m = tasks.find(function (x) { return x.id === taskId; });
if (m) { m.stage_id = target.id; m.stage_name = name; }
renderBoard();
} catch (err) { alert("이동 실패: " + err.message); }
});
@@ -428,6 +398,7 @@
// ── 리스트 (정렬·컬럼 너비 조절) ──
const LIST_COLS = [
{ key: "project", label: "프로젝트", text: function (t) { return t.project_name || "-"; }, sort: function (t) { return (t.project_name || "").toLowerCase(); } },
{ key: "title", label: "업무", text: function (t) { return t.title; }, sort: function (t) { return (t.title || "").toLowerCase(); } },
{ key: "assignee", label: "담당자", text: function (t) { return t.assignee_email ? idOf(t.assignee_email) : "-"; }, sort: function (t) { return idOf(t.assignee_email || ""); } },
{ key: "stage", label: "단계", text: function (t) { return t.stage_name || "-"; }, sort: function (t) { return t.stage_name || ""; } },
@@ -521,12 +492,22 @@
wireModalClose(taskModal);
let currentTaskId = null;
// 단계 select 를 해당 업무가 속한 프로젝트의 단계로 채운다(전체 뷰에서 타 프로젝트 업무 편집 대비).
function fillStageSelect(projId) {
const sel = el("pj-t-stage");
const list = (projId != null && stagesByProject[projId]) ? stagesByProject[projId] : stages;
sel.innerHTML = list.map(function (s) {
return '<option value="' + s.id + '">' + escapeHtml(s.name) + "</option>";
}).join("");
}
function openTaskModal(task) {
el("pj-task-modal-title").textContent = task ? "업무 편집" : "새 업무";
el("pj-t-id").value = task ? task.id : "";
el("pj-t-title").value = task ? task.title : "";
el("pj-t-desc").value = task ? (task.description || "") : "";
el("pj-t-assignee").value = task ? (task.assignee_email || "") : "";
fillStageSelect(task ? task.project_id : projectId);
el("pj-t-stage").value = task && task.stage_id ? task.stage_id : (stages[0] ? stages[0].id : "");
el("pj-t-priority").value = task ? (task.priority || "normal") : "normal";
el("pj-t-start").value = task ? (task.start_date || "") : "";