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
+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 || "") : "";