diff --git a/app/modules/project/db.py b/app/modules/project/db.py
index 0ed8283..469568b 100644
--- a/app/modules/project/db.py
+++ b/app/modules/project/db.py
@@ -812,6 +812,20 @@ class ProjectStore:
(i, int(sid), project_id),
)
+ def reorder_tasks(self, *, stage_id: int, ordered_ids: list[int]) -> None:
+ """보드 카드 드래그로 같은 세션 안에서 순서 바꾸기. sort_order 는
+ 업무 생성 시 프로젝트 전체 기준으로 매겨지지만(다른 세션과 섞여도
+ 무방 — 화면은 항상 세션으로 먼저 필터링한 뒤 이 값으로 정렬한다),
+ 여기서는 이 세션 소속 업무만 골라 0부터 다시 매긴다."""
+ with self._pool.connection() as conn:
+ with conn.transaction():
+ for i, tid in enumerate(ordered_ids):
+ conn.execute(
+ "UPDATE tasks SET sort_order = %s "
+ "WHERE id = %s AND stage_id = %s",
+ (i, int(tid), stage_id),
+ )
+
# ════════════════════════════════════════════════════════════
# 알림센터 (project_notifications)
# ════════════════════════════════════════════════════════════
diff --git a/app/modules/project/router.py b/app/modules/project/router.py
index d19bb6c..ef05f8a 100644
--- a/app/modules/project/router.py
+++ b/app/modules/project/router.py
@@ -1287,6 +1287,21 @@ async def api_reorder_stages(
return JSONResponse({"stages": st.list_stages(project_id=project_id)})
+@router.put("/api/projects/{project_id}/stages/{stage_id}/tasks/order")
+async def api_reorder_tasks(
+ request: Request, project_id: int, stage_id: int, payload: dict[str, Any] = Body(...)
+) -> JSONResponse:
+ """보드에서 카드를 같은 세션 안에서 드래그로 재정렬."""
+ user = _require_user(request)
+ st = _db_or_503(request)
+ _require_manage(request, st, user, project_id)
+ ids = payload.get("ordered_ids") or []
+ if not isinstance(ids, list) or not ids:
+ raise HTTPException(status_code=400, detail="ordered_ids 가 필요합니다.")
+ st.reorder_tasks(stage_id=stage_id, ordered_ids=[int(i) for i in ids])
+ return JSONResponse({"ok": True})
+
+
@router.put("/api/projects/{project_id}/stages/{stage_id}")
async def api_update_stage(
request: Request, project_id: int, stage_id: int, payload: dict[str, Any] = Body(...)
diff --git a/app/modules/project/templates/project/inbox.html b/app/modules/project/templates/project/inbox.html
index 87d25db..f779d7f 100644
--- a/app/modules/project/templates/project/inbox.html
+++ b/app/modules/project/templates/project/inbox.html
@@ -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 9cba93a..6705ee8 100644
--- a/app/modules/project/templates/project/index.html
+++ b/app/modules/project/templates/project/index.html
@@ -393,5 +393,5 @@
-
+
{% endblock %}
diff --git a/app/modules/project/templates/project/project.html b/app/modules/project/templates/project/project.html
index 3e60b2a..3986f4d 100644
--- a/app/modules/project/templates/project/project.html
+++ b/app/modules/project/templates/project/project.html
@@ -461,5 +461,5 @@
-
+
{% endblock %}
diff --git a/app/static/project.js b/app/static/project.js
index 0f84132..8deafa8 100644
--- a/app/static/project.js
+++ b/app/static/project.js
@@ -1910,6 +1910,17 @@
// 보드 드롭(프로젝트 스코프) — 세션 id 로 바로 옮긴다. 이 프로젝트가 원
// 소속이 아닌(멀티호밍된) 업무는 원본을 안 건드리고 "이 프로젝트 안에서의
// 세션"만 연결(POST .../links)로 바꾼다.
+ // 드롭 지점(마우스 Y)이 이 컬럼의 카드들 중 어디에 해당하는지 인덱스로.
+ // 드래그 중인 카드 자신은 계산에서 제외(자기 위치가 기준을 흔들지 않게).
+ function cardDropIndex(body, clientY, excludeTaskId) {
+ const cards = Array.prototype.slice.call(body.querySelectorAll(".pj-card"))
+ .filter(function (c) { return String(c.dataset.taskId) !== String(excludeTaskId); });
+ for (let i = 0; i < cards.length; i++) {
+ const r = cards[i].getBoundingClientRect();
+ if (clientY < r.top + r.height / 2) return i;
+ }
+ return cards.length;
+ }
function enableDropByStageId(body, stageId) {
body.addEventListener("dragover", function (e) {
if (!e.dataTransfer.types.includes("text/plain")) return;
@@ -1922,7 +1933,24 @@
body.classList.remove("is-over");
const taskId = parseInt(e.dataTransfer.getData("text/plain"), 10);
const t = tasks.find(function (x) { return x.id === taskId; });
- if (!t || t.stage_id === stageId) return;
+ if (!t) return;
+ if (t.stage_id === stageId) {
+ // 같은 세션 안에서 카드 위치만 이동 — 세션 이동이 아니라 정렬.
+ const currentOrder = tasks.filter(function (x) { return x.stage_id === stageId; });
+ const idx = cardDropIndex(body, e.clientY, taskId);
+ const withoutDragged = currentOrder.filter(function (x) { return x.id !== taskId; });
+ withoutDragged.splice(idx, 0, t);
+ const orderedIds = withoutDragged.map(function (x) { return x.id; });
+ const beforeIds = currentOrder.map(function (x) { return x.id; });
+ if (beforeIds.join(",") === orderedIds.join(",")) return; // 놓은 자리가 원래 자리와 같으면 아무것도 안 함
+ try {
+ await api("PUT", "/project/api/projects/" + projectId + "/stages/" + stageId + "/tasks/order",
+ { ordered_ids: orderedIds });
+ applyStageOrder(stageId, orderedIds);
+ renderBoard();
+ } catch (err) { showToast("순서 변경 실패: " + err.message, "error"); }
+ return;
+ }
try {
if (t.project_id === projectId) {
const res = await api("PUT", "/project/api/tasks/" + taskId, { stage_id: stageId });
@@ -1934,7 +1962,7 @@
t.is_linked = true;
}
renderBoard();
- } catch (err) { alert("이동 실패: " + err.message); }
+ } catch (err) { showToast("이동 실패: " + err.message, "error"); }
});
}
@@ -2154,6 +2182,20 @@
else tasks.push(task);
}
+ // 같은 세션 안 카드 드래그 재정렬 — tasks 배열은 여러 세션이 뒤섞인
+ // 하나의 배열이라, 이 세션이 차지하던 자리(인덱스)만 그대로 두고 그
+ // 자리에 들어갈 값만 새 순서대로 바꿔치기한다(다른 세션 업무의 상대
+ // 위치는 건드리지 않는다).
+ function applyStageOrder(stageId, orderedIds) {
+ const orderMap = {};
+ orderedIds.forEach(function (id, i) { orderMap[id] = i; });
+ const idxs = [];
+ tasks.forEach(function (t, i) { if (t.stage_id === stageId) idxs.push(i); });
+ const sorted = idxs.map(function (i) { return tasks[i]; })
+ .sort(function (a, b) { return orderMap[a.id] - orderMap[b.id]; });
+ idxs.forEach(function (i, k) { tasks[i] = sorted[k]; });
+ }
+
function refreshActiveView() {
// 모든 뷰 + 좌측 트리 클라이언트 재렌더(새로고침 없음).
renderView(currentView);
diff --git a/docs/PROJECT_MODULE.md b/docs/PROJECT_MODULE.md
index d9503a8..47ab7d1 100644
--- a/docs/PROJECT_MODULE.md
+++ b/docs/PROJECT_MODULE.md
@@ -96,8 +96,10 @@
- **달력** — FullCalendar. 업무를 시작~마감 기간으로 표시. 클릭 편집.
- **타임라인** — vis-timeline(간트형). 기간 있는 업무만.
- **보드** — 세션별 칸반. 카드 드래그로 세션 이동(`PUT /api/tasks/{id}` `stage_id`,
- 멀티호밍된 업무는 `POST /api/tasks/{id}/links`). 세션 컬럼 자체도 드래그로
- 재정렬(§1-1).
+ 멀티호밍된 업무는 `POST /api/tasks/{id}/links`). 같은 세션 안에서 드래그하면
+ 이동이 아니라 카드 순서만 바꾼다(`PUT /api/projects/{id}/stages/{stage_id}/tasks/order`,
+ 세션 재정렬과 같은 패턴 — `tasks.sort_order`를 그 세션 소속만 0부터 재배정).
+ 세션 컬럼 자체도 드래그로 재정렬(§1-1).
- **리스트** — 표.
- 4개 뷰 모두 툴바의 "이 프로젝트"/"전체 프로젝트" 스코프 토글을 따른다(§1-1).
- 라이브러리는 현재 CDN 로드(스켈레톤). 추후 `app/static/vendor/` self-host 권장.