diff --git a/app/modules/project/db.py b/app/modules/project/db.py index 672a374..00a4626 100644 --- a/app/modules/project/db.py +++ b/app/modules/project/db.py @@ -521,6 +521,26 @@ class ProjectStore: ).fetchone() 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: with self._pool.connection() as conn: row = conn.execute( diff --git a/app/modules/project/router.py b/app/modules/project/router.py index a1e7866..b8c1ce1 100644 --- a/app/modules/project/router.py +++ b/app/modules/project/router.py @@ -813,6 +813,28 @@ async def api_add_comment( 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}") async def api_delete_comment(request: Request, comment_id: int) -> JSONResponse: from app.store import is_admin # noqa: WPS433 diff --git a/app/modules/project/templates/project/inbox.html b/app/modules/project/templates/project/inbox.html index 3bb37f3..d311d2f 100644 --- a/app/modules/project/templates/project/inbox.html +++ b/app/modules/project/templates/project/inbox.html @@ -1,7 +1,7 @@ {% extends "erp_base.html" %} {% block head_extra %} - + {% endblock %} @@ -45,5 +45,5 @@ {% endif %} - + {% endblock %} diff --git a/app/modules/project/templates/project/index.html b/app/modules/project/templates/project/index.html index 23e1a15..6062378 100644 --- a/app/modules/project/templates/project/index.html +++ b/app/modules/project/templates/project/index.html @@ -1,7 +1,7 @@ {% extends "erp_base.html" %} {% block head_extra %} - + {% endblock %} @@ -101,5 +101,5 @@ - + {% endblock %} diff --git a/app/modules/project/templates/project/project.html b/app/modules/project/templates/project/project.html index dd65ee5..3552fc4 100644 --- a/app/modules/project/templates/project/project.html +++ b/app/modules/project/templates/project/project.html @@ -1,7 +1,7 @@ {% extends "erp_base.html" %} {% block head_extra %} - + @@ -256,5 +256,5 @@ - + {% endblock %} diff --git a/app/static/project.css b/app/static/project.css index 0e9ebda..a03e80e 100644 --- a/app/static/project.css +++ b/app/static/project.css @@ -189,6 +189,11 @@ .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 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; } diff --git a/app/static/project.js b/app/static/project.js index 7872ac7..4014103 100644 --- a/app/static/project.js +++ b/app/static/project.js @@ -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 @@ '
' + escapeHtml(idOf(c.author_email)) + ' ' + (c.created_at || "").slice(0, 16).replace("T", " ") + "
" + - '
' + escapeHtml(c.body) + "
" + - ''; - 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); } + '
' + + '' + + '' + + '' + + ""; + 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 = "
  • 불러오기 실패
  • "; } } + // 댓글 인라인 수정 — 본문을 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();