feat(project): 프로젝트 페이지의 내 업무 패널에도 우클릭 메뉴 추가

지난번엔 홈 화면 내 업무 패널만 처리했는데, 프로젝트 상세 페이지 우측
내 업무 패널(.pj-project-mytasks)에도 동일하게 열기/업무 추가/수정/
완료토글/삭제 메뉴를 추가. "업무 추가"는 openTaskModal(null) 이 항상
현재 열려 있는 프로젝트에만 새 업무를 만들 수 있는 제약이 있어, 업무가
다른 프로젝트 소속이면 그 프로젝트 페이지로 이동시켜 거기서 추가하도록
처리(실제 프로젝트 A/B 두 개로 헤드리스 크롬에서 검증).
This commit is contained in:
2026-09-16 15:17:46 +09:00
parent 2773d8858d
commit 4dbb3e7856
4 changed files with 92 additions and 7 deletions
@@ -45,5 +45,5 @@
{% endif %}
</div>
<script src="/static/project.js?v=20260917j" defer></script>
<script src="/static/project.js?v=20260917k" defer></script>
{% endblock %}
@@ -393,5 +393,5 @@
<script>
window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }};
</script>
<script src="/static/project.js?v=20260917j" defer></script>
<script src="/static/project.js?v=20260917k" defer></script>
{% endblock %}
@@ -461,5 +461,5 @@
</script>
<script src="/static/vendor/vis-timeline/vis-timeline-graph2d.min.js"></script>
<script src="/static/project.js?v=20260917j" defer></script>
<script src="/static/project.js?v=20260917k" defer></script>
{% endblock %}
+89 -4
View File
@@ -1081,15 +1081,100 @@
const avatars = data.avatars || {};
const myTasksById = {};
(data.myTasks || []).forEach(function (t) { myTasksById[t.id] = t; });
// 우측 "내 업무" 패널 — openTaskModal 은 이 함수 뒤쪽에서 선언되지만
// function 선언은 호이스팅되어 여기서 참조해도 문제없다.
document.querySelectorAll(".pj-project-mytasks .pj-task-popup-link").forEach(function (a) {
// 우측 "내 업무" 패널 — openTaskModal/mergeTask/refreshActiveView 는 이
// 함수 뒤쪽에서 선언되지만 function 선언은 호이스팅되어 여기서 참조해도
// 문제없다. 이 패널의 업무는 전부 "내가 담당자"인 게 보장돼 있어(서버가
// assignee_email 로 조회) 홈 페이지 syncMyTaskPanel 과 달리 재배정 시
// 사라지는 경우를 신경 쓸 필요 없다.
function myTaskRowHtml(t) {
const dates = t.start_date || t.due_date
? '<span class="pj-mytree-dates">' +
(t.start_date && t.due_date ? fmtDateKr(t.start_date) + " ~ " + fmtDateKr(t.due_date)
: t.due_date ? "~ " + fmtDateKr(t.due_date) : fmtDateKr(t.start_date) + " ~") +
"</span>"
: "";
return '<li class="pj-mytree-task' + (t.completed_at ? " is-done" : "") + '" data-task-id="' + t.id + '">' +
'<a href="/project/p/' + t.project_id + '?task=' + t.id + '" data-task-id="' + t.id + '" class="pj-task-popup-link">' +
'<span class="pj-mytree-lead">' +
(t.comment_count
? '<span class="pj-cmt-badge" data-task-id="' + t.id + '" data-count="' + t.comment_count + '" title="댓글 ' + t.comment_count + '개">' +
'<span class="pj-cmt-ic">💬</span><span class="pj-cmt-n">' + t.comment_count + "</span></span>"
: "") +
"</span>" +
'<span class="pj-mytree-body"><span class="pj-mytree-title">' + escapeHtml(t.title || "") + "</span>" + dates + "</span>" +
"</a></li>";
}
function syncProjectMyTaskPanel(t, removed) {
const group = document.querySelector('.pj-project-mytasks .pj-mytree-group[data-group-id="' + t.project_id + '"] .pj-mytree-tasks');
if (!group) return; // 이 프로젝트에 내 업무가 하나도 없었으면 다음 새로고침까지 생략.
const existingLink = group.querySelector('.pj-task-popup-link[data-task-id="' + t.id + '"]');
const existingLi = existingLink ? existingLink.closest("li") : null;
if (removed) { if (existingLi) existingLi.remove(); return; }
const wrap = document.createElement("div");
wrap.innerHTML = myTaskRowHtml(t);
const newLi = wrap.firstElementChild;
wireMyTaskPopupLink(newLi.querySelector(".pj-task-popup-link"));
if (existingLi) existingLi.replaceWith(newLi); else group.appendChild(newLi);
}
// 내 업무 항목 우클릭 메뉴 — 열기(프로젝트 페이지로 이동)/업무 추가/수정
// (팝업 편집)/완료토글/삭제. "업무 추가"는 현재 열려 있는 프로젝트로만
// 새 업무를 만들 수 있는 openTaskModal(null) 의 제약 때문에, 이 업무가
// 다른 프로젝트 소속이면 그 프로젝트 페이지로 이동시켜 거기서 추가하게 한다.
function myTaskContextItems(t) {
return [
{ label: "열기", onClick: function () { location.href = "/project/p/" + t.project_id + "?task=" + t.id; } },
{
label: "업무 추가",
onClick: function () {
if (t.project_id === projectId) openTaskModal(null);
else location.href = "/project/p/" + t.project_id;
},
},
{ label: "수정", onClick: function () { openTaskModal(t); } },
{
label: t.completed_at ? "완료 취소" : "완료로 표시",
onClick: async function () {
try {
const res = await api("PUT", "/project/api/tasks/" + t.id, { completed: !t.completed_at });
mergeTask(res.task);
myTasksById[t.id] = Object.assign({}, t, res.task);
syncProjectMyTaskPanel(myTasksById[t.id]);
refreshActiveView();
} catch (e) { showToast("처리 실패: " + e.message, "error"); }
},
},
null,
{
label: "삭제", danger: true,
onClick: async function () {
if (!canDelete(t.created_by)) { alert(DELETE_DENIED); return; }
if (!confirm("이 업무를 삭제할까요?")) return;
try {
await api("DELETE", "/project/api/tasks/" + t.id);
tasks = tasks.filter(function (x) { return x.id !== t.id; });
delete myTasksById[t.id];
syncProjectMyTaskPanel(t, true);
refreshActiveView();
} catch (e) { showToast("삭제 실패: " + e.message, "error"); }
},
},
];
}
function wireMyTaskPopupLink(a) {
a.addEventListener("click", function (e) {
e.preventDefault();
const t = myTasksById[a.dataset.taskId];
if (t) openTaskModal(t);
});
});
a.addEventListener("contextmenu", function (e) {
const t = myTasksById[a.dataset.taskId];
if (!t) return;
e.preventDefault();
e.stopPropagation();
showContextMenu(e.clientX, e.clientY, myTaskContextItems(t));
});
}
document.querySelectorAll(".pj-project-mytasks .pj-task-popup-link").forEach(function (a) { wireMyTaskPopupLink(a); });
// 담당자/작성자 아이콘 = 구글 프로필 이미지(원형). 없으면 기본 아이콘.
function avatarHtml(email, cls) {
const url = email ? avatars[String(email).toLowerCase()] : "";