From e36fcb1a348707e0b79248da232691c77535a89b Mon Sep 17 00:00:00 2001 From: king Date: Thu, 25 Jun 2026 16:23:49 +0900 Subject: [PATCH] =?UTF-8?q?feat(project):=20=EB=8B=AC=EB=A0=A5=C2=B7?= =?UTF-8?q?=ED=83=80=EC=9E=84=EB=9D=BC=EC=9D=B8=C2=B7=EB=B3=B4=EB=93=9C?= =?UTF-8?q?=C2=B7=EB=A6=AC=EC=8A=A4=ED=8A=B8=204=EA=B0=9C=20=EB=B7=B0?= =?UTF-8?q?=EB=A5=BC=20=EC=A0=84=EC=B2=B4=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - project_page 가 접근 가능한 모든 프로젝트의 업무를 집계(all_tasks) - 달력: 전 프로젝트 bar(라벨에 [프로젝트명] 유지) - 타임라인: 프로젝트별 행(그룹) + 행마다 전체기간 배경 + 업무 막대 - 보드: 단계 '이름' 기준 컬럼으로 전 프로젝트 묶음, 카드에 프로젝트 배지 드롭 시 해당 업무 프로젝트의 동일 이름 단계로 이동 - 리스트: '프로젝트' 컬럼 추가 - 모달 단계 선택은 해당 업무 프로젝트의 단계로 채움(타 프로젝트 편집 대비) Co-Authored-By: Claude Opus 4.8 --- app/modules/project/router.py | 33 +++- .../project/templates/project/index.html | 4 +- .../project/templates/project/project.html | 9 +- app/static/project.css | 3 + app/static/project.js | 169 ++++++++---------- 5 files changed, 115 insertions(+), 103 deletions(-) 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 || "") : "";