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 권장.