feat(project): 카드 드래그 순서변경/전체멤버/탭 슬라이드/내업무 실시간반영

- 홈 프로젝트 카드 드래그 재정렬(관리자, projects.sort_order 공유)
- 프로젝트 제목 색상 pill 스타일로 강조
- 보드 세션 우클릭에 "업무 추가" 메뉴 추가
- 좌측 "멤버"→"프로젝트 멤버" + "전체 멤버" 그리드(클릭 시 담당 업무 팝업)
- 뷰 탭/스코프 토글에 슬라이드 하이라이트 애니메이션
- "내 업무" 패널이 8초 실시간 폴링에서 빠져 있던 문제 수정(홈/프로젝트 화면 모두 /api/my-tasks 로 patch)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 10:58:06 +09:00
parent 62ab883d24
commit 2f611767ca
7 changed files with 331 additions and 18 deletions
+198 -6
View File
@@ -114,6 +114,37 @@
return mm + "/" + dd + "(" + wd + ")";
}
// ── 탭 그룹(뷰 탭/스코프 토글, 둘 다 .pj-view-tabs 마크업 공유)에 슬라이드
// 하이라이트를 붙인다 — is-active 를 단순 배경색 대신, 실제 위치가 움직이는
// 절대배치 요소 하나로 표현한다. 탭 클릭·초기 렌더·리사이즈마다 위치 재계산.
function initTabSlider(container) {
if (!container || container.dataset.pjSlider) return null;
container.dataset.pjSlider = "1";
const hl = document.createElement("div");
hl.className = "pj-tab-highlight";
container.insertBefore(hl, container.firstChild);
function move(animate) {
const active = container.querySelector(".pj-tab.is-active");
if (!active) { hl.style.opacity = "0"; return; }
hl.style.opacity = "1";
if (!animate) hl.classList.add("pj-no-anim");
hl.style.width = active.offsetWidth + "px";
hl.style.height = active.offsetHeight + "px";
hl.style.transform = "translateX(" + active.offsetLeft + "px)";
if (!animate) {
// 강제 리플로우로 즉시 반영시킨 뒤 트랜지션을 다시 켠다(다음 클릭부터 애니메이션).
void hl.offsetWidth;
hl.classList.remove("pj-no-anim");
}
}
move(false);
container.addEventListener("click", function (e) {
if (e.target.closest(".pj-tab")) requestAnimationFrame(function () { move(true); });
});
window.addEventListener("resize", function () { move(false); });
return move;
}
// 멤버 선택 후보 카드(아이콘 위 / 이름 가운데 / 체크박스 아래) — 새 프로젝트
// 모달(홈·프로젝트 페이지)과 멤버 배정 모달 3곳이 전부 이 함수 하나를 쓴다.
// /api/assignable-users 응답의 avatar 필드를 그대로 쓰므로 페이지별
@@ -286,6 +317,13 @@
// 카드 하나에 수정/업무추가/우클릭 메뉴를 건다 — 최초 로드된 카드·새로
// 만든 카드 모두 이 함수 하나로 통일(initHome 맨 끝에서 전체 카드에 호출).
function wireProjectCard(card) {
if (isAdmin) {
card.draggable = true;
// <a> 는 브라우저 기본값이 draggable="true" — 그대로 두면 카드 대신
// 링크(URL) 드래그가 시작돼 우리 dragstart 핸들러가 무력화된다.
const link = card.querySelector(".pj-project-cardlink");
if (link) link.draggable = false;
}
const editBtn = card.querySelector(".pj-card-edit");
if (editBtn) editBtn.addEventListener("click", function (e) {
e.preventDefault(); e.stopPropagation();
@@ -1039,6 +1077,74 @@
// 최초 로드된 카드 + 이후 새로 만든 카드 모두 여기서 한 번에 배선.
document.querySelectorAll(".pj-project-card").forEach(wireProjectCard);
// 프로젝트 카드 드래그로 순서 변경 — 정렬은 화면을 보는 모두에게 공유되므로
// (세션 재정렬과 같은 원칙) 관리자만 드래그할 수 있다(wireProjectCard 가
// isAdmin 일 때만 draggable 을 켠다).
if (isAdmin) wireProjectGridDrag();
// ── 실시간 동기화: "내 업무" 패널만 8초 안에 반영(카드 미리보기 목록은
// 기존처럼 다음 새로고침까지 유지 — initLiveRefresh 주석 참고). 다른
// 사용자가 내 업무를 수정/완료/재배정해도 여기서 바로 patch 된다.
softSyncProjectRef = async function () {
try {
const res = await api("GET", "/project/api/my-tasks");
const fresh = res.tasks || [];
const freshIds = new Set(fresh.map(function (t) { return t.id; }));
document.querySelectorAll(".pj-mytree-group .pj-mytree-tasks .pj-task-popup-link[data-task-id]").forEach(function (a) {
const id = Number(a.dataset.taskId);
if (freshIds.has(id)) return;
const group = a.closest(".pj-mytree-group");
const pid = group ? Number(group.dataset.groupId) : null;
syncMyTaskPanel({ id: id, project_id: pid }, true);
});
fresh.forEach(function (t) { homeTasks[t.id] = t; syncMyTaskPanel(t); });
} catch (_) {}
};
}
// 프로젝트 카드 드래그 순서 변경 — 진행중/완료 그리드 각각 안에서만
// 재정렬(그리드 사이 드래그로 옮기면 상태가 바뀐 것처럼 보이니 막는다).
function wireProjectGridDrag() {
const grids = [el("pj-project-grid"), el("pj-project-grid-done")].filter(Boolean);
if (!grids.length) return;
let dragCard = null, sourceGrid = null;
grids.forEach(function (grid) {
grid.addEventListener("dragstart", function (e) {
const card = e.target.closest(".pj-project-card");
if (!card || card.draggable !== true) return;
dragCard = card;
sourceGrid = grid;
e.dataTransfer.setData("application/x-pj-project", String(card.dataset.id));
e.dataTransfer.effectAllowed = "move";
card.classList.add("is-dragging");
});
grid.addEventListener("dragend", function () {
if (dragCard) dragCard.classList.remove("is-dragging");
dragCard = null; sourceGrid = null;
});
grid.addEventListener("dragover", function (e) {
if (!dragCard || grid !== sourceGrid) return;
const target = e.target.closest(".pj-project-card");
if (!target || target === dragCard) { e.preventDefault(); return; }
e.preventDefault();
const r = target.getBoundingClientRect();
const before = (e.clientX - r.left) < r.width / 2;
grid.insertBefore(dragCard, before ? target : target.nextSibling);
});
grid.addEventListener("drop", async function (e) {
if (!dragCard || grid !== sourceGrid) return;
e.preventDefault();
const ids = Array.prototype.slice.call(grid.querySelectorAll(".pj-project-card"))
.map(function (c) { return parseInt(c.dataset.id, 10); });
try {
await api("PUT", "/project/api/projects/order", { ordered_ids: ids });
} catch (err) {
showToast("순서 변경 실패: " + err.message, "error");
location.reload();
}
});
});
}
// ════════════════════════════════════════════════════════════
@@ -1209,6 +1315,7 @@
// ── 뷰 토글 ──
const tabs = el("pj-view-tabs");
const moveViewHighlight = initTabSlider(tabs);
tabs.addEventListener("click", function (e) {
const btn = e.target.closest(".pj-tab");
if (!btn) return;
@@ -1231,10 +1338,12 @@
document.querySelectorAll(".pj-view").forEach(function (v) {
v.hidden = v.dataset.view !== currentView;
});
if (moveViewHighlight) moveViewHighlight(false);
}
// ── 프로젝트 스코프 토글 ──
const scopeToggle = el("pj-scope-toggle");
const moveScopeHighlight = initTabSlider(scopeToggle);
if (scopeToggle) {
scopeToggle.addEventListener("click", function (e) {
const btn = e.target.closest("[data-scope]");
@@ -1246,6 +1355,7 @@
renderView(currentView);
});
}
if (moveScopeHighlight) moveScopeHighlight(false);
function renderView(view) {
if (view === "calendar") renderCalendar();
@@ -1867,6 +1977,7 @@
enableDropByStageId(body, s.id);
wireColumnControls(col, s);
wireColumnDrag(col, s);
wireStageAddTask(body, s);
}
board.appendChild(col);
});
@@ -1931,6 +2042,7 @@
if (head) head.addEventListener("contextmenu", function (e) {
e.preventDefault();
showContextMenu(e.clientX, e.clientY, [
{ label: "업무 추가", onClick: function () { openTaskModal(null, s.id); } },
{ label: "이름 변경", onClick: renameStage },
{ label: s.is_done_stage ? "완료 세션 해제" : "완료 세션으로 지정", onClick: toggleDoneStage },
null,
@@ -1939,6 +2051,18 @@
});
}
// 세션 본문(카드 사이 빈 공간) 우클릭 — "업무 추가"만. 카드 위에서 우클릭한
// 경우는 taskCard() 쪽 컨텍스트 메뉴(수정/완료/삭제 등)가 대신 뜨도록 건너뛴다.
function wireStageAddTask(body, s) {
body.addEventListener("contextmenu", function (e) {
if (e.target.closest(".pj-card")) return;
e.preventDefault();
showContextMenu(e.clientX, e.clientY, [
{ label: "업무 추가", onClick: function () { openTaskModal(null, s.id); } },
]);
});
}
// 세션(컬럼) 드래그 재정렬 — 카드 드래그와 같은 네이티브 HTML5 DnD 방식이되,
// dataTransfer 타입을 달리 써서(application/x-pj-stage) 카드 드롭과 안 섞인다.
function wireColumnDrag(col, s) {
@@ -2604,7 +2728,7 @@
sel.value = currentEmail || "";
}
function openTaskModal(task) {
function openTaskModal(task, presetStageId) {
const projName = task ? (task.project_name || currentProject.name || "") : (currentProject.name || "");
el("pj-task-modal-title").textContent = "[" + projName + "]의 " + (task ? "업무 편집" : "새 업무");
el("pj-t-id").value = task ? task.id : "";
@@ -2612,7 +2736,8 @@
if (descEditor) { descEditor.setTaskId(task ? task.id : null); descEditor.setHtml(task ? (task.description || "") : ""); }
fillAssigneeSelect(task ? task.project_id : projectId, task ? (task.assignee_email || "") : "");
fillStageSelect(task ? task.project_id : projectId);
el("pj-t-stage").value = task && task.stage_id ? task.stage_id : (stages[0] ? stages[0].id : "");
el("pj-t-stage").value = task && task.stage_id ? task.stage_id
: (presetStageId != null ? presetStageId : (stages[0] ? stages[0].id : ""));
el("pj-t-priority").value = task ? (task.priority || "normal") : "normal";
el("pj-t-start").value = task ? (task.start_date || "") : "";
el("pj-t-due").value = task ? (task.due_date || "") : "";
@@ -3066,6 +3191,56 @@
});
}
renderMemberList();
// ── 전체 멤버(프로젝트 권한 보유 등록 사용자 전원) — 아이콘 클릭 시
// 그 사람이 담당자인 업무를 팝업으로 보여준다(프로젝트/세션/마감일).
function allMemberItemHtml(u) {
const icon = u.avatar
? '<img class="pj-avatar pj-member-pick-icon" src="' + escapeHtml(u.avatar) + '" alt="" referrerpolicy="no-referrer" />'
: '<span class="material-symbols-outlined pj-gicon pj-member-pick-icon">account_circle</span>';
return '<button type="button" class="pj-allmember-item" data-email="' + escapeHtml(u.email) + '" data-name="' + escapeHtml(u.id) + '">' +
icon + '<span class="pj-allmember-name">' + escapeHtml(u.id) + "</span></button>";
}
const memberTasksModal = el("pj-modal-member-tasks");
if (memberTasksModal) wireModalClose(memberTasksModal);
async function openMemberTasksModal(email, name) {
if (!memberTasksModal) return;
el("pj-mt-title").textContent = (name || email) + "님의 업무";
const list = el("pj-mt-list");
list.innerHTML = "<li class='pj-empty-sm'>불러오는 중…</li>";
openModal(memberTasksModal);
try {
const res = await api("GET", "/project/api/users/tasks?email=" + encodeURIComponent(email));
const items = res.tasks || [];
list.innerHTML = items.length
? items.map(function (t) {
return '<li class="pj-mt-row' + (t.completed_at ? " is-done" : "") + '">' +
'<span class="pj-mt-proj" style="color:' + (t.project_color || "#4573d2") + '">' + escapeHtml(t.project_name || "-") + "</span>" +
'<span class="pj-mt-stage">' + escapeHtml(t.stage_name || "미지정") + "</span>" +
'<span class="pj-mt-title2">' + escapeHtml(t.title || "") + "</span>" +
'<span class="pj-mt-due">' + (t.due_date ? fmtDateKr(t.due_date) : "-") + "</span>" +
"</li>";
}).join("")
: "<li class='pj-empty-sm'>배정된 업무가 없습니다.</li>";
} catch (e) {
list.innerHTML = "<li class='pj-empty-sm'>불러오기 실패: " + escapeHtml(e.message) + "</li>";
}
}
const allMembersGrid = el("pj-allmembers-grid");
if (allMembersGrid) {
api("GET", "/project/api/all-users").then(function (res) {
const users = res.users || [];
allMembersGrid.innerHTML = users.length
? users.map(allMemberItemHtml).join("")
: "<span class='pj-empty-sm'>없음</span>";
allMembersGrid.querySelectorAll(".pj-allmember-item").forEach(function (btn) {
btn.addEventListener("click", function () { openMemberTasksModal(btn.dataset.email, btn.dataset.name); });
});
}).catch(function (e) {
allMembersGrid.innerHTML = "<span class='pj-empty-sm'>불러오기 실패: " + escapeHtml(e.message) + "</span>";
});
}
const addMemberBtn = el("pj-add-member");
if (addMemberBtn) addMemberBtn.addEventListener("click", async function () {
const box = el("pj-m-users");
@@ -3126,10 +3301,11 @@
// live-version 자체가 감지 못하는 기존 사각지대 — 다음 새로고침까지 반영 안 됨.
softSyncProjectRef = async function () {
try {
const [tRes, sRes, mRes] = await Promise.all([
const [tRes, sRes, mRes, myRes] = await Promise.all([
api("GET", "/project/api/projects/" + projectId + "/tasks"),
api("GET", "/project/api/projects/" + projectId + "/stages"),
api("GET", "/project/api/projects/" + projectId + "/members"),
api("GET", "/project/api/my-tasks"),
]);
// 서버 페이지 렌더 시엔 router.py 가 각 업무에 context_project_id(=지금
// 보는 프로젝트 id)를 붙여준다(멀티호밍 스코프 필터의 기준) — 이 API
@@ -3144,6 +3320,21 @@
members = mRes.members || [];
renderMemberList();
refreshActiveView();
// 우측 "내 업무" 패널 — 다른 사람이 내 업무를 수정/완료/재배정해도
// 다음 전체 새로고침까지 기다리지 않고 8초 안에 반영되게 patch.
const freshMy = myRes.tasks || [];
const freshIds = new Set(freshMy.map(function (t) { return t.id; }));
Object.keys(myTasksById).forEach(function (idStr) {
const id = Number(idStr);
if (freshIds.has(id)) return;
const old = myTasksById[id];
delete myTasksById[id];
syncProjectMyTaskPanel(old, true);
});
freshMy.forEach(function (t) {
myTasksById[t.id] = t;
syncProjectMyTaskPanel(t);
});
} catch (_) {}
};
}
@@ -3672,9 +3863,10 @@
// ── 실시간 새로고침 ──
// 다른 사용자가 프로젝트/업무/댓글/멤버/단계를 추가·수정하면, 같은 화면을 열어둔
// 모든 사용자에게 8초 주기로 반영한다. 전체 새로고침 대신 배경 목록만 조용히
// 다시 받아 patch 한다(입력 중인 모달 폼은 건드리지 않음) — initProject() 가
// 끝나면서 softSyncProjectRef 를 채워준다(홈 페이지는 채우지 않아 벨 배지만
// 갱신). initLiveRefresh 는 어느 페이지든(프로젝트/홈) 항상 이 하나로 처리.
// 다시 받아 patch 한다(입력 중인 모달 폼은 건드리지 않음) — initHome()/
// initProject() 가 끝나면서 각자 softSyncProjectRef 를 채워준다(홈은 "내
// 업무" 패널 + 벨 배지, 프로젝트 화면은 보드/리스트/멤버 + "내 업무" 패널).
// initLiveRefresh 는 어느 페이지든(프로젝트/홈) 항상 이 하나로 처리.
let softSyncProjectRef = null;
function initLiveRefresh() {
if (!document.querySelector(".pj-wrap, .pj-project-view")) return;