feat(project): 프로젝트 페이지에 아사나식 대시보드 탭 추가

보드/리스트/달력/타임라인 옆에 "대시보드" 탭 신설. 서버 재조회 없이 이미
로드된 tasks(visibleTasks(), 스코프 토글도 그대로 따름)로 클라이언트에서
즉시 집계:
- 통계 카드 4개(완료/미완료/마감초과/전체), 숫자가 0에서 카운트업
- 미완료 작업 합계(섹션별) — 세로 막대, 보드 컬럼과 같은 순서
- 작업 합계(완료 상태별) — SVG 도넛(완료=초록/미완료=포인트컬러), 등장 시
  획이 0에서 목표 길이까지 그려짐
- 예정된 작업 합계(담당자별) — 일정 있는 미완료 업무만, 상위 10명+기타

막대/도넛 둘 다 호버 시 값 우선 툴팁, 카드는 순차 페이드인(아사나 느낌).
차트 라이브러리 추가 없이 순수 SVG/CSS로 구현. 탭 5개로 늘면서 좁은
화면에서 텍스트가 줄바꿈되던 버그도 같이 고침(pj-view-tabs flex-shrink).
This commit is contained in:
2026-09-16 17:08:40 +09:00
parent cfa41f0f6f
commit 8634b2602b
5 changed files with 281 additions and 9 deletions
+72 -2
View File
@@ -248,8 +248,8 @@
.pj-view { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
.pj-view[hidden] { display: none; }
.pj-view[data-view="board"]:not([hidden]) { display: flex; flex-direction: column; overflow: hidden; }
.pj-view-tabs { display: inline-flex; background: #eef0f2; border-radius: 10px; padding: 3px; gap: 2px; }
.pj-tab { border: none; background: transparent; padding: 7px 16px; border-radius: 8px; font-size: 13px; font-weight: 600; color: #525860; cursor: pointer; }
.pj-view-tabs { display: inline-flex; flex: 0 0 auto; background: #eef0f2; border-radius: 10px; padding: 3px; gap: 2px; }
.pj-tab { border: none; background: transparent; padding: 7px 16px; border-radius: 8px; font-size: 13px; font-weight: 600; color: #525860; cursor: pointer; white-space: nowrap; }
.pj-tab.is-active { background: var(--pj-accent); color: #fff; box-shadow: 0 1px 3px rgba(123,104,238,.35); }
/* 알림 벨 흔들림(미읽음 있을 때) */
@@ -731,3 +731,73 @@ label.pj-desc-fmtbtn { line-height: 26px; }
.pj-ctxmenu-item.is-danger { color: #c22b10; }
.pj-ctxmenu-item.is-danger:hover { background: #fbecea; }
.pj-ctxmenu-sep { height: 1px; background: var(--pj-border); margin: 4px 2px; }
/* ── 대시보드 — 통계 카드 + 차트(전부 클라이언트 집계/렌더). 카드 자체는
pj-col 과 같은 흰 카드 톤(radius/shadow)을 재사용, 숫자·막대·도넛만
애니메이션(등장 시 0→목표값으로 부드럽게, 아사나 느낌). ── */
.pj-dash { display: flex; flex-direction: column; gap: 20px; padding: 4px 2px 24px; }
.pj-dash-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
.pj-dash-stat {
background: #fff; border: 1px solid var(--pj-border); border-radius: var(--pj-card-radius);
box-shadow: var(--pj-card-shadow); padding: 18px 20px;
}
.pj-dash-stat-label { font-size: 12.5px; color: var(--pj-ink-soft); font-weight: 600; margin-bottom: 10px; }
.pj-dash-stat-value { font-size: 34px; font-weight: 700; color: var(--pj-ink); line-height: 1; }
.pj-dash-charts { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; }
.pj-dash-card {
background: #fff; border: 1px solid var(--pj-border); border-radius: var(--pj-card-radius);
box-shadow: var(--pj-card-shadow); padding: 18px 20px 20px; min-width: 0;
}
.pj-dash-card-wide { grid-column: 1 / -1; }
.pj-dash-card-title { font-size: 13.5px; font-weight: 700; color: var(--pj-ink); margin-bottom: 18px; }
/* 세로 막대(섹션별/담당자별 공용) */
.pj-dash-bars { display: flex; align-items: flex-end; gap: 18px; overflow-x: auto; padding-bottom: 2px; min-height: 200px; }
.pj-dash-barcol { display: flex; flex-direction: column; align-items: center; flex: 0 0 auto; min-width: 52px; cursor: default; border-radius: 8px; outline: none; }
.pj-dash-barcol:focus-visible { box-shadow: 0 0 0 2px var(--pj-accent-tint); }
.pj-dash-barcol-value { font-size: 12.5px; font-weight: 700; color: var(--pj-ink); margin-bottom: 6px; }
.pj-dash-barcol-track {
position: relative; width: 34px; height: 140px; background: var(--pj-accent-tint);
border-radius: 6px 6px 4px 4px; overflow: hidden;
}
.pj-dash-barcol-fill {
position: absolute; left: 0; right: 0; bottom: 0; height: 0%;
background: var(--pj-accent); border-radius: 6px 6px 0 0;
transition: height .7s cubic-bezier(.22,.9,.3,1);
}
.pj-dash-barcol:hover .pj-dash-barcol-fill { background: var(--pj-accent-dark); }
.pj-dash-barcol-label {
margin-top: 8px; font-size: 11.5px; color: var(--pj-ink-soft); text-align: center;
max-width: 72px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* 도넛(완료 상태별) */
.pj-dash-donut-wrap { display: flex; align-items: center; gap: 24px; flex-wrap: wrap; }
.pj-dash-donut { width: 150px; height: 150px; flex: 0 0 auto; }
.pj-dash-donut-track { fill: none; stroke: var(--pj-border); stroke-width: 14; }
.pj-dash-donut-seg { fill: none; stroke-width: 14; stroke-linecap: butt; transition: stroke-dasharray .8s cubic-bezier(.22,.9,.3,1); }
.pj-dash-donut-center { font-size: 26px; font-weight: 700; fill: var(--pj-ink); }
.pj-dash-legend { display: flex; flex-direction: column; gap: 10px; }
.pj-dash-legend-item { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--pj-ink); }
.pj-dash-legend-item b { margin-left: 2px; }
.pj-dash-legend-dot { width: 10px; height: 10px; border-radius: 50%; flex: 0 0 auto; }
/* 막대/세그먼트 호버 툴팁 — 값이 크게, 라벨은 보조(값 우선 원칙) */
.pj-dash-tooltip {
position: absolute; z-index: 2150; transform: translate(-50%, -100%);
background: var(--pj-ink); color: #fff; padding: 6px 10px; border-radius: 8px;
font-size: 12px; white-space: nowrap; pointer-events: none;
box-shadow: var(--pj-card-shadow-hover);
}
.pj-dash-tooltip b { font-size: 13.5px; margin-right: 6px; }
.pj-dash-tooltip span { color: #cfd3da; }
/* 카드 등장 트랜지션 — 대시보드 탭을 열 때마다 살짝 떠오르며 나타난다. */
.pj-dash-stat, .pj-dash-card { animation: pj-dash-in .35s cubic-bezier(.22,.9,.3,1) both; }
.pj-dash-stats .pj-dash-stat:nth-child(2) { animation-delay: .04s; }
.pj-dash-stats .pj-dash-stat:nth-child(3) { animation-delay: .08s; }
.pj-dash-stats .pj-dash-stat:nth-child(4) { animation-delay: .12s; }
.pj-dash-charts .pj-dash-card:nth-child(2) { animation-delay: .06s; }
.pj-dash-charts .pj-dash-card:nth-child(3) { animation-delay: .12s; }
@keyframes pj-dash-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
@media (max-width: 900px) { .pj-dash-stats { grid-template-columns: repeat(2, 1fr); } }
+197 -1
View File
@@ -1191,7 +1191,7 @@
let currentView = "board";
try {
const saved = localStorage.getItem(VIEW_KEY);
if (saved && ["board", "list", "calendar", "timeline"].indexOf(saved) >= 0) currentView = saved;
if (saved && ["board", "list", "calendar", "timeline", "dashboard"].indexOf(saved) >= 0) currentView = saved;
} catch (_) {}
// ── 프로젝트 스코프 — 아사나처럼 기본은 "이 프로젝트만", "전체 프로젝트"로
@@ -1252,6 +1252,7 @@
else if (view === "timeline") renderTimeline();
else if (view === "board") renderBoard();
else if (view === "list") renderList();
else if (view === "dashboard") renderDashboard();
}
// ════════════════════════════════════════════════════════════
@@ -2176,6 +2177,201 @@
}
}
// ════════════════════════════════════════════════════════════
// 대시보드 — 통계 카드 + 차트 3종. 전부 이미 로드된 tasks(visibleTasks())
// 로 클라이언트에서 즉시 집계 — 서버 재조회 없음. 스코프 토글(이
// 프로젝트/전체 프로젝트)도 다른 뷰와 똑같이 따른다.
// ════════════════════════════════════════════════════════════
function animateCount(el, target, duration) {
const start = 0;
const t0 = performance.now();
function step(now) {
const p = Math.min(1, (now - t0) / duration);
const eased = 1 - Math.pow(1 - p, 3); // ease-out cubic
el.textContent = Math.round(start + (target - start) * eased);
if (p < 1) requestAnimationFrame(step);
else el.textContent = String(target);
}
requestAnimationFrame(step);
}
function dashStatTile(label, value) {
const tile = document.createElement("div");
tile.className = "pj-dash-stat";
tile.innerHTML =
'<div class="pj-dash-stat-label"></div>' +
'<div class="pj-dash-stat-value">0</div>';
tile.querySelector(".pj-dash-stat-label").textContent = label;
const valEl = tile.querySelector(".pj-dash-stat-value");
animateCount(valEl, value, 700);
return tile;
}
// 세로 막대 그래프 — 섹션별/담당자별 공용. data = [{label, value}].
function dashBarChart(data) {
const wrap = document.createElement("div");
wrap.className = "pj-dash-bars";
if (!data.length) {
wrap.innerHTML = "<div class='pj-empty-sm'>데이터가 없습니다.</div>";
return wrap;
}
const max = Math.max.apply(null, data.map(function (d) { return d.value; }).concat([1]));
const tooltip = dashTooltip();
data.forEach(function (d) {
const col = document.createElement("div");
col.className = "pj-dash-barcol";
col.tabIndex = 0;
col.innerHTML =
'<div class="pj-dash-barcol-value">' + d.value + "</div>" +
'<div class="pj-dash-barcol-track"><div class="pj-dash-barcol-fill"></div></div>' +
'<div class="pj-dash-barcol-label"></div>';
col.querySelector(".pj-dash-barcol-label").textContent = d.label;
const fill = col.querySelector(".pj-dash-barcol-fill");
const pct = Math.round((d.value / max) * 100);
requestAnimationFrame(function () {
requestAnimationFrame(function () { fill.style.height = pct + "%"; });
});
function showTip() { tooltip.show(col, d.label, d.value); }
col.addEventListener("mouseenter", showTip);
col.addEventListener("focus", showTip);
col.addEventListener("mouseleave", tooltip.hide);
col.addEventListener("blur", tooltip.hide);
wrap.appendChild(col);
});
return wrap;
}
// 우클릭 메뉴와 같은 자리에 하나만 떠 있는 공용 호버 툴팁(값 강조, 카테고리는 보조).
function dashTooltip() {
let el = null;
return {
show: function (anchor, label, value) {
if (!el) {
el = document.createElement("div");
el.className = "pj-dash-tooltip";
document.body.appendChild(el);
}
el.innerHTML = '<b></b><span></span>';
el.querySelector("b").textContent = value;
el.querySelector("span").textContent = label;
const r = anchor.getBoundingClientRect();
el.style.left = (window.scrollX + r.left + r.width / 2) + "px";
el.style.top = (window.scrollY + r.top - 8) + "px";
el.hidden = false;
},
hide: function () { if (el) el.hidden = true; },
};
}
// 도넛(완료 상태별) — SVG stroke-dasharray 로 두 구간을 그린다.
function dashDonutChart(segments) {
const total = segments.reduce(function (s, x) { return s + x.value; }, 0);
const R = 52, C = 2 * Math.PI * R;
const wrap = document.createElement("div");
wrap.className = "pj-dash-donut-wrap";
let svg = '<svg viewBox="0 0 120 120" class="pj-dash-donut">' +
'<circle class="pj-dash-donut-track" cx="60" cy="60" r="' + R + '"></circle>';
let offsetAcc = 0;
segments.forEach(function (s, i) {
const frac = total ? s.value / total : 0;
const len = frac * C;
svg += '<circle class="pj-dash-donut-seg" data-i="' + i + '" cx="60" cy="60" r="' + R + '" ' +
'stroke="' + s.color + '" stroke-dasharray="0 ' + C + '" stroke-dashoffset="' + (-offsetAcc) + '" ' +
'transform="rotate(-90 60 60)"></circle>';
offsetAcc += len;
});
svg += '<text x="60" y="65" class="pj-dash-donut-center" text-anchor="middle"></text></svg>';
wrap.innerHTML = svg;
wrap.querySelector(".pj-dash-donut-center").textContent = total;
const legend = document.createElement("div");
legend.className = "pj-dash-legend";
segments.forEach(function (s) {
const item = document.createElement("div");
item.className = "pj-dash-legend-item";
item.innerHTML = '<span class="pj-dash-legend-dot"></span><span class="pj-dash-legend-label"></span> <b></b>';
item.querySelector(".pj-dash-legend-dot").style.background = s.color;
item.querySelector(".pj-dash-legend-label").textContent = s.label;
item.querySelector("b").textContent = s.value;
legend.appendChild(item);
});
wrap.appendChild(legend);
// 다음 프레임에 목표 길이로 트랜지션(애니메이션이 실제로 재생되도록 한 틱 늦춘다).
requestAnimationFrame(function () {
requestAnimationFrame(function () {
const arcs = wrap.querySelectorAll(".pj-dash-donut-seg");
segments.forEach(function (s, i) {
const frac = total ? s.value / total : 0;
const len = frac * C;
arcs[i].setAttribute("stroke-dasharray", len + " " + C);
});
});
});
return wrap;
}
function renderDashboard() {
const root = el("pj-dash");
if (!root) return;
root.innerHTML = "";
const vis = visibleTasks().filter(function (t) { return !t.parent_task_id; }); // 하위 업무는 보드에도 안 보이므로 제외
const today = new Date().toISOString().slice(0, 10);
const completed = vis.filter(function (t) { return !!t.completed_at; });
const incomplete = vis.filter(function (t) { return !t.completed_at; });
const overdue = incomplete.filter(function (t) { return t.due_date && t.due_date.slice(0, 10) < today; });
const stats = document.createElement("div");
stats.className = "pj-dash-stats";
stats.appendChild(dashStatTile("완료된 작업 합계", completed.length));
stats.appendChild(dashStatTile("미완료 작업 합계", incomplete.length));
stats.appendChild(dashStatTile("마감일이 지난 작업 합계", overdue.length));
stats.appendChild(dashStatTile("작업 합계", vis.length));
root.appendChild(stats);
const charts = document.createElement("div");
charts.className = "pj-dash-charts";
// 섹션별 미완료 — "이 프로젝트" 스코프면 보드 컬럼과 같은 순서로.
const sectionOrder = viewScope === "project"
? (stagesByProject[projectId] || []).slice().sort(function (a, b) { return a.sort_order - b.sort_order; }).map(function (s) { return s.name; })
: boardStages.slice();
const bySection = {};
incomplete.forEach(function (t) { const k = t.stage_name || "미지정"; bySection[k] = (bySection[k] || 0) + 1; });
const sectionNames = sectionOrder.filter(function (n) { return bySection[n]; });
Object.keys(bySection).forEach(function (n) { if (sectionNames.indexOf(n) < 0) sectionNames.push(n); });
const sectionData = sectionNames.map(function (n) { return { label: n, value: bySection[n] }; });
const sectionCard = document.createElement("div");
sectionCard.className = "pj-dash-card";
sectionCard.innerHTML = '<div class="pj-dash-card-title">미완료 작업 합계(섹션별)</div>';
sectionCard.appendChild(dashBarChart(sectionData));
charts.appendChild(sectionCard);
// 완료 상태별 — 2구간 도넛(완료=초록, 미완료=포인트 컬러).
const statusCard = document.createElement("div");
statusCard.className = "pj-dash-card";
statusCard.innerHTML = '<div class="pj-dash-card-title">작업 합계(완료 상태별)</div>';
statusCard.appendChild(dashDonutChart([
{ label: "완료", value: completed.length, color: "var(--color-success-green, #2e9e5b)" },
{ label: "미완료", value: incomplete.length, color: "var(--pj-accent)" },
]));
charts.appendChild(statusCard);
// 담당자별 예정된 업무 — 미완료 + 일정(시작/마감일) 있는 업무만, 상위 10명.
const byAssignee = {};
incomplete.forEach(function (t) {
if (!t.start_date && !t.due_date) return;
const k = t.assignee_email ? (t.assignee_name || idOf(t.assignee_email)) : "미지정";
byAssignee[k] = (byAssignee[k] || 0) + 1;
});
let assigneeEntries = Object.keys(byAssignee).map(function (k) { return { label: k, value: byAssignee[k] }; })
.sort(function (a, b) { return b.value - a.value; });
if (assigneeEntries.length > 10) {
const rest = assigneeEntries.slice(10).reduce(function (s, x) { return s + x.value; }, 0);
assigneeEntries = assigneeEntries.slice(0, 10).concat([{ label: "기타", value: rest }]);
}
const assigneeCard = document.createElement("div");
assigneeCard.className = "pj-dash-card pj-dash-card-wide";
assigneeCard.innerHTML = '<div class="pj-dash-card-title">예정된 작업 합계(담당자별)</div>';
assigneeCard.appendChild(dashBarChart(assigneeEntries));
charts.appendChild(assigneeCard);
root.appendChild(charts);
}
function mergeTask(task) {
const i = tasks.findIndex(function (x) { return x.id === task.id; });
if (i >= 0) tasks[i] = Object.assign({}, tasks[i], task);