diff --git a/app/modules/project/templates/project/inbox.html b/app/modules/project/templates/project/inbox.html index e703b3b..2590188 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 d9da281..eb07ad4 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 %} @@ -349,5 +349,5 @@ - + {% endblock %} diff --git a/app/modules/project/templates/project/project.html b/app/modules/project/templates/project/project.html index 9ea5ac1..544f70b 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 %} - + @@ -41,15 +41,8 @@ {% for p in nav_projects %}
  • - {% if p.tasks %} - - {% else %} - - {% endif %} - folder + {{ p.name[0] | upper }} {{ p.name }} {% if is_admin %} @@ -58,25 +51,6 @@ {% endif %}
    - {% if p.tasks %} - - {% endif %}
  • {% endfor %} @@ -331,5 +305,5 @@ - + {% endblock %} diff --git a/app/static/project.css b/app/static/project.css index 0243a6d..64b1982 100644 --- a/app/static/project.css +++ b/app/static/project.css @@ -1,17 +1,30 @@ /* 프로젝트 관리(아사나식) 모듈 스타일. - * 기존 erp.css 토큰 위에 아사나 느낌(밝은 캔버스, 색 점, 칸반 컬럼)을 얹는다. */ + * 기존 erp.css 토큰(폰트 등) 위에 아사나 느낌의 색·카드·그림자를 별도 변수로 + * 얹는다 — 다른 모듈(erp.css)은 그대로 두고 이 모듈(.pj- 클래스)에만 적용. */ -.pj-wrap { padding: 4px 2px 40px; } +:root { + --pj-accent: #7b68ee; /* 아사나 특유의 보라 포인트 컬러 */ + --pj-accent-dark: #6453d6; + --pj-accent-tint: #f1eeff; /* 선택/호버 배경(연한 보라) */ + --pj-ink: #151b26; /* 본문 텍스트(진한 남색 계열, 순검정 대신) */ + --pj-ink-soft: #6b6f76; /* 보조 텍스트 */ + --pj-border: #e4e4e7; + --pj-card-radius: 12px; + --pj-card-shadow: 0 1px 2px rgba(16,24,40,.04), 0 2px 6px rgba(16,24,40,.06); + --pj-card-shadow-hover: 0 6px 16px rgba(16,24,40,.10), 0 2px 4px rgba(16,24,40,.06); +} + +.pj-wrap { padding: 4px 2px 40px; color: var(--pj-ink); } /* ── 버튼 ── */ .pj-btn { - border: 1px solid #d8dce2; background: #fff; color: #1e1f21; + border: 1px solid #d8dce2; background: #fff; color: var(--pj-ink); padding: 7px 14px; border-radius: 8px; font-size: 13px; font-weight: 600; cursor: pointer; transition: background .12s, border-color .12s; } .pj-btn:hover { background: #f6f7f8; } -.pj-btn-primary { background: #1e1f21; color: #fff; border-color: #1e1f21; } -.pj-btn-primary:hover { background: #000; } +.pj-btn-primary { background: var(--pj-accent); color: #fff; border-color: var(--pj-accent); } +.pj-btn-primary:hover { background: var(--pj-accent-dark); border-color: var(--pj-accent-dark); } .pj-btn-danger { background: #fff; color: #c22b10; border-color: #e6c4bd; } .pj-btn-danger:hover { background: #fbecea; } .pj-icon-btn { @@ -36,10 +49,10 @@ .pj-project-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 14px; align-items: start; } .pj-project-card { - position: relative; border: 1px solid #e7e9ec; border-radius: 14px; - background: #fff; transition: box-shadow .14s, transform .1s; + position: relative; border: 1px solid var(--pj-border); border-radius: var(--pj-card-radius); + background: #fff; box-shadow: var(--pj-card-shadow); transition: box-shadow .14s, transform .1s; } -.pj-project-card:hover { box-shadow: 0 4px 16px rgba(0,0,0,.08); transform: translateY(-1px); } +.pj-project-card:hover { box-shadow: var(--pj-card-shadow-hover); transform: translateY(-1px); } .pj-project-cardlink { display: flex; gap: 12px; padding: 16px; text-decoration: none; color: inherit; } /* 완료 프로젝트 — 비활성(흐리게) */ .pj-project-card.is-done { background: #f6f7f8; opacity: .62; } @@ -124,28 +137,24 @@ .pj-member-list li:hover { background: #f6f7f8; } .pj-member-name { font-size: 13px; flex: 1; } -/* ── 아사나식 프로젝트 트리 ── */ +/* ── 아사나식 프로젝트 트리(사이드바) — 프로젝트 이름만 나열, 업무 목록 없음 ── */ .pj-tree { list-style: none; margin: 0; padding: 0; } -.pj-tree .pj-tree { padding-left: 0; } /* 들여쓰기는 row 패딩으로 처리 */ -.pj-tree-row { display: flex; align-items: center; gap: 2px; border-radius: 7px; padding-right: 2px; - padding-left: calc(var(--pj-indent, 0) * 16px); position: relative; } +.pj-tree-row { display: flex; align-items: center; gap: 2px; border-radius: 8px; padding-right: 2px; position: relative; } .pj-tree-row:hover { background: #f1f2f4; } -.pj-tree-row.is-active { background: #ececf8; } -.pj-tree-row.is-active .pj-tree-name { font-weight: 700; color: #1e1f21; } -.pj-tree-caret { border: none; background: transparent; cursor: pointer; width: 20px; height: 26px; - display: inline-flex; align-items: center; justify-content: center; color: #6b7280; padding: 0; } -.pj-tree-caret .material-symbols-outlined { font-size: 18px; transition: transform .12s; } -.pj-tree-caret.is-open .material-symbols-outlined { transform: rotate(90deg); } -.pj-tree-caret-spacer { width: 20px; flex: 0 0 auto; } -.pj-tree-link { display: flex; align-items: center; gap: 7px; flex: 1; min-width: 0; - text-decoration: none; color: inherit; padding: 5px 2px; } -.pj-tree-icon { font-size: 18px; flex: 0 0 auto; } +.pj-tree-row.is-active { background: var(--pj-accent-tint); } +.pj-tree-row.is-active .pj-tree-name { font-weight: 700; color: var(--pj-accent-dark); } +.pj-tree-link { display: flex; align-items: center; gap: 9px; flex: 1; min-width: 0; + text-decoration: none; color: inherit; padding: 6px 4px; } +/* 프로젝트 색 + 첫 글자 — 아사나의 색 아이콘 자리를 대신한다 */ +.pj-tree-avatar { + width: 22px; height: 22px; border-radius: 6px; flex: 0 0 auto; + display: inline-flex; align-items: center; justify-content: center; + color: #fff; font-size: 11px; font-weight: 700; +} .pj-tree-name { font-size: 13.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .pj-tree-acts { display: none; gap: 0; flex: 0 0 auto; } .pj-tree-row:hover .pj-tree-acts { display: inline-flex; } .pj-tree-acts .pj-icon-btn { width: 22px; height: 22px; font-size: 14px; } -.pj-tree-done { text-decoration: line-through; color: #9aa1a9; } -.pj-tree-task .pj-tree-name { font-size: 13px; color: #444; } /* 구글 머티리얼 심볼 아이콘(담당자) */ .material-symbols-outlined { font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24; line-height: 1; vertical-align: middle; } @@ -160,7 +169,7 @@ .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: #1e1f21; color: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.18); } +.pj-tab.is-active { background: var(--pj-accent); color: #fff; box-shadow: 0 1px 3px rgba(123,104,238,.35); } /* 알림 벨 흔들림(미읽음 있을 때) */ @keyframes pj-bell-ring { @@ -174,19 +183,27 @@ } /* ── 보드(칸반) ── */ -.pj-board { display: flex; gap: 14px; overflow-x: auto; padding-bottom: 8px; align-items: flex-start; } -.pj-col { flex: 0 0 272px; background: #f7f8f9; border-radius: 12px; padding: 10px; } -.pj-col-head { display: flex; align-items: center; gap: 6px; font-weight: 700; font-size: 13px; padding: 4px 6px 10px; } -.pj-col-count { margin-left: auto; color: #9aa1a9; font-weight: 600; } +.pj-board { display: flex; gap: 16px; overflow-x: auto; padding-bottom: 8px; align-items: flex-start; } +.pj-col { flex: 0 0 280px; background: #f6f5fb; border-radius: 14px; padding: 12px; } +.pj-col-head { display: flex; align-items: center; gap: 6px; font-weight: 700; font-size: 13px; padding: 2px 6px 12px; color: var(--pj-ink); } +.pj-col-count { + margin-left: auto; color: var(--pj-ink-soft); font-weight: 700; font-size: 11px; + background: #fff; border-radius: 999px; min-width: 20px; height: 20px; padding: 0 6px; + display: inline-flex; align-items: center; justify-content: center; +} .pj-col-body { display: flex; flex-direction: column; gap: 8px; min-height: 40px; border-radius: 8px; } -.pj-col-body.is-over { background: #e6ecfa; outline: 2px dashed #aebfe6; } -.pj-card { background: #fff; border: 1px solid #e7e9ec; border-radius: 10px; padding: 10px 12px; cursor: pointer; box-shadow: 0 1px 2px rgba(0,0,0,.04); } -.pj-card:hover { border-color: #c9cdd3; } +.pj-col-body.is-over { background: var(--pj-accent-tint); outline: 2px dashed var(--pj-accent); } +.pj-card { + background: #fff; border: 1px solid var(--pj-border); border-radius: var(--pj-card-radius); + padding: 12px 14px; cursor: pointer; box-shadow: var(--pj-card-shadow); + transition: box-shadow .12s, transform .08s; +} +.pj-card:hover { box-shadow: var(--pj-card-shadow-hover); transform: translateY(-1px); } .pj-card.is-dragging { opacity: .5; } .pj-card.is-done .pj-card-title { text-decoration: line-through; color: #9aa1a9; } -.pj-card-title { font-size: 13.5px; font-weight: 600; } -.pj-card-meta { display: flex; align-items: center; gap: 6px; margin-top: 8px; } -.pj-card-due { margin-left: auto; font-size: 11px; color: #9aa1a9; } +.pj-card-title { font-size: 13.5px; font-weight: 600; color: var(--pj-ink); line-height: 1.4; } +.pj-card-meta { display: flex; align-items: center; gap: 6px; margin-top: 10px; } +.pj-card-due { margin-left: auto; font-size: 11px; color: var(--pj-ink-soft); font-weight: 600; } .pj-pri { padding: 1px 7px; border-radius: 999px; font-size: 10px; font-weight: 700; } .pj-pri-high { background: #fbe3df; color: #c22b10; } .pj-pri-low { background: #e6eef0; color: #4a6066; } @@ -198,6 +215,20 @@ .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-list-group-row { background: #fafafd; } +.pj-list-group-head { + display: flex; align-items: center; gap: 6px; padding: 10px 6px !important; + font-size: 13px; font-weight: 700; color: var(--pj-ink); cursor: pointer; + border-bottom: 1px solid var(--pj-border) !important; white-space: nowrap; +} +.pj-list-group-caret { font-size: 18px; color: var(--pj-ink-soft); } +.pj-list-group-count { + color: var(--pj-ink-soft); font-weight: 700; font-size: 11px; background: #eef0f2; + border-radius: 999px; min-width: 20px; height: 20px; padding: 0 6px; + display: inline-flex; align-items: center; justify-content: center; +} .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; } diff --git a/app/static/project.js b/app/static/project.js index a609fa1..194ff11 100644 --- a/app/static/project.js +++ b/app/static/project.js @@ -926,18 +926,35 @@ const projById = {}; projectsMeta.forEach(function (p) { projById[p.id] = p; }); - // 프로젝트별로 업무를 묶어 그룹(행) 생성 - const byProj = {}; - dated.forEach(function (t) { (byProj[t.project_id] = byProj[t.project_id] || []).push(t); }); + // "이 프로젝트" 스코프는 세션별로 행을 나눈다(아사나 타임라인=섹션 스윔레인). + // "전체 프로젝트"는 예전처럼 프로젝트별로 나눈다(세션이 프로젝트마다 달라 + // 하나로 묶을 수 없다). + const groupBySession = viewScope === "project"; + const byGroup = {}; + dated.forEach(function (t) { + const gid = groupBySession ? String(t.stage_id || "none") : String(t.project_id); + (byGroup[gid] = byGroup[gid] || []).push(t); + }); + let groupOrder = Object.keys(byGroup); + if (groupBySession) { + const sessionOrder = (stagesByProject[projectId] || []).slice() + .sort(function (a, b) { return a.sort_order - b.sort_order; }) + .map(function (s) { return String(s.id); }); + groupOrder = sessionOrder.filter(function (id) { return byGroup[id]; }); + if (byGroup.none) groupOrder.push("none"); + } const items = []; const groupRows = []; - Object.keys(byProj).forEach(function (pid) { - const list = byProj[pid]; - const grp = "P" + pid; - const meta = projById[pid] || {}; - const projColor = list[0].project_color || meta.color || "#4573d2"; - groupRows.push({ id: grp, content: list[0].project_name || meta.name || "프로젝트" }); + groupOrder.forEach(function (gid) { + const list = byGroup[gid]; + const grp = (groupBySession ? "S" : "P") + gid; + const meta = groupBySession ? {} : (projById[gid] || {}); + const groupColor = list[0].project_color || meta.color || "#4573d2"; + const groupName = groupBySession + ? (gid === "none" ? "미지정" : (list[0].stage_name || "세션")) + : (list[0].project_name || meta.name || "프로젝트"); + groupRows.push({ id: grp, content: escapeHtml(groupName) }); let minMs = Infinity, maxMs = -Infinity; list.forEach(function (t) { const s = t.start_date || t.due_date; @@ -951,24 +968,25 @@ content: (t.title || "") + (t.comment_count ? " 💬" + t.comment_count : ""), start: startISO, end: isRange ? endISO : undefined, type: isRange ? "range" : "point", - style: "background-color:" + (t.completed_at ? "#9aa5b1" : projColor) + ";color:#fff;border:none;", + style: "background-color:" + (t.completed_at ? "#9aa5b1" : groupColor) + ";color:#fff;border:none;", }); const sm = new Date(startISO).getTime(); const em = new Date(endISO).getTime(); if (sm < minMs) minMs = sm; if (em > maxMs) maxMs = em; }); - // 배경(프로젝트 전체 일정) = 프로젝트 자체 시작/마감일 우선, 없으면 업무 최소~최대. - let bgStart = meta.start_date ? new Date(meta.start_date + "T00:00:00").getTime() : minMs; - let bgEnd = meta.due_date ? new Date(meta.due_date + "T00:00:00").getTime() : maxMs; - // 업무가 프로젝트 기간 밖에 있어도 가려지지 않게 범위를 넓힌다. + // 배경(전체 일정) = 프로젝트 모드일 때만 프로젝트 자체 시작/마감일 우선, + // 세션 모드는 세션에 자체 기간이 없으므로 업무 최소~최대만 쓴다. + let bgStart = (!groupBySession && meta.start_date) ? new Date(meta.start_date + "T00:00:00").getTime() : minMs; + let bgEnd = (!groupBySession && meta.due_date) ? new Date(meta.due_date + "T00:00:00").getTime() : maxMs; + // 업무가 그 기간 밖에 있어도 가려지지 않게 범위를 넓힌다. bgStart = Math.min(bgStart, minMs); bgEnd = Math.max(bgEnd, maxMs); bgEnd += 86400000; // 마지막 날 하루를 덮도록 +1일 items.push({ - id: "bg" + pid, group: grp, type: "background", + id: "bg" + grp, group: grp, type: "background", start: new Date(bgStart), end: new Date(bgEnd), - style: "background-color:" + projColor + "22;", + style: "background-color:" + groupColor + "22;", }); }); const groups = new vis.DataSet(groupRows); @@ -1324,7 +1342,9 @@ } // ── 리스트 (정렬·컬럼 너비 조절) ── - const LIST_COLS = [ + // "전체 프로젝트" 스코프 — 세션이 프로젝트마다 달라 그룹화 의미가 없으므로 + // 예전처럼 프로젝트·세션을 컬럼으로 둔 평범한 정렬 테이블. + const LIST_COLS_ALL = [ { key: "project", label: "프로젝트", text: function (t) { return t.project_name || "-"; }, sort: function (t) { return (t.project_name || "").toLowerCase(); } }, { 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 || ""); } }, @@ -1333,6 +1353,10 @@ { 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 || ""; } }, ]; + // "이 프로젝트" 스코프 — 아사나 리스트 뷰처럼 세션이 컬럼이 아니라 그룹 + // 구분선이 된다(renderListGrouped). 그래서 세션 컬럼은 뺀다. + const LIST_COLS_PROJECT = LIST_COLS_ALL.filter(function (c) { return c.key !== "project" && c.key !== "stage"; }); + function listCols() { return viewScope === "project" ? LIST_COLS_PROJECT : LIST_COLS_ALL; } 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 (_) {} } @@ -1350,13 +1374,25 @@ }); } - function renderList() { - const table = el("pj-list"); - table.classList.add("pj-table-fixed"); - const widths = listWidths(); + function sortRows(rows, cols) { + if (!listSort.key) return rows; + const col = cols.find(function (c) { return c.key === listSort.key; }); + if (!col) return rows; + 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; + }); + return rows; + } + function renderListHead(table, cols) { + const widths = listWidths(); const trh = document.createElement("tr"); - LIST_COLS.forEach(function (col) { + 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"; @@ -1376,38 +1412,93 @@ const thead = table.querySelector("thead"); thead.innerHTML = ""; thead.appendChild(trh); + } - let rows = visibleTasks(); - 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; - }); - } + function taskRow(t, cols) { + const tr = document.createElement("tr"); + if (t.completed_at) tr.className = "is-done"; + // 행 배경 = 프로젝트 색 연하게(10%) — 다른 프로젝트에서 온(멀티호밍) 업무를 + // 구분하기 위함. 이 프로젝트 고유 업무는 색이 다 같아 사실상 안 보인다. + const pc = t.project_color || "#4573d2"; + tr.style.background = pc + "14"; + tr.innerHTML = cols.map(function (c) { + let inner = escapeHtml(String(c.text(t))); + if (c.key === "title") inner += cmtBadge(t); + if (c.key === "assignee" && t.assignee_email) { + inner = '' + avatarHtml(t.assignee_email, "pj-gicon-sm") + escapeHtml(idOf(t.assignee_email)) + ""; + } + return "" + inner + ""; + }).join(""); + if (canManage) { tr.style.cursor = "pointer"; tr.addEventListener("click", function () { openTaskModal(t); }); } + return tr; + } + + function listCollapsedSet() { + try { return new Set(JSON.parse(localStorage.getItem("pj_list_collapsed") || "[]")); } + catch (_) { return new Set(); } + } + function toggleListCollapsed(key) { + const s = listCollapsedSet(); + if (s.has(key)) s.delete(key); else s.add(key); + try { localStorage.setItem("pj_list_collapsed", JSON.stringify(Array.from(s))); } catch (_) {} + } + + function renderList() { + const table = el("pj-list"); + table.classList.add("pj-table-fixed"); + const cols = listCols(); + renderListHead(table, cols); const tbody = table.querySelector("tbody"); tbody.innerHTML = ""; - rows.forEach(function (t) { - const tr = document.createElement("tr"); - if (t.completed_at) tr.className = "is-done"; - // 행 배경 = 프로젝트 색 연하게(10%) - const pc = t.project_color || "#4573d2"; - tr.style.background = pc + "14"; - tr.innerHTML = LIST_COLS.map(function (c) { - let inner = escapeHtml(String(c.text(t))); - if (c.key === "title") inner += cmtBadge(t); - if (c.key === "assignee" && t.assignee_email) { - inner = '' + avatarHtml(t.assignee_email, "pj-gicon-sm") + escapeHtml(idOf(t.assignee_email)) + ""; - } - return "" + inner + ""; - }).join(""); - if (canManage) { tr.style.cursor = "pointer"; tr.addEventListener("click", function () { openTaskModal(t); }); } - tbody.appendChild(tr); + + if (viewScope !== "project") { + sortRows(visibleTasks(), cols).forEach(function (t) { tbody.appendChild(taskRow(t, cols)); }); + return; + } + + // ── 아사나 리스트 뷰처럼 세션을 그룹 구분선으로 ── + renderListGrouped(tbody, cols); + } + + function renderListGrouped(tbody, cols) { + const vis = visibleTasks(); + const sessions = (stagesByProject[projectId] || []).slice() + .sort(function (a, b) { return a.sort_order - b.sort_order; }); + const collapsed = listCollapsedSet(); + const colSpan = cols.length; + + function addGroup(key, name, groupTasks) { + const head = document.createElement("tr"); + head.className = "pj-list-group-row"; + const isCollapsed = collapsed.has(key); + const td = document.createElement("td"); + td.colSpan = colSpan; + td.className = "pj-list-group-head"; + td.innerHTML = + '' + + (isCollapsed ? "chevron_right" : "expand_more") + "" + + '' + escapeHtml(name) + "" + + '' + groupTasks.length + ""; + td.addEventListener("click", function () { toggleListCollapsed(key); renderList(); }); + head.appendChild(td); + tbody.appendChild(head); + if (!isCollapsed) sortRows(groupTasks.slice(), cols).forEach(function (t) { tbody.appendChild(taskRow(t, cols)); }); + } + + sessions.forEach(function (s) { + addGroup("s" + s.id, s.name, vis.filter(function (t) { return t.stage_id === s.id; })); }); + const unstaged = vis.filter(function (t) { return !t.stage_id; }); + if (unstaged.length) addGroup("none", "미지정", unstaged); + if (!sessions.length && !unstaged.length) { + const tr = document.createElement("tr"); + const td = document.createElement("td"); + td.colSpan = colSpan; + td.className = "pj-empty-sm"; + td.textContent = "세션이 없습니다."; + tr.appendChild(td); + tbody.appendChild(tr); + } } function mergeTask(task) { @@ -1673,8 +1764,6 @@ if (id) res = await api("PUT", "/project/api/tasks/" + id, payload); else res = await api("POST", "/project/api/projects/" + projectId + "/tasks", payload); mergeTask(res.task); - // 새 업무는 해당 프로젝트 트리를 펼쳐 바로 보이게. - if (!id) treeSaveOpen((res.task && res.task.project_id) || projectId, true); closeModal(taskModal); refreshActiveView(); } catch (e) { alert("저장 실패: " + e.message); } @@ -1694,73 +1783,21 @@ } catch (e) { alert("삭제 실패: " + e.message); } }); - // ── 프로젝트 트리 (아사나식) — tasks 로부터 클라이언트 렌더(업무 추가 즉시 반영) ── - function setTreeOpen(li, open) { - const children = li.querySelector(":scope > .pj-tree-children"); - const caret = li.querySelector(":scope > .pj-tree-row > .pj-tree-caret"); - if (!children) return; - children.hidden = !open; - if (caret) { caret.setAttribute("aria-expanded", open ? "true" : "false"); caret.classList.toggle("is-open", open); } - } - function treeOpenSet() { - try { return new Set(JSON.parse(localStorage.getItem("pj_tree_open") || "[]")); } - catch (_) { return new Set(); } - } - function treeSaveOpen(id, open) { - const s = treeOpenSet(); - if (open) s.add(String(id)); else s.delete(String(id)); - try { localStorage.setItem("pj_tree_open", JSON.stringify(Array.from(s))); } catch (_) {} - } - - function treeTaskLi(t, pid) { - return '
  • ' + - '' + - '' + - '' + - (t.completed_at ? "task_alt" : "radio_button_unchecked") + "" + - '' + escapeHtml(t.title || "") + "" + - "
  • "; - } + // ── 프로젝트 트리(사이드바) — 아사나처럼 프로젝트 이름만 나열한다(업무 목록은 + // 안 보여준다 — 업무는 가운데 화면에서만 본다). 색상 원(첫 글자) 아이콘. function treeProjLi(p) { - const ptasks = tasks.filter(function (t) { return t.project_id === p.id; }); - const has = ptasks.length > 0; - const caret = has - ? '' - : ''; const del = isAdmin ? '' : ""; - let h = '
  • ' + - caret + '' + - 'folder' + - '' + escapeHtml(p.name) + "" + del + "
    "; - if (has) h += '"; - return h + "
  • "; + const initial = escapeHtml((p.name || "?").trim().charAt(0).toUpperCase() || "?"); + return '
  • ' + + '' + + '' + initial + "" + + '' + escapeHtml(p.name) + "" + del + "
  • "; } function wireTreeHandlers(rootUl) { - // 좌측 트리의 업무 클릭 — 이미 로드된 tasks 배열에서 바로 찾아 팝업(페이지 이동 없음). - rootUl.querySelectorAll(".pj-tree-tasklink").forEach(function (a) { - a.addEventListener("click", function (e) { - const tid = a.dataset.task; - const t = tasks.find(function (x) { return String(x.id) === String(tid); }); - if (!t) return; // 못 찾으면 기존 링크 이동으로 폴백 - e.preventDefault(); - openTaskModal(t); - }); - }); - rootUl.querySelectorAll(".pj-tree-caret").forEach(function (caret) { - caret.addEventListener("click", function (e) { - e.preventDefault(); e.stopPropagation(); - const li = caret.closest(".pj-tree-li"); - const children = li.querySelector(":scope > .pj-tree-children"); - const open = children.hidden; - setTreeOpen(li, open); - treeSaveOpen(li.dataset.id, open); - }); - }); rootUl.querySelectorAll(".pj-tree-del").forEach(function (b) { b.addEventListener("click", async function (e) { e.preventDefault(); e.stopPropagation(); @@ -1774,26 +1811,11 @@ }); }); } - function restoreTreeState() { - const openSet = treeOpenSet(); - document.querySelectorAll(".pj-tree-li").forEach(function (li) { - if (openSet.has(String(li.dataset.id))) setTreeOpen(li, true); - }); - const active = document.querySelector(".pj-tree-row.is-active"); - let box = active ? active.closest(".pj-tree-children") : null; - while (box) { - box.hidden = false; - const li = box.closest(".pj-tree-li"); - if (li) setTreeOpen(li, true); - box = li ? (li.parentElement ? li.parentElement.closest(".pj-tree-children") : null) : null; - } - } function renderTree() { const rootUl = el("pj-tree-root"); if (!rootUl) return; rootUl.innerHTML = projectsMeta.map(treeProjLi).join(""); wireTreeHandlers(rootUl); - restoreTreeState(); } renderTree();