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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user