style(project): 아사나 느낌 색상/카드 디자인 + 사이드바·리스트·타임라인 개편
색상/폰트/카드/사이드바/리스트/타임라인이 아사나와 다르다는 피드백 반영.
- 보라 포인트 컬러(--pj-accent) 도입 — 기본 검정 버튼/활성 탭을 이걸로
교체(erp.css 등 다른 모듈은 안 건드림, .pj- 클래스 범위 안에서만).
- 카드(.pj-card, .pj-project-card)·보드 컬럼 라운드/그림자 키워 아사나
카드 느낌으로.
- 왼쪽 사이드바 — 프로젝트마다 업무를 중첩해서 보여주던 트리를 걷어내고
아사나처럼 "프로젝트 이름 목록만"으로 단순화. 폴더 아이콘 대신 프로젝트
색 + 첫 글자 아바타.
- 리스트 뷰("이 프로젝트" 스코프) — 세션이 컬럼이 아니라 아사나처럼 그룹
구분선이 되도록 개편(세션별로 묶어서 보여주고 접기/펼치기, 개수 표시).
"전체 프로젝트" 스코프는 세션이 프로젝트마다 달라 그룹화 의미가 없으므로
기존 프로젝트/세션 컬럼 있는 평범한 정렬 테이블 유지.
- 타임라인 — "이 프로젝트" 스코프에서는 프로젝트가 아니라 세션별로 행(스윔
레인)을 나눔(아사나 타임라인=섹션 단위). "전체 프로젝트"는 기존처럼
프로젝트별.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
{% block head_extra %}
|
{% block head_extra %}
|
||||||
<link rel="stylesheet" href="/static/project.css?v=20260916a" />
|
<link rel="stylesheet" href="/static/project.css?v=20260916b" />
|
||||||
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
|
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -45,5 +45,5 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/project.js?v=20260916c" defer></script>
|
<script src="/static/project.js?v=20260916d" defer></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
{% block head_extra %}
|
{% block head_extra %}
|
||||||
<link rel="stylesheet" href="/static/project.css?v=20260916a" />
|
<link rel="stylesheet" href="/static/project.css?v=20260916b" />
|
||||||
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
|
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -349,5 +349,5 @@
|
|||||||
<script>
|
<script>
|
||||||
window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }};
|
window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }};
|
||||||
</script>
|
</script>
|
||||||
<script src="/static/project.js?v=20260916c" defer></script>
|
<script src="/static/project.js?v=20260916d" defer></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
{% block head_extra %}
|
{% block head_extra %}
|
||||||
<link rel="stylesheet" href="/static/project.css?v=20260916a" />
|
<link rel="stylesheet" href="/static/project.css?v=20260916b" />
|
||||||
<!-- 구글 머티리얼 심볼(담당자 아이콘 등) — self-host -->
|
<!-- 구글 머티리얼 심볼(담당자 아이콘 등) — self-host -->
|
||||||
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
|
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
|
||||||
<!-- 타임라인 vis-timeline — self-host -->
|
<!-- 타임라인 vis-timeline — self-host -->
|
||||||
@@ -41,15 +41,8 @@
|
|||||||
{% for p in nav_projects %}
|
{% for p in nav_projects %}
|
||||||
<li class="pj-tree-li" data-id="{{ p.id }}">
|
<li class="pj-tree-li" data-id="{{ p.id }}">
|
||||||
<div class="pj-tree-row {% if p.id == project.id %}is-active{% endif %}">
|
<div class="pj-tree-row {% if p.id == project.id %}is-active{% endif %}">
|
||||||
{% if p.tasks %}
|
|
||||||
<button type="button" class="pj-tree-caret" aria-expanded="false" title="펼치기/접기">
|
|
||||||
<span class="material-symbols-outlined">chevron_right</span>
|
|
||||||
</button>
|
|
||||||
{% else %}
|
|
||||||
<span class="pj-tree-caret-spacer"></span>
|
|
||||||
{% endif %}
|
|
||||||
<a class="pj-tree-link" href="/project/p/{{ p.id }}">
|
<a class="pj-tree-link" href="/project/p/{{ p.id }}">
|
||||||
<span class="pj-tree-icon material-symbols-outlined" style="color: {{ p.color }}">folder</span>
|
<span class="pj-tree-avatar" style="background: {{ p.color }}">{{ p.name[0] | upper }}</span>
|
||||||
<span class="pj-tree-name">{{ p.name }}</span>
|
<span class="pj-tree-name">{{ p.name }}</span>
|
||||||
</a>
|
</a>
|
||||||
{% if is_admin %}
|
{% if is_admin %}
|
||||||
@@ -58,25 +51,6 @@
|
|||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if p.tasks %}
|
|
||||||
<div class="pj-tree-children" hidden>
|
|
||||||
<ul class="pj-tree">
|
|
||||||
{% for t in p.tasks %}
|
|
||||||
<li class="pj-tree-li pj-tree-task">
|
|
||||||
<div class="pj-tree-row" style="--pj-indent: 1">
|
|
||||||
<span class="pj-tree-caret-spacer"></span>
|
|
||||||
<a class="pj-tree-link pj-tree-tasklink"
|
|
||||||
href="/project/p/{{ p.id }}?task={{ t.id }}"
|
|
||||||
data-project="{{ p.id }}" data-task="{{ t.id }}">
|
|
||||||
<span class="pj-tree-icon material-symbols-outlined" style="color: {{ t.color }}">{% if t.completed %}task_alt{% else %}radio_button_unchecked{% endif %}</span>
|
|
||||||
<span class="pj-tree-name {% if t.completed %}pj-tree-done{% endif %}">{{ t.title }}</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -331,5 +305,5 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script src="/static/vendor/vis-timeline/vis-timeline-graph2d.min.js"></script>
|
<script src="/static/vendor/vis-timeline/vis-timeline-graph2d.min.js"></script>
|
||||||
<script src="/static/project.js?v=20260916c" defer></script>
|
<script src="/static/project.js?v=20260916d" defer></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+66
-35
@@ -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 {
|
.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;
|
padding: 7px 14px; border-radius: 8px; font-size: 13px; font-weight: 600;
|
||||||
cursor: pointer; transition: background .12s, border-color .12s;
|
cursor: pointer; transition: background .12s, border-color .12s;
|
||||||
}
|
}
|
||||||
.pj-btn:hover { background: #f6f7f8; }
|
.pj-btn:hover { background: #f6f7f8; }
|
||||||
.pj-btn-primary { background: #1e1f21; color: #fff; border-color: #1e1f21; }
|
.pj-btn-primary { background: var(--pj-accent); color: #fff; border-color: var(--pj-accent); }
|
||||||
.pj-btn-primary:hover { background: #000; }
|
.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 { background: #fff; color: #c22b10; border-color: #e6c4bd; }
|
||||||
.pj-btn-danger:hover { background: #fbecea; }
|
.pj-btn-danger:hover { background: #fbecea; }
|
||||||
.pj-icon-btn {
|
.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-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 14px; align-items: start; }
|
||||||
.pj-project-card {
|
.pj-project-card {
|
||||||
position: relative; border: 1px solid #e7e9ec; border-radius: 14px;
|
position: relative; border: 1px solid var(--pj-border); border-radius: var(--pj-card-radius);
|
||||||
background: #fff; transition: box-shadow .14s, transform .1s;
|
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-cardlink { display: flex; gap: 12px; padding: 16px; text-decoration: none; color: inherit; }
|
||||||
/* 완료 프로젝트 — 비활성(흐리게) */
|
/* 완료 프로젝트 — 비활성(흐리게) */
|
||||||
.pj-project-card.is-done { background: #f6f7f8; opacity: .62; }
|
.pj-project-card.is-done { background: #f6f7f8; opacity: .62; }
|
||||||
@@ -124,28 +137,24 @@
|
|||||||
.pj-member-list li:hover { background: #f6f7f8; }
|
.pj-member-list li:hover { background: #f6f7f8; }
|
||||||
.pj-member-name { font-size: 13px; flex: 1; }
|
.pj-member-name { font-size: 13px; flex: 1; }
|
||||||
|
|
||||||
/* ── 아사나식 프로젝트 트리 ── */
|
/* ── 아사나식 프로젝트 트리(사이드바) — 프로젝트 이름만 나열, 업무 목록 없음 ── */
|
||||||
.pj-tree { list-style: none; margin: 0; padding: 0; }
|
.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: 8px; padding-right: 2px; position: relative; }
|
||||||
.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:hover { background: #f1f2f4; }
|
.pj-tree-row:hover { background: #f1f2f4; }
|
||||||
.pj-tree-row.is-active { background: #ececf8; }
|
.pj-tree-row.is-active { background: var(--pj-accent-tint); }
|
||||||
.pj-tree-row.is-active .pj-tree-name { font-weight: 700; color: #1e1f21; }
|
.pj-tree-row.is-active .pj-tree-name { font-weight: 700; color: var(--pj-accent-dark); }
|
||||||
.pj-tree-caret { border: none; background: transparent; cursor: pointer; width: 20px; height: 26px;
|
.pj-tree-link { display: flex; align-items: center; gap: 9px; flex: 1; min-width: 0;
|
||||||
display: inline-flex; align-items: center; justify-content: center; color: #6b7280; padding: 0; }
|
text-decoration: none; color: inherit; padding: 6px 4px; }
|
||||||
.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-avatar {
|
||||||
.pj-tree-caret-spacer { width: 20px; flex: 0 0 auto; }
|
width: 22px; height: 22px; border-radius: 6px; flex: 0 0 auto;
|
||||||
.pj-tree-link { display: flex; align-items: center; gap: 7px; flex: 1; min-width: 0;
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
text-decoration: none; color: inherit; padding: 5px 2px; }
|
color: #fff; font-size: 11px; font-weight: 700;
|
||||||
.pj-tree-icon { font-size: 18px; flex: 0 0 auto; }
|
}
|
||||||
.pj-tree-name { font-size: 13.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.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-acts { display: none; gap: 0; flex: 0 0 auto; }
|
||||||
.pj-tree-row:hover .pj-tree-acts { display: inline-flex; }
|
.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-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; }
|
.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-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-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 { 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 {
|
@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-board { display: flex; gap: 16px; 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 { 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: 4px 6px 10px; }
|
.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: #9aa1a9; font-weight: 600; }
|
.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 { 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-col-body.is-over { background: var(--pj-accent-tint); outline: 2px dashed var(--pj-accent); }
|
||||||
.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 {
|
||||||
.pj-card:hover { border-color: #c9cdd3; }
|
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-dragging { opacity: .5; }
|
||||||
.pj-card.is-done .pj-card-title { text-decoration: line-through; color: #9aa1a9; }
|
.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-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: 8px; }
|
.pj-card-meta { display: flex; align-items: center; gap: 6px; margin-top: 10px; }
|
||||||
.pj-card-due { margin-left: auto; font-size: 11px; color: #9aa1a9; }
|
.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 { padding: 1px 7px; border-radius: 999px; font-size: 10px; font-weight: 700; }
|
||||||
.pj-pri-high { background: #fbe3df; color: #c22b10; }
|
.pj-pri-high { background: #fbe3df; color: #c22b10; }
|
||||||
.pj-pri-low { background: #e6eef0; color: #4a6066; }
|
.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 tr.is-done td { color: #9aa1a9; text-decoration: line-through; }
|
||||||
.pj-table-fixed { table-layout: fixed; }
|
.pj-table-fixed { table-layout: fixed; }
|
||||||
.pj-table-fixed th, .pj-table-fixed td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.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 { cursor: pointer; user-select: none; display: inline-flex; align-items: center; gap: 4px; }
|
||||||
.pj-th-label:hover { color: #4573d2; }
|
.pj-th-label:hover { color: #4573d2; }
|
||||||
.pj-th-sort { font-size: 11px; color: #9aa1a9; }
|
.pj-th-sort { font-size: 11px; color: #9aa1a9; }
|
||||||
|
|||||||
+149
-127
@@ -926,18 +926,35 @@
|
|||||||
const projById = {};
|
const projById = {};
|
||||||
projectsMeta.forEach(function (p) { projById[p.id] = p; });
|
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 items = [];
|
||||||
const groupRows = [];
|
const groupRows = [];
|
||||||
Object.keys(byProj).forEach(function (pid) {
|
groupOrder.forEach(function (gid) {
|
||||||
const list = byProj[pid];
|
const list = byGroup[gid];
|
||||||
const grp = "P" + pid;
|
const grp = (groupBySession ? "S" : "P") + gid;
|
||||||
const meta = projById[pid] || {};
|
const meta = groupBySession ? {} : (projById[gid] || {});
|
||||||
const projColor = list[0].project_color || meta.color || "#4573d2";
|
const groupColor = list[0].project_color || meta.color || "#4573d2";
|
||||||
groupRows.push({ id: grp, content: list[0].project_name || meta.name || "프로젝트" });
|
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;
|
let minMs = Infinity, maxMs = -Infinity;
|
||||||
list.forEach(function (t) {
|
list.forEach(function (t) {
|
||||||
const s = t.start_date || t.due_date;
|
const s = t.start_date || t.due_date;
|
||||||
@@ -951,24 +968,25 @@
|
|||||||
content: (t.title || "") + (t.comment_count ? " 💬" + t.comment_count : ""),
|
content: (t.title || "") + (t.comment_count ? " 💬" + t.comment_count : ""),
|
||||||
start: startISO, end: isRange ? endISO : undefined,
|
start: startISO, end: isRange ? endISO : undefined,
|
||||||
type: isRange ? "range" : "point",
|
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 sm = new Date(startISO).getTime();
|
||||||
const em = new Date(endISO).getTime();
|
const em = new Date(endISO).getTime();
|
||||||
if (sm < minMs) minMs = sm;
|
if (sm < minMs) minMs = sm;
|
||||||
if (em > maxMs) maxMs = em;
|
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);
|
bgStart = Math.min(bgStart, minMs);
|
||||||
bgEnd = Math.max(bgEnd, maxMs);
|
bgEnd = Math.max(bgEnd, maxMs);
|
||||||
bgEnd += 86400000; // 마지막 날 하루를 덮도록 +1일
|
bgEnd += 86400000; // 마지막 날 하루를 덮도록 +1일
|
||||||
items.push({
|
items.push({
|
||||||
id: "bg" + pid, group: grp, type: "background",
|
id: "bg" + grp, group: grp, type: "background",
|
||||||
start: new Date(bgStart), end: new Date(bgEnd),
|
start: new Date(bgStart), end: new Date(bgEnd),
|
||||||
style: "background-color:" + projColor + "22;",
|
style: "background-color:" + groupColor + "22;",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const groups = new vis.DataSet(groupRows);
|
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: "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: "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: "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: "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 || ""; } },
|
{ 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 };
|
let listSort = { key: null, dir: 1 };
|
||||||
function listWidths() { try { return JSON.parse(localStorage.getItem("pj_list_widths") || "{}"); } catch (_) { return {}; } }
|
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 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() {
|
function sortRows(rows, cols) {
|
||||||
const table = el("pj-list");
|
if (!listSort.key) return rows;
|
||||||
table.classList.add("pj-table-fixed");
|
const col = cols.find(function (c) { return c.key === listSort.key; });
|
||||||
const widths = listWidths();
|
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");
|
const trh = document.createElement("tr");
|
||||||
LIST_COLS.forEach(function (col) {
|
cols.forEach(function (col) {
|
||||||
const th = document.createElement("th");
|
const th = document.createElement("th");
|
||||||
th.dataset.key = col.key;
|
th.dataset.key = col.key;
|
||||||
if (widths[col.key]) th.style.width = widths[col.key] + "px";
|
if (widths[col.key]) th.style.width = widths[col.key] + "px";
|
||||||
@@ -1376,38 +1412,93 @@
|
|||||||
const thead = table.querySelector("thead");
|
const thead = table.querySelector("thead");
|
||||||
thead.innerHTML = "";
|
thead.innerHTML = "";
|
||||||
thead.appendChild(trh);
|
thead.appendChild(trh);
|
||||||
|
}
|
||||||
|
|
||||||
let rows = visibleTasks();
|
function taskRow(t, cols) {
|
||||||
if (listSort.key) {
|
const tr = document.createElement("tr");
|
||||||
const col = LIST_COLS.find(function (c) { return c.key === listSort.key; });
|
if (t.completed_at) tr.className = "is-done";
|
||||||
rows.sort(function (a, b) {
|
// 행 배경 = 프로젝트 색 연하게(10%) — 다른 프로젝트에서 온(멀티호밍) 업무를
|
||||||
let va = col.sort(a), vb = col.sort(b);
|
// 구분하기 위함. 이 프로젝트 고유 업무는 색이 다 같아 사실상 안 보인다.
|
||||||
if (va == null) va = -Infinity;
|
const pc = t.project_color || "#4573d2";
|
||||||
if (vb == null) vb = -Infinity;
|
tr.style.background = pc + "14";
|
||||||
if (va < vb) return -listSort.dir;
|
tr.innerHTML = cols.map(function (c) {
|
||||||
if (va > vb) return listSort.dir;
|
let inner = escapeHtml(String(c.text(t)));
|
||||||
return 0;
|
if (c.key === "title") inner += cmtBadge(t);
|
||||||
});
|
if (c.key === "assignee" && t.assignee_email) {
|
||||||
}
|
inner = '<span class="pj-assignee">' + avatarHtml(t.assignee_email, "pj-gicon-sm") + escapeHtml(idOf(t.assignee_email)) + "</span>";
|
||||||
|
}
|
||||||
|
return "<td>" + inner + "</td>";
|
||||||
|
}).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");
|
const tbody = table.querySelector("tbody");
|
||||||
tbody.innerHTML = "";
|
tbody.innerHTML = "";
|
||||||
rows.forEach(function (t) {
|
|
||||||
const tr = document.createElement("tr");
|
if (viewScope !== "project") {
|
||||||
if (t.completed_at) tr.className = "is-done";
|
sortRows(visibleTasks(), cols).forEach(function (t) { tbody.appendChild(taskRow(t, cols)); });
|
||||||
// 행 배경 = 프로젝트 색 연하게(10%)
|
return;
|
||||||
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)));
|
renderListGrouped(tbody, cols);
|
||||||
if (c.key === "title") inner += cmtBadge(t);
|
}
|
||||||
if (c.key === "assignee" && t.assignee_email) {
|
|
||||||
inner = '<span class="pj-assignee">' + avatarHtml(t.assignee_email, "pj-gicon-sm") + escapeHtml(idOf(t.assignee_email)) + "</span>";
|
function renderListGrouped(tbody, cols) {
|
||||||
}
|
const vis = visibleTasks();
|
||||||
return "<td>" + inner + "</td>";
|
const sessions = (stagesByProject[projectId] || []).slice()
|
||||||
}).join("");
|
.sort(function (a, b) { return a.sort_order - b.sort_order; });
|
||||||
if (canManage) { tr.style.cursor = "pointer"; tr.addEventListener("click", function () { openTaskModal(t); }); }
|
const collapsed = listCollapsedSet();
|
||||||
tbody.appendChild(tr);
|
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 =
|
||||||
|
'<span class="pj-list-group-caret material-symbols-outlined">' +
|
||||||
|
(isCollapsed ? "chevron_right" : "expand_more") + "</span>" +
|
||||||
|
'<span class="pj-list-group-name">' + escapeHtml(name) + "</span>" +
|
||||||
|
'<span class="pj-list-group-count">' + groupTasks.length + "</span>";
|
||||||
|
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) {
|
function mergeTask(task) {
|
||||||
@@ -1673,8 +1764,6 @@
|
|||||||
if (id) res = await api("PUT", "/project/api/tasks/" + id, payload);
|
if (id) res = await api("PUT", "/project/api/tasks/" + id, payload);
|
||||||
else res = await api("POST", "/project/api/projects/" + projectId + "/tasks", payload);
|
else res = await api("POST", "/project/api/projects/" + projectId + "/tasks", payload);
|
||||||
mergeTask(res.task);
|
mergeTask(res.task);
|
||||||
// 새 업무는 해당 프로젝트 트리를 펼쳐 바로 보이게.
|
|
||||||
if (!id) treeSaveOpen((res.task && res.task.project_id) || projectId, true);
|
|
||||||
closeModal(taskModal);
|
closeModal(taskModal);
|
||||||
refreshActiveView();
|
refreshActiveView();
|
||||||
} catch (e) { alert("저장 실패: " + e.message); }
|
} catch (e) { alert("저장 실패: " + e.message); }
|
||||||
@@ -1694,73 +1783,21 @@
|
|||||||
} catch (e) { alert("삭제 실패: " + e.message); }
|
} 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 '<li class="pj-tree-li pj-tree-task"><div class="pj-tree-row" style="--pj-indent: 1">' +
|
|
||||||
'<span class="pj-tree-caret-spacer"></span>' +
|
|
||||||
'<a class="pj-tree-link pj-tree-tasklink" href="/project/p/' + pid + '?task=' + t.id +
|
|
||||||
'" data-project="' + pid + '" data-task="' + t.id + '">' +
|
|
||||||
'<span class="pj-tree-icon material-symbols-outlined" style="color:' + (t.project_color || "#4573d2") + '">' +
|
|
||||||
(t.completed_at ? "task_alt" : "radio_button_unchecked") + "</span>" +
|
|
||||||
'<span class="pj-tree-name ' + (t.completed_at ? "pj-tree-done" : "") + '">' + escapeHtml(t.title || "") + "</span>" +
|
|
||||||
"</a></div></li>";
|
|
||||||
}
|
|
||||||
function treeProjLi(p) {
|
function treeProjLi(p) {
|
||||||
const ptasks = tasks.filter(function (t) { return t.project_id === p.id; });
|
|
||||||
const has = ptasks.length > 0;
|
|
||||||
const caret = has
|
|
||||||
? '<button type="button" class="pj-tree-caret" aria-expanded="false" title="펼치기/접기"><span class="material-symbols-outlined">chevron_right</span></button>'
|
|
||||||
: '<span class="pj-tree-caret-spacer"></span>';
|
|
||||||
const del = isAdmin
|
const del = isAdmin
|
||||||
? '<span class="pj-tree-acts"><button type="button" class="pj-icon-btn pj-tree-del" data-id="' + p.id +
|
? '<span class="pj-tree-acts"><button type="button" class="pj-icon-btn pj-tree-del" data-id="' + p.id +
|
||||||
'" data-name="' + escapeHtml(p.name) + '" data-creator="' + escapeHtml(p.created_by || "") + '" title="프로젝트 삭제">×</button></span>'
|
'" data-name="' + escapeHtml(p.name) + '" data-creator="' + escapeHtml(p.created_by || "") + '" title="프로젝트 삭제">×</button></span>'
|
||||||
: "";
|
: "";
|
||||||
let h = '<li class="pj-tree-li" data-id="' + p.id + '"><div class="pj-tree-row ' + (p.id === projectId ? "is-active" : "") + '">' +
|
const initial = escapeHtml((p.name || "?").trim().charAt(0).toUpperCase() || "?");
|
||||||
caret + '<a class="pj-tree-link" href="/project/p/' + p.id + '">' +
|
return '<li class="pj-tree-li" data-id="' + p.id + '"><div class="pj-tree-row ' +
|
||||||
'<span class="pj-tree-icon material-symbols-outlined" style="color:' + (p.color || "#4573d2") + '">folder</span>' +
|
(p.id === projectId ? "is-active" : "") + '">' +
|
||||||
'<span class="pj-tree-name">' + escapeHtml(p.name) + "</span></a>" + del + "</div>";
|
'<a class="pj-tree-link" href="/project/p/' + p.id + '">' +
|
||||||
if (has) h += '<div class="pj-tree-children" hidden><ul class="pj-tree">' +
|
'<span class="pj-tree-avatar" style="background:' + (p.color || "#4573d2") + '">' + initial + "</span>" +
|
||||||
ptasks.map(function (t) { return treeTaskLi(t, p.id); }).join("") + "</ul></div>";
|
'<span class="pj-tree-name">' + escapeHtml(p.name) + "</span></a>" + del + "</div></li>";
|
||||||
return h + "</li>";
|
|
||||||
}
|
}
|
||||||
function wireTreeHandlers(rootUl) {
|
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) {
|
rootUl.querySelectorAll(".pj-tree-del").forEach(function (b) {
|
||||||
b.addEventListener("click", async function (e) {
|
b.addEventListener("click", async function (e) {
|
||||||
e.preventDefault(); e.stopPropagation();
|
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() {
|
function renderTree() {
|
||||||
const rootUl = el("pj-tree-root");
|
const rootUl = el("pj-tree-root");
|
||||||
if (!rootUl) return;
|
if (!rootUl) return;
|
||||||
rootUl.innerHTML = projectsMeta.map(treeProjLi).join("");
|
rootUl.innerHTML = projectsMeta.map(treeProjLi).join("");
|
||||||
wireTreeHandlers(rootUl);
|
wireTreeHandlers(rootUl);
|
||||||
restoreTreeState();
|
|
||||||
}
|
}
|
||||||
renderTree();
|
renderTree();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user