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:
@@ -521,6 +521,26 @@ class ProjectStore:
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
return self._serialize(row)
|
return self._serialize(row)
|
||||||
|
|
||||||
|
def update_comment(
|
||||||
|
self, *, comment_id: int, requester: str, is_admin: bool, body: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
text = store.norm_str(body)
|
||||||
|
if not text:
|
||||||
|
raise ValueError("댓글 내용이 비었습니다.")
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT author_email FROM task_comments WHERE id = %s", (comment_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise KeyError(comment_id)
|
||||||
|
if not is_admin and row["author_email"] != store.norm_str(requester, lower=True):
|
||||||
|
raise PermissionError("본인 댓글만 수정할 수 있습니다.")
|
||||||
|
out = conn.execute(
|
||||||
|
"UPDATE task_comments SET body = %s WHERE id = %s RETURNING *",
|
||||||
|
(text, comment_id),
|
||||||
|
).fetchone()
|
||||||
|
return self._serialize(out)
|
||||||
|
|
||||||
def delete_comment(self, *, comment_id: int, requester: str, is_admin: bool) -> None:
|
def delete_comment(self, *, comment_id: int, requester: str, is_admin: bool) -> None:
|
||||||
with self._pool.connection() as conn:
|
with self._pool.connection() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
|
|||||||
@@ -813,6 +813,28 @@ async def api_add_comment(
|
|||||||
return JSONResponse({"comment": comment}, status_code=201)
|
return JSONResponse({"comment": comment}, status_code=201)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/comments/{comment_id}")
|
||||||
|
async def api_update_comment(
|
||||||
|
request: Request, comment_id: int, payload: dict[str, Any] = Body(...)
|
||||||
|
) -> JSONResponse:
|
||||||
|
from app.store import is_admin # noqa: WPS433
|
||||||
|
|
||||||
|
user = _require_user(request)
|
||||||
|
st = _db_or_503(request)
|
||||||
|
try:
|
||||||
|
comment = st.update_comment(
|
||||||
|
comment_id=comment_id, requester=user["email"],
|
||||||
|
is_admin=is_admin(user), body=payload.get("body", ""),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(status_code=404, detail="댓글을 찾을 수 없습니다.")
|
||||||
|
except PermissionError as exc:
|
||||||
|
raise HTTPException(status_code=403, detail=str(exc))
|
||||||
|
return JSONResponse({"comment": comment})
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/api/comments/{comment_id}")
|
@router.delete("/api/comments/{comment_id}")
|
||||||
async def api_delete_comment(request: Request, comment_id: int) -> JSONResponse:
|
async def api_delete_comment(request: Request, comment_id: int) -> JSONResponse:
|
||||||
from app.store import is_admin # noqa: WPS433
|
from app.store import is_admin # noqa: WPS433
|
||||||
|
|||||||
@@ -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=20260625f" />
|
<link rel="stylesheet" href="/static/project.css?v=20260625g" />
|
||||||
<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=20260625f" defer></script>
|
<script src="/static/project.js?v=20260625g" 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=20260625f" />
|
<link rel="stylesheet" href="/static/project.css?v=20260625g" />
|
||||||
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
|
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -101,5 +101,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=20260625f" defer></script>
|
<script src="/static/project.js?v=20260625g" 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=20260625f" />
|
<link rel="stylesheet" href="/static/project.css?v=20260625g" />
|
||||||
<!-- 구글 머티리얼 심볼(담당자 아이콘 등) — 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 -->
|
||||||
@@ -256,5 +256,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=20260625f" defer></script>
|
<script src="/static/project.js?v=20260625g" defer></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -189,6 +189,11 @@
|
|||||||
.pj-comment-body { font-size: 13px; color: #333; margin-top: 1px; white-space: pre-wrap; }
|
.pj-comment-body { font-size: 13px; color: #333; margin-top: 1px; white-space: pre-wrap; }
|
||||||
.pj-comment-form { display: flex; gap: 6px; }
|
.pj-comment-form { display: flex; gap: 6px; }
|
||||||
.pj-comment-form input { flex: 1; padding: 7px 10px; border: 1px solid #d8dce2; border-radius: 8px; font-size: 13px; font-family: inherit; }
|
.pj-comment-form input { flex: 1; padding: 7px 10px; border: 1px solid #d8dce2; border-radius: 8px; font-size: 13px; font-family: inherit; }
|
||||||
|
.pj-comment-acts { display: inline-flex; gap: 1px; flex: 0 0 auto; }
|
||||||
|
.pj-comment-edit-box { margin-top: 5px; }
|
||||||
|
.pj-comment-edit-box textarea { width: 100%; box-sizing: border-box; padding: 7px 9px; border: 1px solid #d8dce2; border-radius: 8px; font-size: 13px; font-family: inherit; resize: vertical; }
|
||||||
|
.pj-comment-edit-actions { display: flex; gap: 6px; justify-content: flex-end; margin-top: 6px; }
|
||||||
|
.pj-comment-edit-actions .pj-btn { padding: 4px 12px; font-size: 12px; }
|
||||||
|
|
||||||
/* ── 보드 단계 컨트롤 ── */
|
/* ── 보드 단계 컨트롤 ── */
|
||||||
.pj-col-head { position: relative; }
|
.pj-col-head { position: relative; }
|
||||||
|
|||||||
+101
-32
@@ -142,6 +142,39 @@
|
|||||||
return Math.round((new Date(b + "T00:00:00") - new Date(a + "T00:00:00")) / 86400000);
|
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() {
|
function wireCalendar() {
|
||||||
if (calendarWired) return;
|
if (calendarWired) return;
|
||||||
calendarWired = true;
|
calendarWired = true;
|
||||||
@@ -157,41 +190,37 @@
|
|||||||
if (canManage) {
|
if (canManage) {
|
||||||
bar.draggable = true;
|
bar.draggable = true;
|
||||||
bar.addEventListener("dragstart", function (e) {
|
bar.addEventListener("dragstart", function (e) {
|
||||||
e.dataTransfer.setData("text/plain", bar.dataset.taskId);
|
dragTaskId = bar.dataset.taskId;
|
||||||
e.dataTransfer.effectAllowed = "move";
|
e.dataTransfer.effectAllowed = "move";
|
||||||
|
try { e.dataTransfer.setData("text/plain", bar.dataset.taskId); } catch (_) {}
|
||||||
if (cal) cal.classList.add("pj-cal-dragging");
|
if (cal) cal.classList.add("pj-cal-dragging");
|
||||||
});
|
});
|
||||||
bar.addEventListener("dragend", function () {
|
bar.addEventListener("dragend", function () {
|
||||||
|
dragTaskId = null;
|
||||||
if (cal) cal.classList.remove("pj-cal-dragging");
|
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;
|
if (!canManage) return;
|
||||||
document.querySelectorAll(".pj-day[data-date]").forEach(function (cell) {
|
// 드롭 대상은 주(week) 단위로 받고, 마우스 X 로 어느 날짜 칸인지 계산.
|
||||||
cell.addEventListener("dragover", function (e) { e.preventDefault(); cell.classList.add("pj-drop-over"); });
|
// (bar 오버레이/pointer-events 간섭 없이 안정적으로 드롭됨)
|
||||||
cell.addEventListener("dragleave", function () { cell.classList.remove("pj-drop-over"); });
|
document.querySelectorAll(".pj-week").forEach(function (week) {
|
||||||
cell.addEventListener("drop", async function (e) {
|
week.addEventListener("dragover", function (e) {
|
||||||
|
if (!dragTaskId) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
cell.classList.remove("pj-drop-over");
|
e.dataTransfer.dropEffect = "move";
|
||||||
const taskId = e.dataTransfer.getData("text/plain");
|
const hit = dateAtPointer(week, e.clientX);
|
||||||
const t = tasks.find(function (x) { return String(x.id) === String(taskId); });
|
if (hit) { clearDropHL(); hit.cell.classList.add("pj-drop-over"); }
|
||||||
if (!t) return;
|
});
|
||||||
const dropDate = cell.dataset.date;
|
week.addEventListener("drop", function (e) {
|
||||||
// 앵커(시작일 우선, 없으면 마감일) 기준 이동량 → 기간 유지하며 시프트
|
e.preventDefault();
|
||||||
const anchor = t.start_date || t.due_date;
|
const taskId = dragTaskId || e.dataTransfer.getData("text/plain");
|
||||||
if (!anchor) return;
|
clearDropHL();
|
||||||
const delta = dayDiff(anchor, dropDate);
|
if (!taskId) return;
|
||||||
if (delta === 0) return;
|
const hit = dateAtPointer(week, e.clientX);
|
||||||
const payload = {
|
if (hit) moveTaskTo(taskId, hit.date);
|
||||||
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); }
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -471,18 +500,58 @@
|
|||||||
'<div class="pj-comment-main"><div class="pj-comment-head">' +
|
'<div class="pj-comment-main"><div class="pj-comment-head">' +
|
||||||
escapeHtml(idOf(c.author_email)) + ' <span class="pj-comment-time">' +
|
escapeHtml(idOf(c.author_email)) + ' <span class="pj-comment-time">' +
|
||||||
(c.created_at || "").slice(0, 16).replace("T", " ") + "</span></div>" +
|
(c.created_at || "").slice(0, 16).replace("T", " ") + "</span></div>" +
|
||||||
'<div class="pj-comment-body">' + escapeHtml(c.body) + "</div></div>" +
|
'<div class="pj-comment-body"></div></div>' +
|
||||||
'<button type="button" class="pj-icon-btn pj-comment-del" data-id="' + c.id + '">×</button>';
|
'<span class="pj-comment-acts">' +
|
||||||
ul.appendChild(li);
|
'<button type="button" class="pj-icon-btn pj-comment-edit" title="수정">✎</button>' +
|
||||||
});
|
'<button type="button" class="pj-icon-btn pj-comment-del" title="삭제">×</button>' +
|
||||||
ul.querySelectorAll(".pj-comment-del").forEach(function (b) {
|
"</span>";
|
||||||
b.addEventListener("click", async function () {
|
li.querySelector(".pj-comment-body").textContent = c.body; // XSS 안전(textContent)
|
||||||
try { await api("DELETE", "/project/api/comments/" + b.dataset.id); loadComments(taskId); }
|
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); }
|
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>"; }
|
} 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() {
|
async function sendComment() {
|
||||||
const inp = el("pj-comment-input");
|
const inp = el("pj-comment-input");
|
||||||
const body = inp.value.trim();
|
const body = inp.value.trim();
|
||||||
|
|||||||
Reference in New Issue
Block a user