diff --git a/app/modules/project/router.py b/app/modules/project/router.py
index 35b1462..b4d8def 100644
--- a/app/modules/project/router.py
+++ b/app/modules/project/router.py
@@ -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(),
diff --git a/app/modules/project/templates/project/index.html b/app/modules/project/templates/project/index.html
index 569b92d..bd97bde 100644
--- a/app/modules/project/templates/project/index.html
+++ b/app/modules/project/templates/project/index.html
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
-
+
{% endblock %}
@@ -116,5 +116,5 @@
-
+
{% endblock %}
diff --git a/app/modules/project/templates/project/project.html b/app/modules/project/templates/project/project.html
index 0f4e58e..a93fe34 100644
--- a/app/modules/project/templates/project/project.html
+++ b/app/modules/project/templates/project/project.html
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
-
+
@@ -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 }}
}
-
+
{% endblock %}
diff --git a/app/static/project.css b/app/static/project.css
index 96fb3fb..bfde81d 100644
--- a/app/static/project.css
+++ b/app/static/project.css
@@ -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; }
diff --git a/app/static/project.js b/app/static/project.js
index 5770796..7911bd8 100644
--- a/app/static/project.js
+++ b/app/static/project.js
@@ -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 = '
기간이 설정된 업무가 없습니다.
'; 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 = '기간이 설정된 업무가 없습니다.
'; 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 =
- '' +
- '' +
- '' +
- '' +
- '' +
- '' +
- "";
- }
+ col.dataset.stageName = name;
col.innerHTML =
- '' + escapeHtml(stage.name) + "" +
- (stage.is_done_stage ? ' 완료' : "") +
- '' + controls + "
" +
+ '' + escapeHtml(name) + "" +
+ '
' +
'';
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
+ ? '● ' + escapeHtml(t.project_name) + "
"
+ : "") +
'' + escapeHtml(t.title) + "
" +
'' +
(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 '";
+ }).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 || "") : "";