diff --git a/app/modules/project/templates/project/inbox.html b/app/modules/project/templates/project/inbox.html
index f779d7f..e25bf92 100644
--- a/app/modules/project/templates/project/inbox.html
+++ b/app/modules/project/templates/project/inbox.html
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
-
+
{% endblock %}
@@ -45,5 +45,5 @@
{% endif %}
-
+
{% endblock %}
diff --git a/app/modules/project/templates/project/index.html b/app/modules/project/templates/project/index.html
index 6705ee8..fef4c7b 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 %}
@@ -393,5 +393,5 @@
-
+
{% endblock %}
diff --git a/app/modules/project/templates/project/project.html b/app/modules/project/templates/project/project.html
index 3986f4d..b1f1b66 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 %}
-
+
@@ -100,6 +100,7 @@
+
{{ project.name }}
+
@@ -461,5 +467,5 @@
-
+
{% endblock %}
diff --git a/app/static/project.css b/app/static/project.css
index 02fdbb0..2af7d84 100644
--- a/app/static/project.css
+++ b/app/static/project.css
@@ -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); } }
diff --git a/app/static/project.js b/app/static/project.js
index 8deafa8..b295a60 100644
--- a/app/static/project.js
+++ b/app/static/project.js
@@ -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 =
+ '' +
+ '0
';
+ 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 = "데이터가 없습니다.
";
+ 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 =
+ '' + d.value + "
" +
+ '' +
+ '';
+ 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 = '';
+ 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 = '';
+ 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 = ' ';
+ 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 = '미완료 작업 합계(섹션별)
';
+ sectionCard.appendChild(dashBarChart(sectionData));
+ charts.appendChild(sectionCard);
+
+ // 완료 상태별 — 2구간 도넛(완료=초록, 미완료=포인트 컬러).
+ const statusCard = document.createElement("div");
+ statusCard.className = "pj-dash-card";
+ statusCard.innerHTML = '작업 합계(완료 상태별)
';
+ 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 = '예정된 작업 합계(담당자별)
';
+ 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);