diff --git a/app/modules/project/router.py b/app/modules/project/router.py index bff4c55..35b1462 100644 --- a/app/modules/project/router.py +++ b/app/modules/project/router.py @@ -321,6 +321,21 @@ async def index(request: Request) -> HTMLResponse: ) tree = _build_tree(projects) my_tasks = st.list_tasks(assignee_email=user["email"], limit=500) + # 내 업무를 프로젝트별로 묶어 트리로 표시 + my_groups: dict[int, dict[str, Any]] = {} + for t in my_tasks: + pid = t.get("project_id") + g = my_groups.get(pid) + if g is None: + g = { + "id": pid, + "name": t.get("project_name") or "프로젝트", + "color": t.get("project_color") or "#4573d2", + "tasks": [], + } + my_groups[pid] = g + g["tasks"].append(t) + my_projects = list(my_groups.values()) return render_template( request, "project/index.html", @@ -331,7 +346,7 @@ async def index(request: Request) -> HTMLResponse: "page_title": "프로젝트 관리", "page_subtitle": "달력·타임라인·보드로 프로젝트를 관리하세요.", "tree": tree, - "my_tasks": my_tasks, + "my_projects": my_projects, "today": today_kst().isoformat(), }, ) diff --git a/app/modules/project/templates/project/inbox.html b/app/modules/project/templates/project/inbox.html index 9d99532..2b9efa2 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 33a74d8..569b92d 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 %} @@ -54,22 +54,38 @@ @@ -100,5 +116,5 @@ - + {% endblock %} diff --git a/app/modules/project/templates/project/project.html b/app/modules/project/templates/project/project.html index e208cd9..0f4e58e 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 %} - + @@ -297,5 +297,5 @@ - + {% endblock %} diff --git a/app/static/project.css b/app/static/project.css index af3056a..96fb3fb 100644 --- a/app/static/project.css +++ b/app/static/project.css @@ -98,7 +98,18 @@ .pj-toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; } .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-tab.is-active { background: #fff; color: #1e1f21; box-shadow: 0 1px 3px rgba(0,0,0,.08); } +.pj-tab.is-active { background: #1e1f21; color: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.18); } + +/* 알림 벨 흔들림(미읽음 있을 때) */ +@keyframes pj-bell-ring { + 0%, 50%, 100% { transform: rotate(0); } + 10%, 30% { transform: rotate(14deg); } + 20%, 40% { transform: rotate(-14deg); } +} +.pj-bell.has-unread .material-symbols-outlined { + animation: pj-bell-ring 1.4s ease-in-out infinite; + transform-origin: top center; +} /* ── 보드(칸반) ── */ .pj-board { display: flex; gap: 14px; overflow-x: auto; padding-bottom: 8px; align-items: flex-start; } @@ -120,9 +131,29 @@ /* ── 리스트 ── */ .pj-table { width: 100%; border-collapse: collapse; font-size: 13.5px; } -.pj-table th { text-align: left; padding: 9px 10px; border-bottom: 2px solid #eef0f2; color: #6b7280; font-size: 12px; } +.pj-table th { text-align: left; padding: 9px 10px; border-bottom: 2px solid #eef0f2; color: #1e1f21; font-size: 14px; font-weight: 700; position: relative; } .pj-table td { padding: 10px; border-bottom: 1px solid #f1f2f4; } .pj-table tr.is-done td { color: #9aa1a9; text-decoration: line-through; } +.pj-table-fixed { table-layout: fixed; } +.pj-table-fixed th, .pj-table-fixed td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.pj-th-label { cursor: pointer; user-select: none; display: inline-flex; align-items: center; gap: 4px; } +.pj-th-label:hover { color: #4573d2; } +.pj-th-sort { font-size: 11px; color: #9aa1a9; } +.pj-th-resize { position: absolute; top: 0; right: -3px; width: 8px; height: 100%; cursor: col-resize; z-index: 2; } +.pj-th-resize:hover { background: linear-gradient(90deg, transparent 40%, #c9d4ea 40%, #c9d4ea 60%, transparent 60%); } + +/* ── 내 업무 트리(홈) ── */ +.pj-mytree-group { margin-bottom: 14px; } +.pj-mytree-proj { display: flex; align-items: center; gap: 7px; text-decoration: none; color: #1e1f21; font-weight: 700; font-size: 13.5px; padding: 4px 2px; border-radius: 7px; } +.pj-mytree-proj:hover { background: #f6f7f8; } +.pj-mytree-tasks { list-style: none; margin: 2px 0 0; padding: 0 0 0 8px; border-left: 2px solid #eef0f2; margin-left: 10px; } +.pj-mytree-task a { display: flex; align-items: flex-start; gap: 7px; padding: 6px 6px; border-radius: 7px; text-decoration: none; color: inherit; } +.pj-mytree-task a:hover { background: #f6f7f8; } +.pj-mytree-ic { font-size: 16px; flex: 0 0 auto; margin-top: 1px; } +.pj-mytree-body { display: flex; flex-direction: column; min-width: 0; } +.pj-mytree-title { font-size: 13px; font-weight: 600; } +.pj-mytree-dates { font-size: 11px; color: #9aa1a9; margin-top: 1px; } +.pj-mytree-task.is-done .pj-mytree-title { text-decoration: line-through; color: #9aa1a9; } /* ── 달력 (휴가 모듈과 동일한 월간 그리드 룩) ── */ .pj-cal-head { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; } diff --git a/app/static/project.js b/app/static/project.js index fa58d9a..5770796 100644 --- a/app/static/project.js +++ b/app/static/project.js @@ -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 = '
기간이 설정된 업무가 없습니다.
'; 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 = + '' + escapeHtml(col.label) + + ' ' + arrow + "" + + ''; + 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 = - "" + escapeHtml(t.title) + "" + - "" + (t.assignee_email ? escapeHtml(idOf(t.assignee_email)) : "-") + "" + - "" + escapeHtml(t.stage_name || "-") + "" + - "" + (priorityLabels[t.priority] || t.priority || "-") + "" + - "" + (t.start_date || "-") + "" + - "" + (t.due_date || "-") + ""; + tr.innerHTML = LIST_COLS.map(function (c) { + return "" + escapeHtml(String(c.text(t))) + ""; + }).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 (_) {} }