feat(project): 댓글 수정 기능 + 달력 드래그 드롭 수정

- 댓글 수정: PUT /api/comments/{id}(본인·관리자), 모달서 인라인 편집(textarea)
- 달력 드래그 드롭이 안 되던 문제 수정: 드롭 대상을 day 셀 대신 주(week)
  단위로 받고 마우스 X 로 날짜 칸을 계산. bar 오버레이/pointer-events 간섭
  없이 안정적으로 드롭됨. dragover 에서 dropEffect=move 명시.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 12:42:04 +09:00
parent 15eabd0bbf
commit 5f85826965
7 changed files with 154 additions and 38 deletions
+101 -32
View File
@@ -142,6 +142,39 @@
return Math.round((new Date(b + "T00:00:00") - new Date(a + "T00:00:00")) / 86400000);
}
let dragTaskId = null;
// 한 주 안에서 clientX → 0..6 열 인덱스 → 그 칸의 날짜.
function dateAtPointer(week, clientX) {
const cells = week.querySelectorAll(".pj-week-days .pj-day[data-date]");
if (!cells.length) return null;
const rect = week.getBoundingClientRect();
let col = Math.floor((clientX - rect.left) / (rect.width / 7));
col = Math.max(0, Math.min(cells.length - 1, col));
return { date: cells[col].dataset.date, cell: cells[col] };
}
function clearDropHL() {
document.querySelectorAll(".pj-day.pj-drop-over").forEach(function (d) { d.classList.remove("pj-drop-over"); });
}
async function moveTaskTo(taskId, dropDate) {
const t = tasks.find(function (x) { return String(x.id) === String(taskId); });
if (!t) return;
const anchor = t.start_date || t.due_date;
if (!anchor) { alert("날짜가 없는 업무는 달력에서 이동할 수 없습니다."); return; }
const delta = dayDiff(anchor, dropDate);
if (delta === 0) return;
const payload = {
start_date: t.start_date ? addDays(t.start_date, delta) : null,
due_date: t.due_date ? addDays(t.due_date, delta) : null,
};
try {
await api("PUT", "/project/api/tasks/" + taskId, payload);
location.reload();
} catch (err) { alert("이동 실패: " + err.message); }
}
function wireCalendar() {
if (calendarWired) return;
calendarWired = true;
@@ -157,41 +190,37 @@
if (canManage) {
bar.draggable = true;
bar.addEventListener("dragstart", function (e) {
e.dataTransfer.setData("text/plain", bar.dataset.taskId);
dragTaskId = bar.dataset.taskId;
e.dataTransfer.effectAllowed = "move";
try { e.dataTransfer.setData("text/plain", bar.dataset.taskId); } catch (_) {}
if (cal) cal.classList.add("pj-cal-dragging");
});
bar.addEventListener("dragend", function () {
dragTaskId = null;
if (cal) cal.classList.remove("pj-cal-dragging");
document.querySelectorAll(".pj-day.pj-drop-over").forEach(function (d) { d.classList.remove("pj-drop-over"); });
clearDropHL();
});
}
});
if (!canManage || !cal) return;
document.querySelectorAll(".pj-day[data-date]").forEach(function (cell) {
cell.addEventListener("dragover", function (e) { e.preventDefault(); cell.classList.add("pj-drop-over"); });
cell.addEventListener("dragleave", function () { cell.classList.remove("pj-drop-over"); });
cell.addEventListener("drop", async function (e) {
if (!canManage) return;
// 드롭 대상은 주(week) 단위로 받고, 마우스 X 로 어느 날짜 칸인지 계산.
// (bar 오버레이/pointer-events 간섭 없이 안정적으로 드롭됨)
document.querySelectorAll(".pj-week").forEach(function (week) {
week.addEventListener("dragover", function (e) {
if (!dragTaskId) return;
e.preventDefault();
cell.classList.remove("pj-drop-over");
const taskId = e.dataTransfer.getData("text/plain");
const t = tasks.find(function (x) { return String(x.id) === String(taskId); });
if (!t) return;
const dropDate = cell.dataset.date;
// 앵커(시작일 우선, 없으면 마감일) 기준 이동량 → 기간 유지하며 시프트
const anchor = t.start_date || t.due_date;
if (!anchor) return;
const delta = dayDiff(anchor, dropDate);
if (delta === 0) return;
const payload = {
start_date: t.start_date ? addDays(t.start_date, delta) : null,
due_date: t.due_date ? addDays(t.due_date, delta) : null,
};
try {
await api("PUT", "/project/api/tasks/" + taskId, payload);
location.reload();
} catch (err) { alert("이동 실패: " + err.message); }
e.dataTransfer.dropEffect = "move";
const hit = dateAtPointer(week, e.clientX);
if (hit) { clearDropHL(); hit.cell.classList.add("pj-drop-over"); }
});
week.addEventListener("drop", function (e) {
e.preventDefault();
const taskId = dragTaskId || e.dataTransfer.getData("text/plain");
clearDropHL();
if (!taskId) return;
const hit = dateAtPointer(week, e.clientX);
if (hit) moveTaskTo(taskId, hit.date);
});
});
}
@@ -471,18 +500,58 @@
'<div class="pj-comment-main"><div class="pj-comment-head">' +
escapeHtml(idOf(c.author_email)) + ' <span class="pj-comment-time">' +
(c.created_at || "").slice(0, 16).replace("T", " ") + "</span></div>" +
'<div class="pj-comment-body">' + escapeHtml(c.body) + "</div></div>" +
'<button type="button" class="pj-icon-btn pj-comment-del" data-id="' + c.id + '">×</button>';
ul.appendChild(li);
});
ul.querySelectorAll(".pj-comment-del").forEach(function (b) {
b.addEventListener("click", async function () {
try { await api("DELETE", "/project/api/comments/" + b.dataset.id); loadComments(taskId); }
'<div class="pj-comment-body"></div></div>' +
'<span class="pj-comment-acts">' +
'<button type="button" class="pj-icon-btn pj-comment-edit" title="수정">✎</button>' +
'<button type="button" class="pj-icon-btn pj-comment-del" title="삭제">×</button>' +
"</span>";
li.querySelector(".pj-comment-body").textContent = c.body; // XSS 안전(textContent)
li.querySelector(".pj-comment-del").addEventListener("click", async function () {
try { await api("DELETE", "/project/api/comments/" + c.id); loadComments(taskId); }
catch (e) { alert("삭제 실패: " + e.message); }
});
li.querySelector(".pj-comment-edit").addEventListener("click", function () {
editComment(li, c, taskId);
});
ul.appendChild(li);
});
} catch (e) { ul.innerHTML = "<li class='pj-empty-sm'>불러오기 실패</li>"; }
}
// 댓글 인라인 수정 — 본문을 textarea 로 교체, 저장/취소.
function editComment(li, c, taskId) {
const main = li.querySelector(".pj-comment-main");
const bodyEl = li.querySelector(".pj-comment-body");
const acts = li.querySelector(".pj-comment-acts");
if (li.querySelector(".pj-comment-edit-box")) return; // 중복 방지
acts.style.visibility = "hidden";
const box = document.createElement("div");
box.className = "pj-comment-edit-box";
const ta = document.createElement("textarea");
ta.rows = 2; ta.value = c.body;
const bar = document.createElement("div");
bar.className = "pj-comment-edit-actions";
const save = document.createElement("button");
save.type = "button"; save.className = "pj-btn pj-btn-primary"; save.textContent = "저장";
const cancel = document.createElement("button");
cancel.type = "button"; cancel.className = "pj-btn"; cancel.textContent = "취소";
bar.appendChild(cancel); bar.appendChild(save);
box.appendChild(ta); box.appendChild(bar);
bodyEl.style.display = "none";
main.appendChild(box);
ta.focus();
cancel.addEventListener("click", function () {
box.remove(); bodyEl.style.display = ""; acts.style.visibility = "";
});
save.addEventListener("click", async function () {
const text = ta.value.trim();
if (!text) { alert("내용을 입력하세요."); return; }
try {
await api("PUT", "/project/api/comments/" + c.id, { body: text });
loadComments(taskId);
} catch (e) { alert("수정 실패: " + e.message); }
});
}
async function sendComment() {
const inp = el("pj-comment-input");
const body = inp.value.trim();