feat(project): 3단계 구조 프론트엔드 — 세션 관리·스코프·하위업무·멀티호밍·이미지

지난 커밋(07f4d0f)의 백엔드 위에 화면을 얹는다.

- 세션(기존 "단계") 문구 통일 + 프로젝트 스코프("이 프로젝트")일 때 보드
  컬럼에 관리 UI 추가: 이름변경/완료토글/삭제(만든 사람만)/"+ 세션 추가"/
  드래그 재정렬(카드 드래그와 같은 네이티브 HTML5 DnD, 기존
  PUT stages/order 재사용).
- 프로젝트 화면 툴바에 "이 프로젝트"/"전체 프로젝트" 스코프 토글 추가.
  기본은 아사나처럼 지금 프로젝트만(달력/타임라인/보드/리스트 4개 뷰
  전부), "전체"는 예전처럼 접근 가능한 프로젝트를 다 합쳐서 보여준다.
  서버가 tasks 각 행에 context_project_id(어느 프로젝트를 보다가 담겼는지)
  를 실어주고 클라이언트가 그걸로 순수 필터링(재조회 없음).
- 하위 업무 인라인 UI — 업무 모달 안에 체크박스·제목·담당자·마감일이 한
  줄인 하위업무 목록(변경 즉시 저장), "+ 하위 업무 추가". 카드에는
  "완료/전체" 진행 배지만 표시. update_task 에 completed 필드 추가(세션이
  없는 하위업무의 직접 완료 토글용).
- 멀티호밍 UI — 업무 모달에 "연결된 프로젝트" 칩 + "+ 프로젝트에 추가"(
  프로젝트/세션 선택). 보드에서 다른 프로젝트가 원 소속인 카드는 그
  프로젝트 배지를 달고 나타나며, 드래그로 세션을 옮기면
  POST /api/tasks/{id}/links 로 그 프로젝트 안에서의 배치만 바뀐다(원본
  불변). 링크 생성 권한을 다듬음 — 최초 연결은 원/대상 프로젝트 양쪽 권한이
  필요하지만, 이미 연결된 카드를 그 프로젝트 보드 안에서 옮기는 것은 대상
  프로젝트 권한만 있으면 되게 해 정상적인 드래그가 403 나지 않게 했다.
- 업무 설명란을 textarea → contenteditable 로 교체. 클립보드 이미지
  붙여넣기/파일 업로드 버튼으로 삽입(기존 task_attachments 재사용, 새
  GET /api/attachments/{id}/inline 추가 — 기존 /download 는
  Content-Disposition: attachment 라 <img> 렌더링에 못 씀). 새 업무 작성
  중(저장 전)에는 이미지 삽입 비활성화.
- .pj-modal-card 에 max-height/overflow-y 추가(내용이 늘어 화면보다
  길어지는 것 방지).

홈(index.html)·프로젝트(project.html) 두 업무 모달 모두 요소 id 를 공유하는
기존 구조를 그대로 따라, 새 로직은 공용 함수(initDescEditor/initSubtaskBlock/
initLinksBlock)로 한 번만 작성해 양쪽에서 재사용한다.

docs/PROJECT_MODULE.md 갱신 — 1-1절에 3단계 구조·권한 변경 전반을 정리.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 18:42:54 +09:00
parent 07f4d0fd5b
commit 3e4883854d
8 changed files with 846 additions and 70 deletions
+38 -2
View File
@@ -506,6 +506,12 @@ async def project_page(request: Request, project_id: int) -> HTMLResponse:
tasks if p["id"] == project_id
else st.list_tasks(project_id=p["id"], limit=2000)
)
# 이 업무가 '어느 프로젝트를 보다가' 담겼는지 표시 — 멀티호밍된 업무는
# 원 프로젝트(t.project_id)와 연결된 프로젝트(p.id) 양쪽에서 각각 한 번씩
# 나온다(각 프로젝트 화면에 보여야 하므로). "전체 프로젝트" 집계 화면은
# context_project_id == project_id(원 소속)인 것만 세어 중복을 없앤다.
for t in p_tasks:
t["context_project_id"] = p["id"]
all_tasks.extend(p_tasks)
stages_by_project[p["id"]] = st.list_stages(project_id=p["id"])
projects_meta.append({
@@ -989,14 +995,18 @@ async def api_add_task_link(
) -> JSONResponse:
"""업무를 다른 프로젝트(의 한 세션)에도 연결한다(아사나의 멀티호밍).
원래 소속(프로젝트)과 연결할 대상 프로젝트 양쪽에 대한 관리 권한이 필요하다.
최초 연결은 원래 소속(프로젝트)과 연결할 대상 프로젝트 양쪽에 대한 관리
권한이 필요하다(안 그러면 접근 권한 없는 프로젝트의 업무를 아무나 자기
프로젝트로 끌어와 제목 등을 노출시킬 수 있다). 이미 연결된 업무를 그
프로젝트 안에서 다른 세션으로 옮기는 것(보드 드래그)은 대상 프로젝트
권한만 있으면 된다 — 매번 원 프로젝트 권한까지 다시 확인하면 원
프로젝트 멤버가 아닌 사람은 이미 허용된 카드조차 못 옮기게 된다.
"""
user = _require_user(request)
st = _db_or_503(request)
task = st.get_task(task_id=task_id)
if task is None:
raise HTTPException(status_code=404, detail="업무를 찾을 수 없습니다.")
_require_manage(request, st, user, task["project_id"])
try:
target_project_id = int(payload.get("project_id"))
except (TypeError, ValueError):
@@ -1004,6 +1014,11 @@ async def api_add_task_link(
if target_project_id == task["project_id"]:
raise HTTPException(status_code=400, detail="이미 이 업무의 기본 프로젝트입니다.")
_require_manage(request, st, user, target_project_id)
already_linked = any(
link["project_id"] == target_project_id for link in st.list_task_links(task_id=task_id)
)
if not already_linked:
_require_manage(request, st, user, task["project_id"])
stage_id = payload.get("stage_id")
link = st.add_task_link(
task_id=task_id, project_id=target_project_id,
@@ -1184,6 +1199,27 @@ async def api_download_attachment(request: Request, attachment_id: int):
)
@router.get("/api/attachments/{attachment_id}/inline")
async def api_inline_attachment(request: Request, attachment_id: int):
"""설명란에 붙여넣은/업로드한 이미지를 `<img src=...>` 로 바로 보여주기 위한
서빙. `/download` 는 `filename=` 을 줘서 강제 다운로드(Content-Disposition:
attachment)가 걸리므로 이미지 삽입에는 못 쓴다 — filename 을 안 주면 브라우저가
그대로 렌더링한다."""
user = _require_user(request)
st = _db_or_503(request)
att = st.get_attachment(attachment_id=attachment_id)
if att is None:
raise HTTPException(status_code=404, detail="첨부를 찾을 수 없습니다.")
task = st.get_task(task_id=att["task_id"])
if task is None:
raise HTTPException(status_code=404, detail="업무를 찾을 수 없습니다.")
_require_manage(request, st, user, task["project_id"])
path = _attach_dir(request, att["task_id"]) / att["stored_name"]
if not path.exists():
raise HTTPException(status_code=404, detail="파일이 디스크에 없습니다.")
return FileResponse(str(path), media_type=att.get("content_type") or "application/octet-stream")
@router.delete("/api/attachments/{attachment_id}")
async def api_delete_attachment(request: Request, attachment_id: int) -> JSONResponse:
user = _require_user(request)