feat(project): 내업무 트리·벨 애니메이션·타임라인 그룹·리스트 정렬/리사이즈·검정 탭

- 홈 '내 업무': 프로젝트별 그룹 트리, 업무 아래 날짜 기간 작은 글씨
- 알림 벨: 미읽음 있으면 종 흔들림 애니메이션 + 숫자 배지(기존)
- 타임라인: 프로젝트 전체 기간을 연한 색 배경으로 칠하고(그룹 행) 그 안에
  업무 막대 표시 → 업무명에 프로젝트명 불필요
- 리스트: 컬럼 헤더 클릭 정렬(오름/내림 아이콘), 컬럼 너비 드래그 조절+
  localStorage 저장, 헤더 글씨 크고 굵게
- 상단 뷰 탭 선택 시 검은 배경 흰 글씨

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 16:04:45 +09:00
parent 88ce4ee378
commit d89aa349eb
6 changed files with 182 additions and 35 deletions
+99 -14
View File
@@ -227,6 +227,9 @@
// ── 타임라인 (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) {
const s = t.start_date || t.due_date;
@@ -237,18 +240,36 @@
const isRange = (e !== s) || (!!t.start_time && !!t.due_time && t.start_time !== t.due_time);
return {
id: t.id,
content: t.title,
group: grp,
content: t.title, // 프로젝트명 없이 업무명만
start: startISO,
end: isRange ? endISO : undefined,
type: isRange ? "range" : "point",
style: "background-color:" + (t.completed_at ? "#9aa5b1" : (t.project_color || "#4573d2")) + ";color:#fff;border:none;",
style: "background-color:" + (t.completed_at ? "#9aa5b1" : projColor) + ";color:#fff;border:none;",
};
});
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 koWd = function (d) { return ["일", "월", "화", "수", "목", "금", "토"][d.getDay()]; };
const pad2 = function (n) { return ("0" + n).slice(-2); };
timeline = new vis.Timeline(node, new vis.DataSet(items), {
timeline = new vis.Timeline(node, new vis.DataSet(items.concat([bg])), groups, {
stack: true, height: "520px",
horizontalScroll: true, // 마우스 휠 = 좌우 이동(패닝)
verticalScroll: false,
@@ -405,20 +426,79 @@
});
}
// ── 리스트 ──
// ── 리스트 (정렬·컬럼 너비 조절) ──
const LIST_COLS = [
{ 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 || ""; } },
{ key: "priority", label: "우선순위", text: function (t) { return priorityLabels[t.priority] || t.priority || "-"; }, sort: function (t) { return ({ low: 0, normal: 1, high: 2 })[t.priority]; } },
{ key: "start", label: "시작", text: function (t) { return t.start_date || "-"; }, sort: function (t) { return t.start_date || ""; } },
{ key: "due", label: "마감", text: function (t) { return t.due_date || "-"; }, sort: function (t) { return t.due_date || ""; } },
];
let listSort = { key: null, dir: 1 };
function listWidths() { try { return JSON.parse(localStorage.getItem("pj_list_widths") || "{}"); } catch (_) { return {}; } }
function saveListWidth(key, w) { const m = listWidths(); m[key] = w; try { localStorage.setItem("pj_list_widths", JSON.stringify(m)); } catch (_) {} }
function enableColResize(th, key) {
const handle = th.querySelector(".pj-th-resize");
if (!handle) return;
handle.addEventListener("mousedown", function (e) {
e.preventDefault(); e.stopPropagation();
const startX = e.clientX, startW = th.offsetWidth;
function mv(ev) { th.style.width = Math.max(60, startW + (ev.clientX - startX)) + "px"; }
function up() { document.removeEventListener("mousemove", mv); document.removeEventListener("mouseup", up); saveListWidth(key, th.offsetWidth); }
document.addEventListener("mousemove", mv);
document.addEventListener("mouseup", up);
});
}
function renderList() {
const tbody = el("pj-list").querySelector("tbody");
const table = el("pj-list");
table.classList.add("pj-table-fixed");
const widths = listWidths();
const trh = document.createElement("tr");
LIST_COLS.forEach(function (col) {
const th = document.createElement("th");
th.dataset.key = col.key;
if (widths[col.key]) th.style.width = widths[col.key] + "px";
const arrow = listSort.key === col.key ? (listSort.dir > 0 ? "▲" : "▼") : "↕";
th.innerHTML =
'<span class="pj-th-label">' + escapeHtml(col.label) +
' <span class="pj-th-sort">' + arrow + "</span></span>" +
'<span class="pj-th-resize"></span>';
th.querySelector(".pj-th-label").addEventListener("click", function () {
if (listSort.key === col.key) listSort.dir = -listSort.dir;
else { listSort.key = col.key; listSort.dir = 1; }
renderList();
});
enableColResize(th, col.key);
trh.appendChild(th);
});
const thead = table.querySelector("thead");
thead.innerHTML = "";
thead.appendChild(trh);
let rows = tasks.slice();
if (listSort.key) {
const col = LIST_COLS.find(function (c) { return c.key === listSort.key; });
rows.sort(function (a, b) {
let va = col.sort(a), vb = col.sort(b);
if (va == null) va = -Infinity;
if (vb == null) vb = -Infinity;
if (va < vb) return -listSort.dir;
if (va > vb) return listSort.dir;
return 0;
});
}
const tbody = table.querySelector("tbody");
tbody.innerHTML = "";
tasks.forEach(function (t) {
rows.forEach(function (t) {
const tr = document.createElement("tr");
if (t.completed_at) tr.className = "is-done";
tr.innerHTML =
"<td>" + escapeHtml(t.title) + "</td>" +
"<td>" + (t.assignee_email ? escapeHtml(idOf(t.assignee_email)) : "-") + "</td>" +
"<td>" + escapeHtml(t.stage_name || "-") + "</td>" +
"<td>" + (priorityLabels[t.priority] || t.priority || "-") + "</td>" +
"<td>" + (t.start_date || "-") + "</td>" +
"<td>" + (t.due_date || "-") + "</td>";
tr.innerHTML = LIST_COLS.map(function (c) {
return "<td>" + escapeHtml(String(c.text(t))) + "</td>";
}).join("");
if (canManage) { tr.style.cursor = "pointer"; tr.addEventListener("click", function () { openTaskModal(t); }); }
tbody.appendChild(tr);
});
@@ -795,11 +875,16 @@
async function initBell() {
const badge = document.getElementById("pj-bell-badge");
if (!badge) return;
const bell = badge.closest(".pj-bell");
try {
const res = await fetch("/project/api/notifications/unread-count");
if (!res.ok) return;
const d = await res.json();
if (d.unread > 0) { badge.textContent = d.unread > 99 ? "99+" : d.unread; badge.hidden = false; }
if (d.unread > 0) {
badge.textContent = d.unread > 99 ? "99+" : d.unread;
badge.hidden = false;
if (bell) bell.classList.add("has-unread"); // 종 흔들림 애니메이션
}
} catch (_) {}
}