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 = '' + + ''; + let offsetAcc = 0; + segments.forEach(function (s, i) { + const frac = total ? s.value / total : 0; + const len = frac * C; + svg += ''; + offsetAcc += len; + }); + 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);