feat(project): 프로젝트 페이지에 아사나식 대시보드 탭 추가
보드/리스트/달력/타임라인 옆에 "대시보드" 탭 신설. 서버 재조회 없이 이미 로드된 tasks(visibleTasks(), 스코프 토글도 그대로 따름)로 클라이언트에서 즉시 집계: - 통계 카드 4개(완료/미완료/마감초과/전체), 숫자가 0에서 카운트업 - 미완료 작업 합계(섹션별) — 세로 막대, 보드 컬럼과 같은 순서 - 작업 합계(완료 상태별) — SVG 도넛(완료=초록/미완료=포인트컬러), 등장 시 획이 0에서 목표 길이까지 그려짐 - 예정된 작업 합계(담당자별) — 일정 있는 미완료 업무만, 상위 10명+기타 막대/도넛 둘 다 호버 시 값 우선 툴팁, 카드는 순차 페이드인(아사나 느낌). 차트 라이브러리 추가 없이 순수 SVG/CSS로 구현. 탭 5개로 늘면서 좁은 화면에서 텍스트가 줄바꿈되던 버그도 같이 고침(pj-view-tabs flex-shrink).
This commit is contained in:
+197
-1
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user