feat(project): 내 업무 패널에 프로젝트별 전체/진행/완료 개수 배지
프로젝트 이름 오른쪽에 "전체5 진행2 완료3" 형식으로 표시, 숫자는 색깔 있는 동그라미 안에(전체=회색, 진행=파랑, 완료=초록). 서버 렌더용 카운트는 _group_my_tasks() 헬퍼로 통합(홈/프로젝트 화면 중복 제거), 실시간 갱신은 DOM의 실제 <li> 개수를 다시 세는 updateMyTreeCounts()를 기존 sync 함수 (syncMyTaskPanel/syncProjectMyTaskPanel) 안에서 호출해 처리. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -205,6 +205,31 @@ def _fmt_date_kr(d: str | None) -> str:
|
|||||||
return dd.strftime("%m/%d") + f"({_WEEKDAYS_KR[dd.weekday()]})"
|
return dd.strftime("%m/%d") + f"({_WEEKDAYS_KR[dd.weekday()]})"
|
||||||
|
|
||||||
|
|
||||||
|
def _group_my_tasks(my_tasks: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""담당 업무를 프로젝트별로 묶고, "내 업무" 패널에 표시할 전체/진행/완료
|
||||||
|
개수를 함께 센다(홈 화면·프로젝트 화면 양쪽에서 재사용)."""
|
||||||
|
groups: dict[int, dict[str, Any]] = {}
|
||||||
|
for t in my_tasks:
|
||||||
|
pid = t.get("project_id")
|
||||||
|
g = groups.get(pid)
|
||||||
|
if g is None:
|
||||||
|
g = {
|
||||||
|
"id": pid,
|
||||||
|
"name": t.get("project_name") or "프로젝트",
|
||||||
|
"color": t.get("project_color") or "#4573d2",
|
||||||
|
"tasks": [],
|
||||||
|
}
|
||||||
|
groups[pid] = g
|
||||||
|
g["tasks"].append(t)
|
||||||
|
for g in groups.values():
|
||||||
|
total = len(g["tasks"])
|
||||||
|
done = sum(1 for t in g["tasks"] if t.get("completed_at"))
|
||||||
|
g["total"] = total
|
||||||
|
g["done"] = done
|
||||||
|
g["open"] = total - done
|
||||||
|
return list(groups.values())
|
||||||
|
|
||||||
|
|
||||||
def _build_tree(projects: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
def _build_tree(projects: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
"""평면 프로젝트 목록 → parent_id 기준 트리(children 키)."""
|
"""평면 프로젝트 목록 → parent_id 기준 트리(children 키)."""
|
||||||
by_id: dict[int, dict[str, Any]] = {}
|
by_id: dict[int, dict[str, Any]] = {}
|
||||||
@@ -426,21 +451,8 @@ async def index(request: Request) -> HTMLResponse:
|
|||||||
for t in my_tasks:
|
for t in my_tasks:
|
||||||
t["start_label"] = _fmt_date_kr(t.get("start_date"))
|
t["start_label"] = _fmt_date_kr(t.get("start_date"))
|
||||||
t["due_label"] = _fmt_date_kr(t.get("due_date"))
|
t["due_label"] = _fmt_date_kr(t.get("due_date"))
|
||||||
# 내 업무를 프로젝트별로 묶어 트리로 표시
|
# 내 업무를 프로젝트별로 묶어 트리로 표시(전체/진행/완료 개수 포함)
|
||||||
my_groups: dict[int, dict[str, Any]] = {}
|
my_projects = _group_my_tasks(my_tasks)
|
||||||
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())
|
|
||||||
|
|
||||||
# 홈 화면 카드/내업무에 노출된 업무 전체(id→업무) — 클릭 시 페이지 이동 없이
|
# 홈 화면 카드/내업무에 노출된 업무 전체(id→업무) — 클릭 시 페이지 이동 없이
|
||||||
# 팝업으로 편집하기 위해 필요한 원본 데이터를 그대로 클라이언트에 넘긴다.
|
# 팝업으로 편집하기 위해 필요한 원본 데이터를 그대로 클라이언트에 넘긴다.
|
||||||
@@ -567,20 +579,7 @@ async def project_page(request: Request, project_id: int) -> HTMLResponse:
|
|||||||
for t in my_tasks:
|
for t in my_tasks:
|
||||||
t["start_label"] = _fmt_date_kr(t.get("start_date"))
|
t["start_label"] = _fmt_date_kr(t.get("start_date"))
|
||||||
t["due_label"] = _fmt_date_kr(t.get("due_date"))
|
t["due_label"] = _fmt_date_kr(t.get("due_date"))
|
||||||
my_groups: dict[int, dict[str, Any]] = {}
|
my_projects = _group_my_tasks(my_tasks)
|
||||||
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())
|
|
||||||
|
|
||||||
# 달력(휴가식 월간 그리드) — ?y=&m= 로 월 이동, 기본 이번 달
|
# 달력(휴가식 월간 그리드) — ?y=&m= 로 월 이동, 기본 이번 달
|
||||||
today = today_kst()
|
today = today_kst()
|
||||||
|
|||||||
@@ -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=20260917u" />
|
<link rel="stylesheet" href="/static/project.css?v=20260917v" />
|
||||||
<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=20260917w" defer></script>
|
<script src="/static/project.js?v=20260917x" 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=20260917u" />
|
<link rel="stylesheet" href="/static/project.css?v=20260917v" />
|
||||||
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
|
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -165,6 +165,11 @@
|
|||||||
<a class="pj-mytree-proj" href="/project/p/{{ p.id }}">
|
<a class="pj-mytree-proj" href="/project/p/{{ p.id }}">
|
||||||
<span class="pj-mytree-proj-name">{{ p.name }}</span>
|
<span class="pj-mytree-proj-name">{{ p.name }}</span>
|
||||||
</a>
|
</a>
|
||||||
|
<span class="pj-mytree-counts">
|
||||||
|
<span class="pj-mtc">전체<span class="pj-mtc-num pj-mtc-total">{{ p.total }}</span></span>
|
||||||
|
<span class="pj-mtc">진행<span class="pj-mtc-num pj-mtc-open">{{ p.open }}</span></span>
|
||||||
|
<span class="pj-mtc">완료<span class="pj-mtc-num pj-mtc-done">{{ p.done }}</span></span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<ul class="pj-mytree-tasks">
|
<ul class="pj-mytree-tasks">
|
||||||
{% for t in p.tasks %}
|
{% for t in p.tasks %}
|
||||||
@@ -424,5 +429,5 @@
|
|||||||
window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }};
|
window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }};
|
||||||
window.PJ_STAGE_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772","#e8398a","#a15c43"] | tojson }};
|
window.PJ_STAGE_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772","#e8398a","#a15c43"] | tojson }};
|
||||||
</script>
|
</script>
|
||||||
<script src="/static/project.js?v=20260917w" defer></script>
|
<script src="/static/project.js?v=20260917x" 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=20260917u" />
|
<link rel="stylesheet" href="/static/project.css?v=20260917v" />
|
||||||
<!-- 구글 머티리얼 심볼(담당자 아이콘 등) — 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 -->
|
||||||
@@ -207,6 +207,11 @@
|
|||||||
<a class="pj-mytree-proj" href="/project/p/{{ p.id }}">
|
<a class="pj-mytree-proj" href="/project/p/{{ p.id }}">
|
||||||
<span class="pj-mytree-proj-name">{{ p.name }}</span>
|
<span class="pj-mytree-proj-name">{{ p.name }}</span>
|
||||||
</a>
|
</a>
|
||||||
|
<span class="pj-mytree-counts">
|
||||||
|
<span class="pj-mtc">전체<span class="pj-mtc-num pj-mtc-total">{{ p.total }}</span></span>
|
||||||
|
<span class="pj-mtc">진행<span class="pj-mtc-num pj-mtc-open">{{ p.open }}</span></span>
|
||||||
|
<span class="pj-mtc">완료<span class="pj-mtc-num pj-mtc-done">{{ p.done }}</span></span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<ul class="pj-mytree-tasks">
|
<ul class="pj-mytree-tasks">
|
||||||
{% for t in p.tasks %}
|
{% for t in p.tasks %}
|
||||||
@@ -538,5 +543,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=20260917w" defer></script>
|
<script src="/static/project.js?v=20260917x" defer></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -408,6 +408,17 @@
|
|||||||
.pj-mytree-toggle:hover { background: #f1f2f4; }
|
.pj-mytree-toggle:hover { background: #f1f2f4; }
|
||||||
.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; flex: 1 1 auto; min-width: 0; }
|
.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; flex: 1 1 auto; min-width: 0; }
|
||||||
.pj-mytree-proj:hover { background: #f6f7f8; }
|
.pj-mytree-proj:hover { background: #f6f7f8; }
|
||||||
|
/* 프로젝트 이름 오른쪽 — 전체/진행/완료 개수(숫자는 색깔 있는 동그라미 안에) */
|
||||||
|
.pj-mytree-counts { display: flex; align-items: center; gap: 7px; flex: 0 0 auto; padding-right: 4px; }
|
||||||
|
.pj-mtc { display: inline-flex; align-items: center; gap: 3px; font-size: 10.5px; color: #9aa1a9; font-weight: 600; }
|
||||||
|
.pj-mtc-num {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
min-width: 16px; height: 16px; padding: 0 4px; border-radius: 999px;
|
||||||
|
font-size: 10px; font-weight: 800; color: #fff;
|
||||||
|
}
|
||||||
|
.pj-mtc-num.pj-mtc-total { background: #8b93a0; } /* 전체 = 중립 회색 */
|
||||||
|
.pj-mtc-num.pj-mtc-open { background: #4573d2; } /* 진행 = 파랑 */
|
||||||
|
.pj-mtc-num.pj-mtc-done { background: #37a36c; } /* 완료 = 초록 */
|
||||||
.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-tasks { list-style: none; margin: 2px 0 0; padding: 0 0 0 8px; border-left: 2px solid #eef0f2; margin-left: 10px; }
|
||||||
.pj-mytree-group.is-collapsed .pj-mytree-tasks { display: none; }
|
.pj-mytree-group.is-collapsed .pj-mytree-tasks { display: none; }
|
||||||
.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 { display: flex; align-items: flex-start; gap: 7px; padding: 6px 6px; border-radius: 7px; text-decoration: none; color: inherit; }
|
||||||
|
|||||||
+32
-4
@@ -157,6 +157,22 @@
|
|||||||
return "rgba(" + r + "," + g + "," + b + "," + alpha + ")";
|
return "rgba(" + r + "," + g + "," + b + "," + alpha + ")";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "내 업무" 그룹 헤더의 전체/진행/완료 개수 배지 — 그 그룹 안의 실제
|
||||||
|
// <li> 개수를 다시 세어 채운다(서버 재조회 없이, DOM 이 곧 정답).
|
||||||
|
function updateMyTreeCounts(groupEl) {
|
||||||
|
if (!groupEl) return;
|
||||||
|
const items = groupEl.querySelectorAll(".pj-mytree-tasks > .pj-mytree-task");
|
||||||
|
let done = 0;
|
||||||
|
items.forEach(function (li) { if (li.classList.contains("is-done")) done++; });
|
||||||
|
const total = items.length;
|
||||||
|
const totalEl = groupEl.querySelector(".pj-mtc-total");
|
||||||
|
const openEl = groupEl.querySelector(".pj-mtc-open");
|
||||||
|
const doneEl = groupEl.querySelector(".pj-mtc-done");
|
||||||
|
if (totalEl) totalEl.textContent = total;
|
||||||
|
if (openEl) openEl.textContent = total - done;
|
||||||
|
if (doneEl) doneEl.textContent = done;
|
||||||
|
}
|
||||||
|
|
||||||
// 업무 모달(#pj-modal-task) 크기 — 기존 업무 편집은 하위업무/첨부/연결/
|
// 업무 모달(#pj-modal-task) 크기 — 기존 업무 편집은 하위업무/첨부/연결/
|
||||||
// 댓글까지 보여야 해서 800x1100 고정, "새 업무" 작성은 그 칸들이 전부
|
// 댓글까지 보여야 해서 800x1100 고정, "새 업무" 작성은 그 칸들이 전부
|
||||||
// 숨는 홑겹 폼이라 480x700 로 작게(홈 카드·프로젝트 화면 양쪽에서 공용).
|
// 숨는 홑겹 폼이라 480x700 로 작게(홈 카드·프로젝트 화면 양쪽에서 공용).
|
||||||
@@ -816,12 +832,17 @@
|
|||||||
// "내 업무" 패널(우측) 동기화 — 이 프로젝트에 내 업무 그룹이 이미 있을
|
// "내 업무" 패널(우측) 동기화 — 이 프로젝트에 내 업무 그룹이 이미 있을
|
||||||
// 때만 patch 한다(그룹 자체가 없던 경우는 드물어 다음 새로고침까지 생략).
|
// 때만 patch 한다(그룹 자체가 없던 경우는 드물어 다음 새로고침까지 생략).
|
||||||
function syncMyTaskPanel(t, removed) {
|
function syncMyTaskPanel(t, removed) {
|
||||||
const group = document.querySelector('.pj-mytree-group[data-group-id="' + t.project_id + '"] .pj-mytree-tasks');
|
const groupEl = document.querySelector('.pj-mytree-group[data-group-id="' + t.project_id + '"]');
|
||||||
|
const group = groupEl ? groupEl.querySelector(".pj-mytree-tasks") : null;
|
||||||
if (!group) return;
|
if (!group) return;
|
||||||
const isMine = !removed && t.assignee_email && String(t.assignee_email).toLowerCase() === me;
|
const isMine = !removed && t.assignee_email && String(t.assignee_email).toLowerCase() === me;
|
||||||
const existingLink = group.querySelector('.pj-task-popup-link[data-task-id="' + t.id + '"]');
|
const existingLink = group.querySelector('.pj-task-popup-link[data-task-id="' + t.id + '"]');
|
||||||
const existingLi = existingLink ? existingLink.closest("li") : null;
|
const existingLi = existingLink ? existingLink.closest("li") : null;
|
||||||
if (!isMine) { if (existingLi) existingLi.remove(); return; }
|
if (!isMine) {
|
||||||
|
if (existingLi) existingLi.remove();
|
||||||
|
updateMyTreeCounts(groupEl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const dates = t.start_date || t.due_date
|
const dates = t.start_date || t.due_date
|
||||||
? '<span class="pj-mytree-dates">' +
|
? '<span class="pj-mytree-dates">' +
|
||||||
(t.start_date && t.due_date ? fmtDateKr(t.start_date) + " ~ " + fmtDateKr(t.due_date)
|
(t.start_date && t.due_date ? fmtDateKr(t.start_date) + " ~ " + fmtDateKr(t.due_date)
|
||||||
@@ -844,6 +865,7 @@
|
|||||||
wireHomeTaskPopupLink(newLi.querySelector(".pj-task-popup-link"), t);
|
wireHomeTaskPopupLink(newLi.querySelector(".pj-task-popup-link"), t);
|
||||||
if (cmt) cmt.wireBadge(newLi.querySelector(".pj-cmt-badge"));
|
if (cmt) cmt.wireBadge(newLi.querySelector(".pj-cmt-badge"));
|
||||||
if (existingLi) existingLi.replaceWith(newLi); else group.appendChild(newLi);
|
if (existingLi) existingLi.replaceWith(newLi); else group.appendChild(newLi);
|
||||||
|
updateMyTreeCounts(groupEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 완료 프로젝트 섹션 접기/펼치기 (localStorage 기억) ──
|
// ── 완료 프로젝트 섹션 접기/펼치기 (localStorage 기억) ──
|
||||||
@@ -1431,17 +1453,23 @@
|
|||||||
"</a></li>";
|
"</a></li>";
|
||||||
}
|
}
|
||||||
function syncProjectMyTaskPanel(t, removed) {
|
function syncProjectMyTaskPanel(t, removed) {
|
||||||
const group = document.querySelector('.pj-project-mytasks .pj-mytree-group[data-group-id="' + t.project_id + '"] .pj-mytree-tasks');
|
const groupEl = document.querySelector('.pj-project-mytasks .pj-mytree-group[data-group-id="' + t.project_id + '"]');
|
||||||
|
const group = groupEl ? groupEl.querySelector(".pj-mytree-tasks") : null;
|
||||||
if (!group) return; // 이 프로젝트에 내 업무가 하나도 없었으면 다음 새로고침까지 생략.
|
if (!group) return; // 이 프로젝트에 내 업무가 하나도 없었으면 다음 새로고침까지 생략.
|
||||||
const existingLink = group.querySelector('.pj-task-popup-link[data-task-id="' + t.id + '"]');
|
const existingLink = group.querySelector('.pj-task-popup-link[data-task-id="' + t.id + '"]');
|
||||||
const existingLi = existingLink ? existingLink.closest("li") : null;
|
const existingLi = existingLink ? existingLink.closest("li") : null;
|
||||||
if (removed) { if (existingLi) existingLi.remove(); return; }
|
if (removed) {
|
||||||
|
if (existingLi) existingLi.remove();
|
||||||
|
updateMyTreeCounts(groupEl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const wrap = document.createElement("div");
|
const wrap = document.createElement("div");
|
||||||
wrap.innerHTML = myTaskRowHtml(t);
|
wrap.innerHTML = myTaskRowHtml(t);
|
||||||
const newLi = wrap.firstElementChild;
|
const newLi = wrap.firstElementChild;
|
||||||
wireMyTaskPopupLink(newLi.querySelector(".pj-task-popup-link"));
|
wireMyTaskPopupLink(newLi.querySelector(".pj-task-popup-link"));
|
||||||
if (cmt) cmt.wireBadge(newLi.querySelector(".pj-cmt-badge"));
|
if (cmt) cmt.wireBadge(newLi.querySelector(".pj-cmt-badge"));
|
||||||
if (existingLi) existingLi.replaceWith(newLi); else group.appendChild(newLi);
|
if (existingLi) existingLi.replaceWith(newLi); else group.appendChild(newLi);
|
||||||
|
updateMyTreeCounts(groupEl);
|
||||||
}
|
}
|
||||||
// 내 업무 항목 우클릭 메뉴 — 열기(프로젝트 페이지로 이동)/업무 추가/수정
|
// 내 업무 항목 우클릭 메뉴 — 열기(프로젝트 페이지로 이동)/업무 추가/수정
|
||||||
// (팝업 편집)/완료토글/삭제. "업무 추가"는 현재 열려 있는 프로젝트로만
|
// (팝업 편집)/완료토글/삭제. "업무 추가"는 현재 열려 있는 프로젝트로만
|
||||||
|
|||||||
Reference in New Issue
Block a user