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
+9
View File
@@ -548,6 +548,15 @@ class ProjectStore:
newly_completed = True newly_completed = True
elif not done and prev.get("completed_at"): elif not done and prev.get("completed_at"):
sets.append("completed_at = NULL") sets.append("completed_at = NULL")
# 직접 완료 토글 — 하위 업무는 세션(칸반 컬럼)이 없어 위 자동 세팅이
# 적용되지 않는다. "stage_id" 를 같이 보냈으면 그쪽 판정이 우선한다.
elif "completed" in fields:
if fields["completed"] and not prev.get("completed_at"):
sets.append("completed_at = %s")
params.append(now_kst())
newly_completed = True
elif not fields["completed"] and prev.get("completed_at"):
sets.append("completed_at = NULL")
if not sets: if not sets:
return {**prev, "_newly_assigned": None, "_newly_completed": False} return {**prev, "_newly_assigned": None, "_newly_completed": False}
+38 -2
View File
@@ -506,6 +506,12 @@ async def project_page(request: Request, project_id: int) -> HTMLResponse:
tasks if p["id"] == project_id tasks if p["id"] == project_id
else st.list_tasks(project_id=p["id"], limit=2000) 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) all_tasks.extend(p_tasks)
stages_by_project[p["id"]] = st.list_stages(project_id=p["id"]) stages_by_project[p["id"]] = st.list_stages(project_id=p["id"])
projects_meta.append({ projects_meta.append({
@@ -989,14 +995,18 @@ async def api_add_task_link(
) -> JSONResponse: ) -> JSONResponse:
"""업무를 다른 프로젝트(의 한 세션)에도 연결한다(아사나의 멀티호밍). """업무를 다른 프로젝트(의 한 세션)에도 연결한다(아사나의 멀티호밍).
원래 소속(프로젝트)과 연결할 대상 프로젝트 양쪽에 대한 관리 권한이 필요하다. 최초 연결은 원래 소속(프로젝트)과 연결할 대상 프로젝트 양쪽에 대한 관리
권한이 필요하다(안 그러면 접근 권한 없는 프로젝트의 업무를 아무나 자기
프로젝트로 끌어와 제목 등을 노출시킬 수 있다). 이미 연결된 업무를 그
프로젝트 안에서 다른 세션으로 옮기는 것(보드 드래그)은 대상 프로젝트
권한만 있으면 된다 — 매번 원 프로젝트 권한까지 다시 확인하면 원
프로젝트 멤버가 아닌 사람은 이미 허용된 카드조차 못 옮기게 된다.
""" """
user = _require_user(request) user = _require_user(request)
st = _db_or_503(request) st = _db_or_503(request)
task = st.get_task(task_id=task_id) task = st.get_task(task_id=task_id)
if task is None: if task is None:
raise HTTPException(status_code=404, detail="업무를 찾을 수 없습니다.") raise HTTPException(status_code=404, detail="업무를 찾을 수 없습니다.")
_require_manage(request, st, user, task["project_id"])
try: try:
target_project_id = int(payload.get("project_id")) target_project_id = int(payload.get("project_id"))
except (TypeError, ValueError): except (TypeError, ValueError):
@@ -1004,6 +1014,11 @@ async def api_add_task_link(
if target_project_id == task["project_id"]: if target_project_id == task["project_id"]:
raise HTTPException(status_code=400, detail="이미 이 업무의 기본 프로젝트입니다.") raise HTTPException(status_code=400, detail="이미 이 업무의 기본 프로젝트입니다.")
_require_manage(request, st, user, target_project_id) _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") stage_id = payload.get("stage_id")
link = st.add_task_link( link = st.add_task_link(
task_id=task_id, project_id=target_project_id, 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}") @router.delete("/api/attachments/{attachment_id}")
async def api_delete_attachment(request: Request, attachment_id: int) -> JSONResponse: async def api_delete_attachment(request: Request, attachment_id: int) -> JSONResponse:
user = _require_user(request) user = _require_user(request)
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %} {% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260915i" /> <link rel="stylesheet" href="/static/project.css?v=20260916a" />
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" /> <link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
{% endblock %} {% endblock %}
@@ -45,5 +45,5 @@
{% endif %} {% endif %}
</div> </div>
<script src="/static/project.js?v=20260915g" defer></script> <script src="/static/project.js?v=20260916a" defer></script>
{% endblock %} {% endblock %}
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %} {% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260915i" /> <link rel="stylesheet" href="/static/project.css?v=20260916a" />
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" /> <link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
{% endblock %} {% endblock %}
@@ -244,12 +244,17 @@
<input type="hidden" id="pj-t-id" /> <input type="hidden" id="pj-t-id" />
<input type="hidden" id="pj-t-project-id" /> <input type="hidden" id="pj-t-project-id" />
<label>제목<input type="text" id="pj-t-title" placeholder="업무 제목" /></label> <label>제목<input type="text" id="pj-t-title" placeholder="업무 제목" /></label>
<label>설명<textarea id="pj-t-desc" rows="2"></textarea></label> <label>설명</label>
<div class="pj-desc-toolbar">
<label class="pj-upload-btn">이미지 추가<input type="file" id="pj-desc-img-input" accept="image/*" hidden /></label>
<span class="pj-muted-sm" id="pj-desc-hint"></span>
</div>
<div class="pj-desc-editor" id="pj-t-desc" contenteditable="true" data-placeholder="설명을 입력하세요… (이미지는 붙여넣기 또는 위 버튼으로 추가)"></div>
<div class="pj-form-row"> <div class="pj-form-row">
<label>담당자 <label>담당자
<select id="pj-t-assignee"><option value="">불러오는 중…</option></select> <select id="pj-t-assignee"><option value="">불러오는 중…</option></select>
</label> </label>
<label>단계 <label>세션
<select id="pj-t-stage"><option value="">불러오는 중…</option></select> <select id="pj-t-stage"><option value="">불러오는 중…</option></select>
</label> </label>
</div> </div>
@@ -271,6 +276,29 @@
<label>시작 시간<input type="time" id="pj-t-stime" disabled /></label> <label>시작 시간<input type="time" id="pj-t-stime" disabled /></label>
<label>마감 시간<input type="time" id="pj-t-dtime" disabled /></label> <label>마감 시간<input type="time" id="pj-t-dtime" disabled /></label>
</div> </div>
<div class="pj-task-extra" id="pj-subtask-block">
<div class="pj-extra-head">
<span class="material-symbols-outlined pj-gicon pj-gicon-sm">checklist</span> 하위 업무
</div>
<ul class="pj-subtask-list" id="pj-subtask-list"></ul>
<div class="pj-subtask-form">
<input type="text" id="pj-subtask-input" placeholder="하위 업무 추가…" />
<button type="button" class="pj-btn pj-btn-primary" id="pj-subtask-add">추가</button>
</div>
</div>
<div class="pj-task-extra" id="pj-links-block">
<div class="pj-extra-head">
<span class="material-symbols-outlined pj-gicon pj-gicon-sm">link</span> 연결된 프로젝트
<button type="button" class="pj-upload-btn" id="pj-link-add-btn">+ 프로젝트에 추가</button>
</div>
<div class="pj-link-chips" id="pj-link-chips"></div>
<div class="pj-link-form" id="pj-link-form" hidden>
<select id="pj-link-project"><option value="">프로젝트 선택…</option></select>
<select id="pj-link-stage"><option value="">세션 선택…</option></select>
<button type="button" class="pj-btn pj-btn-primary" id="pj-link-save">추가</button>
<button type="button" class="pj-btn" id="pj-link-cancel">취소</button>
</div>
</div>
<div class="pj-task-extra" id="pj-attach-block"> <div class="pj-task-extra" id="pj-attach-block">
<div class="pj-extra-head"> <div class="pj-extra-head">
<span class="material-symbols-outlined pj-gicon pj-gicon-sm">attach_file</span> 첨부파일 <span class="material-symbols-outlined pj-gicon pj-gicon-sm">attach_file</span> 첨부파일
@@ -321,5 +349,5 @@
<script> <script>
window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }}; window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }};
</script> </script>
<script src="/static/project.js?v=20260915g" defer></script> <script src="/static/project.js?v=20260916a" defer></script>
{% endblock %} {% endblock %}
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %} {% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260915i" /> <link rel="stylesheet" href="/static/project.css?v=20260916a" />
<!-- 구글 머티리얼 심볼(담당자 아이콘 등) — self-host --> <!-- 구글 머티리얼 심볼(담당자 아이콘 등) — self-host -->
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" /> <link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
<!-- 타임라인 vis-timeline — self-host --> <!-- 타임라인 vis-timeline — self-host -->
@@ -118,6 +118,12 @@
<button type="button" class="pj-tab" data-view="board">보드</button> <button type="button" class="pj-tab" data-view="board">보드</button>
<button type="button" class="pj-tab" data-view="list">리스트</button> <button type="button" class="pj-tab" data-view="list">리스트</button>
</div> </div>
<!-- 아사나처럼 기본은 "이 프로젝트"만 보여준다. 전체 프로젝트 보기는
예전처럼 접근 가능한 모든 프로젝트를 합쳐서 보여주는 모드. -->
<div class="pj-view-tabs" id="pj-scope-toggle">
<button type="button" class="pj-tab is-active" data-scope="project">이 프로젝트</button>
<button type="button" class="pj-tab" data-scope="all">전체 프로젝트</button>
</div>
</div> </div>
<!-- 달력 (휴가 모듈과 동일한 월간 그리드) --> <!-- 달력 (휴가 모듈과 동일한 월간 그리드) -->
@@ -166,7 +172,7 @@
<div class="pj-view" data-view="list" hidden> <div class="pj-view" data-view="list" hidden>
<table class="pj-table" id="pj-list"> <table class="pj-table" id="pj-list">
<thead> <thead>
<tr><th>업무</th><th>담당자</th><th>단계</th><th>우선순위</th><th>시작</th><th>마감</th></tr> <tr><th>업무</th><th>담당자</th><th>세션</th><th>우선순위</th><th>시작</th><th>마감</th></tr>
</thead> </thead>
<tbody></tbody> <tbody></tbody>
</table> </table>
@@ -181,7 +187,12 @@
<h3 id="pj-task-modal-title">새 업무</h3> <h3 id="pj-task-modal-title">새 업무</h3>
<input type="hidden" id="pj-t-id" /> <input type="hidden" id="pj-t-id" />
<label>제목<input type="text" id="pj-t-title" placeholder="업무 제목" /></label> <label>제목<input type="text" id="pj-t-title" placeholder="업무 제목" /></label>
<label>설명<textarea id="pj-t-desc" rows="2"></textarea></label> <label>설명</label>
<div class="pj-desc-toolbar">
<label class="pj-upload-btn">이미지 추가<input type="file" id="pj-desc-img-input" accept="image/*" hidden /></label>
<span class="pj-muted-sm" id="pj-desc-hint"></span>
</div>
<div class="pj-desc-editor" id="pj-t-desc" contenteditable="true" data-placeholder="설명을 입력하세요… (이미지는 붙여넣기 또는 위 버튼으로 추가)"></div>
<div class="pj-form-row"> <div class="pj-form-row">
<label>담당자 <label>담당자
<select id="pj-t-assignee"> <select id="pj-t-assignee">
@@ -193,7 +204,7 @@
{% endfor %} {% endfor %}
</select> </select>
</label> </label>
<label>단계 <label>세션
<select id="pj-t-stage"> <select id="pj-t-stage">
{% for s in stages %} {% for s in stages %}
<option value="{{ s.id }}">{{ s.name }}</option> <option value="{{ s.id }}">{{ s.name }}</option>
@@ -219,6 +230,34 @@
<label>시작 시간<input type="time" id="pj-t-stime" disabled /></label> <label>시작 시간<input type="time" id="pj-t-stime" disabled /></label>
<label>마감 시간<input type="time" id="pj-t-dtime" disabled /></label> <label>마감 시간<input type="time" id="pj-t-dtime" disabled /></label>
</div> </div>
<!-- 하위 업무 (기존 업무 편집 시에만) -->
<div class="pj-task-extra" id="pj-subtask-block" hidden>
<div class="pj-extra-head">
<span class="material-symbols-outlined pj-gicon pj-gicon-sm">checklist</span> 하위 업무
</div>
<ul class="pj-subtask-list" id="pj-subtask-list"></ul>
<div class="pj-subtask-form">
<input type="text" id="pj-subtask-input" placeholder="하위 업무 추가…" />
<button type="button" class="pj-btn pj-btn-primary" id="pj-subtask-add">추가</button>
</div>
</div>
<!-- 연결된 프로젝트 — 멀티호밍(아사나식). 기존 업무 편집 시에만 -->
<div class="pj-task-extra" id="pj-links-block" hidden>
<div class="pj-extra-head">
<span class="material-symbols-outlined pj-gicon pj-gicon-sm">link</span> 연결된 프로젝트
<button type="button" class="pj-upload-btn" id="pj-link-add-btn">+ 프로젝트에 추가</button>
</div>
<div class="pj-link-chips" id="pj-link-chips"></div>
<div class="pj-link-form" id="pj-link-form" hidden>
<select id="pj-link-project"><option value="">프로젝트 선택…</option></select>
<select id="pj-link-stage"><option value="">세션 선택…</option></select>
<button type="button" class="pj-btn pj-btn-primary" id="pj-link-save">추가</button>
<button type="button" class="pj-btn" id="pj-link-cancel">취소</button>
</div>
</div>
<!-- 첨부파일 (기존 업무 편집 시에만) --> <!-- 첨부파일 (기존 업무 편집 시에만) -->
<div class="pj-task-extra" id="pj-attach-block" hidden> <div class="pj-task-extra" id="pj-attach-block" hidden>
<div class="pj-extra-head"> <div class="pj-extra-head">
@@ -292,5 +331,5 @@
</script> </script>
<script src="/static/vendor/vis-timeline/vis-timeline-graph2d.min.js"></script> <script src="/static/vendor/vis-timeline/vis-timeline-graph2d.min.js"></script>
<script src="/static/project.js?v=20260915g" defer></script> <script src="/static/project.js?v=20260916a" defer></script>
{% endblock %} {% endblock %}
+55 -1
View File
@@ -278,7 +278,7 @@
/* ── 모달 ── */ /* ── 모달 ── */
.pj-modal { position: fixed; inset: 0; background: rgba(20,22,26,.46); display: flex; align-items: center; justify-content: center; z-index: 1000; } .pj-modal { position: fixed; inset: 0; background: rgba(20,22,26,.46); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.pj-modal[hidden] { display: none; } .pj-modal[hidden] { display: none; }
.pj-modal-card { background: #fff; border-radius: 16px; padding: 22px; width: min(480px, 92vw); box-shadow: 0 20px 60px rgba(0,0,0,.25); } .pj-modal-card { background: #fff; border-radius: 16px; padding: 22px; width: min(480px, 92vw); box-shadow: 0 20px 60px rgba(0,0,0,.25); max-height: 90vh; overflow-y: auto; }
.pj-modal-card h3 { margin: 0 0 16px; font-size: 17px; } .pj-modal-card h3 { margin: 0 0 16px; font-size: 17px; }
.pj-modal-hint { margin: -6px 0 14px; font-size: 12px; color: #6b7280; } .pj-modal-hint { margin: -6px 0 14px; font-size: 12px; color: #6b7280; }
.pj-modal-card label { display: block; font-size: 12.5px; font-weight: 600; color: #525860; margin-bottom: 12px; } .pj-modal-card label { display: block; font-size: 12.5px; font-weight: 600; color: #525860; margin-bottom: 12px; }
@@ -389,6 +389,60 @@
.pj-add-stage { width: 100%; padding: 10px; border: 1px dashed #c9cdd3; border-radius: 12px; background: #fff; color: #6b7280; font-size: 13px; font-weight: 600; cursor: pointer; } .pj-add-stage { width: 100%; padding: 10px; border: 1px dashed #c9cdd3; border-radius: 12px; background: #fff; color: #6b7280; font-size: 13px; font-weight: 600; cursor: pointer; }
.pj-add-stage:hover { border-color: #1e1f21; color: #1e1f21; } .pj-add-stage:hover { border-color: #1e1f21; color: #1e1f21; }
/* 세션(컬럼) 드래그 재정렬 */
.pj-col-head[draggable="true"] { cursor: grab; }
.pj-col.is-dragging { opacity: .45; }
.pj-col.pj-col-drop-target { outline: 2px dashed #aebfe6; outline-offset: -2px; border-radius: 12px; }
/* 업무 카드 — 하위 업무 진행 배지("2/5") */
.pj-card-subtasks { font-size: 11px; color: #6b7280; background: #eef0f2; border-radius: 999px; padding: 1px 7px; font-weight: 600; }
/* 프로젝트 스코프 토글(이 프로젝트 / 전체 프로젝트) — 뷰 탭과 같은 모양 재사용 */
#pj-scope-toggle { margin-left: auto; }
/* ── 업무 설명 — contenteditable(이미지 붙여넣기/업로드 삽입) ── */
.pj-desc-toolbar { display: flex; align-items: center; gap: 8px; margin: -6px 0 6px; }
.pj-desc-toolbar .pj-muted-sm { font-size: 11px; color: #9aa1a9; }
.pj-desc-editor {
min-height: 64px; max-height: 260px; overflow-y: auto; padding: 8px 10px;
border: 1px solid #d8dce2; border-radius: 8px; font-size: 13.5px; font-family: inherit;
line-height: 1.5; margin-bottom: 12px; background: #fff;
}
.pj-desc-editor:empty::before { content: attr(data-placeholder); color: #b7bdc5; }
.pj-desc-editor img { max-width: 100%; border-radius: 6px; margin: 4px 0; display: block; }
.pj-desc-editor[contenteditable="false"] { background: #f6f7f8; color: #9aa1a9; }
/* ── 하위 업무(인라인) ── */
.pj-subtask-list { list-style: none; margin: 0 0 8px; padding: 0; }
.pj-subtask-row { display: flex; align-items: center; gap: 6px; padding: 4px 2px; }
.pj-subtask-row.is-done .pj-subtask-title { text-decoration: line-through; color: #9aa1a9; }
.pj-subtask-row input[type="checkbox"] { width: auto; margin: 0; flex: 0 0 auto; }
.pj-subtask-title {
/* flex-basis 는 반드시 0(퍼센트 아님) — auto 로 두면 .pj-modal-card 의 공용
input[type=text] 규칙이 준 width:100% 로 넘어가 버려 한 줄 배치가 깨진다. */
flex: 1 1 0; width: auto !important; min-width: 0;
border: 1px solid transparent !important; background: transparent;
padding: 4px 6px !important; font-size: 13px !important; margin: 0 !important;
}
.pj-subtask-title:hover, .pj-subtask-title:focus { border-color: #d8dce2 !important; background: #fff; }
.pj-subtask-assignee {
flex: 0 0 auto; width: 110px !important; font-size: 12px !important; padding: 4px 6px !important; margin: 0 !important;
}
.pj-subtask-due { flex: 0 0 auto; width: 128px !important; font-size: 12px !important; padding: 4px 6px !important; margin: 0 !important; }
.pj-subtask-form { display: flex; gap: 6px; }
.pj-subtask-form input { flex: 1; padding: 7px 10px; border: 1px solid #d8dce2; border-radius: 8px; font-size: 13px; font-family: inherit; }
/* ── 연결된 프로젝트(멀티호밍) 칩 ── */
.pj-link-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.pj-link-chip {
display: inline-flex; align-items: center; gap: 5px; font-size: 12px; font-weight: 600;
padding: 3px 6px 3px 10px; border-radius: 999px; background: #eef0f2; color: #333;
}
.pj-link-chip .pj-link-del { border: none; background: transparent; cursor: pointer; color: #9aa1a9; font-size: 13px; line-height: 1; padding: 2px; }
.pj-link-chip .pj-link-del:hover { color: #c22b10; }
.pj-link-form { display: flex; gap: 6px; align-items: center; }
.pj-link-form select { flex: 1; padding: 7px 8px; border: 1px solid #d8dce2; border-radius: 8px; font-size: 13px; font-family: inherit; }
/* ── 알림센터 ── */ /* ── 알림센터 ── */
.pj-inbox { list-style: none; margin: 0; padding: 0; } .pj-inbox { list-style: none; margin: 0; padding: 0; }
.pj-inbox-item { display: flex; align-items: flex-start; gap: 12px; padding: 14px 12px; border: 1px solid #eef0f2; border-radius: 12px; margin-bottom: 8px; cursor: pointer; background: #fff; } .pj-inbox-item { display: flex; align-items: flex-start; gap: 12px; padding: 14px 12px; border: 1px solid #eef0f2; border-radius: 12px; margin-bottom: 8px; cursor: pointer; background: #fff; }
+587 -42
View File
@@ -209,6 +209,28 @@
const homeTaskModal = el("pj-modal-task"); const homeTaskModal = el("pj-modal-task");
if (homeTaskModal) { if (homeTaskModal) {
wireModalClose(homeTaskModal); wireModalClose(homeTaskModal);
const descEditor = initDescEditor();
const subtaskBlock = initSubtaskBlock();
const linksBlock = initLinksBlock();
async function loadTaskExtras(task) {
if (!subtaskBlock && !linksBlock) return;
try {
const res = await api("GET", "/project/api/tasks/" + task.id);
const detail = res.task;
if (subtaskBlock) {
subtaskBlock.setContext({ taskId: task.id, projectId: task.project_id, canDelete: canDelete });
subtaskBlock.render(detail.subtasks || []);
}
if (linksBlock) {
linksBlock.setContext({
taskId: task.id, projectId: task.project_id,
onChange: function () { loadTaskExtras(task); },
});
linksBlock.render(detail.links || []);
}
} catch (_) { /* 조용히 무시 — 본편집은 계속 가능해야 한다 */ }
}
async function fillAssignee(pid, currentEmail) { async function fillAssignee(pid, currentEmail) {
const sel = el("pj-t-assignee"); const sel = el("pj-t-assignee");
@@ -314,7 +336,7 @@
el("pj-t-id").value = task.id; el("pj-t-id").value = task.id;
el("pj-t-project-id").value = task.project_id; el("pj-t-project-id").value = task.project_id;
el("pj-t-title").value = task.title || ""; el("pj-t-title").value = task.title || "";
el("pj-t-desc").value = task.description || ""; if (descEditor) { descEditor.setTaskId(task.id); descEditor.setHtml(task.description || ""); }
el("pj-t-priority").value = task.priority || "normal"; el("pj-t-priority").value = task.priority || "normal";
el("pj-t-start").value = task.start_date || ""; el("pj-t-start").value = task.start_date || "";
el("pj-t-due").value = task.due_date || ""; el("pj-t-due").value = task.due_date || "";
@@ -329,6 +351,8 @@
el("pj-t-open-project").hidden = false; el("pj-t-open-project").hidden = false;
el("pj-attach-block").hidden = false; el("pj-attach-block").hidden = false;
el("pj-comment-block").hidden = false; el("pj-comment-block").hidden = false;
if (subtaskBlock) el("pj-subtask-block").hidden = false;
if (linksBlock) el("pj-links-block").hidden = false;
openModal(homeTaskModal); openModal(homeTaskModal);
await Promise.all([ await Promise.all([
fillAssignee(task.project_id, task.assignee_email || ""), fillAssignee(task.project_id, task.assignee_email || ""),
@@ -336,6 +360,7 @@
]); ]);
loadHomeAttachments(task.id); loadHomeAttachments(task.id);
loadHomeComments(task.id); loadHomeComments(task.id);
loadTaskExtras(task);
} }
// 카드 이름 옆 "업무 추가" 버튼 — 같은 모달을 새 업무 작성용으로 초기화해 연다. // 카드 이름 옆 "업무 추가" 버튼 — 같은 모달을 새 업무 작성용으로 초기화해 연다.
@@ -344,7 +369,7 @@
el("pj-t-id").value = ""; el("pj-t-id").value = "";
el("pj-t-project-id").value = projectId; el("pj-t-project-id").value = projectId;
el("pj-t-title").value = ""; el("pj-t-title").value = "";
el("pj-t-desc").value = ""; if (descEditor) { descEditor.setTaskId(null); descEditor.setHtml(""); }
el("pj-t-priority").value = "normal"; el("pj-t-priority").value = "normal";
el("pj-t-start").value = ""; el("pj-t-start").value = "";
el("pj-t-due").value = ""; el("pj-t-due").value = "";
@@ -355,6 +380,8 @@
el("pj-t-open-project").hidden = true; el("pj-t-open-project").hidden = true;
el("pj-attach-block").hidden = true; el("pj-attach-block").hidden = true;
el("pj-comment-block").hidden = true; el("pj-comment-block").hidden = true;
if (subtaskBlock) el("pj-subtask-block").hidden = true;
if (linksBlock) el("pj-links-block").hidden = true;
openModal(homeTaskModal); openModal(homeTaskModal);
await Promise.all([fillAssignee(projectId, ""), fillStage(projectId, null)]); await Promise.all([fillAssignee(projectId, ""), fillStage(projectId, null)]);
} }
@@ -382,7 +409,7 @@
const useTime = el("pj-t-usetime").checked; const useTime = el("pj-t-usetime").checked;
const payload = { const payload = {
title: el("pj-t-title").value.trim(), title: el("pj-t-title").value.trim(),
description: el("pj-t-desc").value.trim(), description: descEditor ? descEditor.getHtml() : "",
assignee_email: assigneeSel.value, assignee_email: assigneeSel.value,
assignee_name: assigneeName, assignee_name: assigneeName,
stage_id: parseInt(el("pj-t-stage").value, 10) || null, stage_id: parseInt(el("pj-t-stage").value, 10) || null,
@@ -580,6 +607,19 @@
let timeline = null; let timeline = null;
let currentView = "calendar"; let currentView = "calendar";
// ── 프로젝트 스코프 — 아사나처럼 기본은 "이 프로젝트만", "전체 프로젝트"로
// 전환하면 예전처럼 접근 가능한 모든 프로젝트를 합쳐서 보여준다.
// tasks 배열의 각 행은 서버가 어느 프로젝트를 보다가 담았는지를
// context_project_id 로 표시한다(멀티호밍된 업무는 원 소속과 연결된
// 프로젝트 양쪽에서 각각 한 번씩 나온다 — 그래야 각 화면에 다 보인다).
// "전체" 모드는 원 소속(context_project_id === project_id)만 세어 중복을 없앤다.
let viewScope = "project";
function visibleTasks() {
return viewScope === "project"
? tasks.filter(function (t) { return t.context_project_id === projectId; })
: tasks.filter(function (t) { return t.context_project_id === t.project_id; });
}
// ── 뷰 토글 ── // ── 뷰 토글 ──
const tabs = el("pj-view-tabs"); const tabs = el("pj-view-tabs");
tabs.addEventListener("click", function (e) { tabs.addEventListener("click", function (e) {
@@ -596,6 +636,20 @@
renderView(view); renderView(view);
}); });
// ── 프로젝트 스코프 토글 ──
const scopeToggle = el("pj-scope-toggle");
if (scopeToggle) {
scopeToggle.addEventListener("click", function (e) {
const btn = e.target.closest("[data-scope]");
if (!btn) return;
viewScope = btn.dataset.scope;
scopeToggle.querySelectorAll("[data-scope]").forEach(function (b) {
b.classList.toggle("is-active", b === btn);
});
renderView(currentView);
});
}
function renderView(view) { function renderView(view) {
if (view === "calendar") renderCalendar(); if (view === "calendar") renderCalendar();
else if (view === "timeline") renderTimeline(); else if (view === "timeline") renderTimeline();
@@ -640,7 +694,7 @@
// (주마다 따로 배치하면 첫주/다음주의 순서가 바뀌는 문제 방지) // (주마다 따로 배치하면 첫주/다음주의 순서가 바뀌는 문제 방지)
function computeLanes() { function computeLanes() {
const items = []; const items = [];
tasks.forEach(function (t) { visibleTasks().forEach(function (t) {
const sp = taskSpan(t); const sp = taskSpan(t);
if (sp) items.push({ id: t.id, s: sp[0], e: sp[1] }); if (sp) items.push({ id: t.id, s: sp[0], e: sp[1] });
}); });
@@ -675,7 +729,7 @@
// 이 주에 걸치는 업무 → 막대 세그먼트(레인은 전역 배정값 사용) // 이 주에 걸치는 업무 → 막대 세그먼트(레인은 전역 배정값 사용)
const bars = []; const bars = [];
tasks.forEach(function (t) { visibleTasks().forEach(function (t) {
const sp = taskSpan(t); const sp = taskSpan(t);
if (!sp) return; if (!sp) return;
if (sp[1] < weekStart || sp[0] > weekEnd) return; if (sp[1] < weekStart || sp[0] > weekEnd) return;
@@ -808,7 +862,7 @@
} }
function openDayPopover(dateStr, anchorEl) { function openDayPopover(dateStr, anchorEl) {
closeDayPop(); closeDayPop();
const list = tasks.filter(function (t) { const list = visibleTasks().filter(function (t) {
const sp = taskSpan(t); return sp && sp[0] <= dateStr && dateStr <= sp[1]; const sp = taskSpan(t); return sp && sp[0] <= dateStr && dateStr <= sp[1];
}); });
dayPop = document.createElement("div"); dayPop = document.createElement("div");
@@ -847,7 +901,7 @@
function renderTimeline() { function renderTimeline() {
const node = el("pj-timeline"); const node = el("pj-timeline");
if (timeline) { timeline.destroy(); timeline = null; } if (timeline) { timeline.destroy(); timeline = null; }
const dated = tasks.filter(function (t) { return t.start_date || t.due_date; }); const dated = visibleTasks().filter(function (t) { return t.start_date || t.due_date; });
if (!dated.length) { node.innerHTML = '<div class="pj-empty">기간이 설정된 업무가 없습니다.</div>'; return; } if (!dated.length) { node.innerHTML = '<div class="pj-empty">기간이 설정된 업무가 없습니다.</div>'; return; }
// 프로젝트 메타(시작/마감/색) 조회용 // 프로젝트 메타(시작/마감/색) 조회용
@@ -961,13 +1015,23 @@
} catch (_) {} } catch (_) {}
} }
// ── 보드 (칸반) — 전체 프로젝트를 단계 '이름' 기준 컬럼으로 묶음 ── // ── 보드 (칸반 = 세션) ──
// scope==="project" → 이 프로젝트의 실제 세션(id 기준) + 세션 관리(이름변경·
// 완료토글·삭제·추가·드래그 재정렬).
// scope==="all" → 여러 프로젝트를 세션 '이름' 기준으로 합쳐 보여준다(기존
// 동작 그대로 — 세션 하나를 특정할 수 없어 관리 컨트롤은 없다).
function renderBoard() { function renderBoard() {
const board = el("pj-board"); const board = el("pj-board");
board.innerHTML = ""; board.innerHTML = "";
// 컬럼 이름 = 서버가 정렬해준 전체 단계 이름 + 단계 미지정 업무용 '미지정' const vis = visibleTasks();
if (viewScope === "project") renderBoardByStage(board, vis);
else renderBoardByName(board, vis);
}
function renderBoardByName(board, vis) {
// 컬럼 이름 = 서버가 정렬해준 전체 세션 이름 + 세션 미지정 업무용 '미지정'
let names = boardStages.slice(); let names = boardStages.slice();
const hasUnstaged = tasks.some(function (t) { return !t.stage_name; }); const hasUnstaged = vis.some(function (t) { return !t.stage_name; });
if (hasUnstaged) names.push("미지정"); if (hasUnstaged) names.push("미지정");
names.forEach(function (name) { names.forEach(function (name) {
const col = document.createElement("div"); const col = document.createElement("div");
@@ -978,7 +1042,7 @@
'<span class="pj-col-count"></span></div>' + '<span class="pj-col-count"></span></div>' +
'<div class="pj-col-body"></div>'; '<div class="pj-col-body"></div>';
const body = col.querySelector(".pj-col-body"); const body = col.querySelector(".pj-col-body");
const colTasks = tasks.filter(function (t) { const colTasks = vis.filter(function (t) {
return name === "미지정" ? !t.stage_name : (t.stage_name || "") === name; return name === "미지정" ? !t.stage_name : (t.stage_name || "") === name;
}); });
col.querySelector(".pj-col-count").textContent = colTasks.length; col.querySelector(".pj-col-count").textContent = colTasks.length;
@@ -988,13 +1052,222 @@
}); });
} }
// 보드 드롭(전체보기) — 컬럼 이름(세션명)으로 옮긴다. 업무가 속한 '그
// 프로젝트'의 동일 이름 세션 id 를 찾아 적용(프로젝트마다 세션 id 가 다르다).
function enableDropByName(body, name) {
body.addEventListener("dragover", function (e) {
if (!e.dataTransfer.types.includes("text/plain")) return;
e.preventDefault(); body.classList.add("is-over");
});
body.addEventListener("dragleave", function () { body.classList.remove("is-over"); });
body.addEventListener("drop", async function (e) {
if (!e.dataTransfer.types.includes("text/plain")) return;
e.preventDefault();
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_name || "") === name) return;
const plist = stagesByProject[t.project_id] || [];
const target = plist.find(function (s) { return s.name === name; });
if (!target) { alert("'" + (t.project_name || "이 프로젝트") + "' 에는 '" + name + "' 세션이 없습니다."); return; }
try {
const res = await api("PUT", "/project/api/tasks/" + taskId, { stage_id: target.id });
mergeTask(res.task);
renderBoard();
} catch (err) { alert("이동 실패: " + err.message); }
});
}
// ── 보드(프로젝트 스코프) — 세션은 이 프로젝트의 실제 행(id)이라 관리 UI를
// 붙일 수 있다: 이름변경·완료토글·삭제(만든 사람만)·추가·드래그 재정렬.
function renderBoardByStage(board, vis) {
const list = (stagesByProject[projectId] || []).slice()
.sort(function (a, b) { return a.sort_order - b.sort_order; });
list.forEach(function (s) {
const col = document.createElement("div");
col.className = "pj-col";
col.dataset.stageId = s.id;
col.innerHTML =
'<div class="pj-col-head"' + (canManage ? ' draggable="true"' : "") + '>' +
'<span class="pj-col-name">' + escapeHtml(s.name) + "</span>" +
(s.is_done_stage ? '<span class="pj-chip pj-chip-sm">완료</span>' : "") +
'<span class="pj-col-count"></span>' +
(canManage
? '<span class="pj-col-ctrls">' +
'<button type="button" class="pj-icon-btn pj-col-rename" title="이름 변경">✎</button>' +
'<button type="button" class="pj-icon-btn pj-col-toggledone" title="완료 세션 지정/해제">' +
(s.is_done_stage ? "☑" : "☐") + "</button>" +
'<button type="button" class="pj-icon-btn pj-col-del" title="세션 삭제">×</button>' +
"</span>"
: "") +
"</div><div class=\"pj-col-body\"></div>";
const body = col.querySelector(".pj-col-body");
const colTasks = vis.filter(function (t) { return t.stage_id === s.id; });
col.querySelector(".pj-col-count").textContent = colTasks.length;
colTasks.forEach(function (t) { body.appendChild(taskCard(t)); });
if (canManage) {
enableDropByStageId(body, s.id);
wireColumnControls(col, s);
wireColumnDrag(col, s);
}
board.appendChild(col);
});
const hasUnstaged = vis.some(function (t) { return !t.stage_id; });
if (hasUnstaged) {
const col = document.createElement("div");
col.className = "pj-col";
col.dataset.stageName = "미지정";
col.innerHTML =
'<div class="pj-col-head"><span class="pj-col-name">미지정</span>' +
'<span class="pj-col-count"></span></div><div class="pj-col-body"></div>';
const body = col.querySelector(".pj-col-body");
const colTasks = vis.filter(function (t) { return !t.stage_id; });
col.querySelector(".pj-col-count").textContent = colTasks.length;
colTasks.forEach(function (t) { body.appendChild(taskCard(t)); });
board.appendChild(col);
}
if (canManage) {
const addCol = document.createElement("div");
addCol.className = "pj-col pj-col-add";
addCol.innerHTML = '<button type="button" class="pj-add-stage">+ 세션 추가</button>';
addCol.querySelector(".pj-add-stage").addEventListener("click", async function () {
const name = prompt("새 세션 이름");
if (!name || !name.trim()) return;
try {
await api("POST", "/project/api/projects/" + projectId + "/stages", { name: name.trim() });
await reloadStagesForCurrentProject();
renderBoard();
} catch (e) { alert("추가 실패: " + e.message); }
});
board.appendChild(addCol);
}
}
async function reloadStagesForCurrentProject() {
const res = await api("GET", "/project/api/projects/" + projectId + "/stages");
stages = res.stages || [];
stagesByProject[projectId] = stages;
}
function wireColumnControls(col, s) {
const renameBtn = col.querySelector(".pj-col-rename");
if (renameBtn) renameBtn.addEventListener("click", async function (e) {
e.stopPropagation();
const name = prompt("세션 이름", s.name);
if (!name || !name.trim() || name.trim() === s.name) return;
try {
await api("PUT", "/project/api/projects/" + projectId + "/stages/" + s.id, { name: name.trim() });
await reloadStagesForCurrentProject();
renderBoard();
} catch (err) { alert("이름 변경 실패: " + err.message); }
});
const doneBtn = col.querySelector(".pj-col-toggledone");
if (doneBtn) doneBtn.addEventListener("click", async function (e) {
e.stopPropagation();
try {
await api("PUT", "/project/api/projects/" + projectId + "/stages/" + s.id,
{ is_done_stage: !s.is_done_stage });
await reloadStagesForCurrentProject();
renderBoard();
} catch (err) { alert("변경 실패: " + err.message); }
});
const delBtn = col.querySelector(".pj-col-del");
if (delBtn) delBtn.addEventListener("click", async function (e) {
e.stopPropagation();
if (!canDelete(s.created_by)) { alert(DELETE_DENIED); return; }
if (!confirm("'" + s.name + "' 세션을 삭제할까요? 이 세션의 업무는 '미지정'이 됩니다.")) return;
try {
await api("DELETE", "/project/api/projects/" + projectId + "/stages/" + s.id);
await reloadStagesForCurrentProject();
renderBoard();
} catch (err) { alert("삭제 실패: " + err.message); }
});
}
// 세션(컬럼) 드래그 재정렬 — 카드 드래그와 같은 네이티브 HTML5 DnD 방식이되,
// dataTransfer 타입을 달리 써서(application/x-pj-stage) 카드 드롭과 안 섞인다.
function wireColumnDrag(col, s) {
const head = col.querySelector(".pj-col-head");
if (!head || head.getAttribute("draggable") !== "true") return;
head.addEventListener("dragstart", function (e) {
e.dataTransfer.setData("application/x-pj-stage", String(s.id));
e.dataTransfer.effectAllowed = "move";
col.classList.add("is-dragging");
});
head.addEventListener("dragend", function () { col.classList.remove("is-dragging"); });
col.addEventListener("dragover", function (e) {
if (!e.dataTransfer.types.includes("application/x-pj-stage")) return;
e.preventDefault();
col.classList.add("pj-col-drop-target");
});
col.addEventListener("dragleave", function () { col.classList.remove("pj-col-drop-target"); });
col.addEventListener("drop", async function (e) {
if (!e.dataTransfer.types.includes("application/x-pj-stage")) return;
e.preventDefault();
col.classList.remove("pj-col-drop-target");
const draggedId = parseInt(e.dataTransfer.getData("application/x-pj-stage"), 10);
if (!draggedId || draggedId === s.id) return;
const list = (stagesByProject[projectId] || []).slice()
.sort(function (a, b) { return a.sort_order - b.sort_order; });
const ids = list.map(function (x) { return x.id; });
const from = ids.indexOf(draggedId), to = ids.indexOf(s.id);
if (from < 0 || to < 0) return;
ids.splice(from, 1);
ids.splice(to, 0, draggedId);
try {
const res = await api("PUT", "/project/api/projects/" + projectId + "/stages/order",
{ ordered_ids: ids });
stages = res.stages || [];
stagesByProject[projectId] = stages;
renderBoard();
} catch (err) { alert("순서 변경 실패: " + err.message); }
});
}
// 보드 드롭(프로젝트 스코프) — 세션 id 로 바로 옮긴다. 이 프로젝트가 원
// 소속이 아닌(멀티호밍된) 업무는 원본을 안 건드리고 "이 프로젝트 안에서의
// 세션"만 연결(POST .../links)로 바꾼다.
function enableDropByStageId(body, stageId) {
body.addEventListener("dragover", function (e) {
if (!e.dataTransfer.types.includes("text/plain")) return;
e.preventDefault(); body.classList.add("is-over");
});
body.addEventListener("dragleave", function () { body.classList.remove("is-over"); });
body.addEventListener("drop", async function (e) {
if (!e.dataTransfer.types.includes("text/plain")) return;
e.preventDefault();
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;
try {
if (t.project_id === projectId) {
const res = await api("PUT", "/project/api/tasks/" + taskId, { stage_id: stageId });
mergeTask(res.task);
} else {
await api("POST", "/project/api/tasks/" + taskId + "/links",
{ project_id: projectId, stage_id: stageId });
t.stage_id = stageId;
t.is_linked = true;
}
renderBoard();
} catch (err) { alert("이동 실패: " + err.message); }
});
}
function taskCard(t) { function taskCard(t) {
const card = document.createElement("div"); const card = document.createElement("div");
card.className = "pj-card" + (t.completed_at ? " is-done" : ""); card.className = "pj-card" + (t.completed_at ? " is-done" : "");
card.draggable = canManage; card.draggable = canManage;
card.dataset.taskId = t.id; card.dataset.taskId = t.id;
// 전체보기는 항상 원 프로젝트 배지를 보여준다. 프로젝트 스코프에서는
// "이 프로젝트가 원 소속"인 카드는 굳이 표시하지 않고, 멀티호밍으로
// 들어온(다른 프로젝트가 원 소속인) 카드만 아사나처럼 배지를 붙인다.
const showProjTag = viewScope === "all" || t.project_id !== projectId;
card.innerHTML = card.innerHTML =
(t.project_name (showProjTag && t.project_name
? '<div class="pj-card-proj" style="color:' + (t.project_color || "#4573d2") + '">● ' + escapeHtml(t.project_name) + "</div>" ? '<div class="pj-card-proj" style="color:' + (t.project_color || "#4573d2") + '">● ' + escapeHtml(t.project_name) + "</div>"
: "") + : "") +
'<div class="pj-card-title">' + escapeHtml(t.title) + "</div>" + '<div class="pj-card-title">' + escapeHtml(t.title) + "</div>" +
@@ -1006,6 +1279,9 @@
? '<span class="pj-assignee">' + avatarHtml(t.assignee_email, "pj-gicon-sm") + ? '<span class="pj-assignee">' + avatarHtml(t.assignee_email, "pj-gicon-sm") +
escapeHtml(idOf(t.assignee_email)) + "</span>" escapeHtml(idOf(t.assignee_email)) + "</span>"
: "") + : "") +
(t.subtask_total
? '<span class="pj-card-subtasks" title="하위 업무">' + (t.subtask_done || 0) + "/" + t.subtask_total + "</span>"
: "") +
(t.due_date ? '<span class="pj-card-due">' + t.due_date + "</span>" : "") + (t.due_date ? '<span class="pj-card-due">' + t.due_date + "</span>" : "") +
(t.comment_count ? '<span class="pj-card-cmt">' + cmtBadge(t) + "</span>" : "") + (t.comment_count ? '<span class="pj-card-cmt">' + cmtBadge(t) + "</span>" : "") +
"</div>"; "</div>";
@@ -1020,36 +1296,12 @@
return card; return card;
} }
// 보드 드롭 — 컬럼 이름(단계명)으로 옮긴다. 업무가 속한 '그 프로젝트'의
// 동일 이름 단계 id 를 찾아 적용(프로젝트마다 단계 id 가 다르므로).
function enableDropByName(body, name) {
body.addEventListener("dragover", function (e) { e.preventDefault(); body.classList.add("is-over"); });
body.addEventListener("dragleave", function () { body.classList.remove("is-over"); });
body.addEventListener("drop", async function (e) {
e.preventDefault();
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_name || "") === name) return;
const plist = stagesByProject[t.project_id] || [];
const target = plist.find(function (s) { return s.name === name; });
if (!target) { alert("'" + (t.project_name || "이 프로젝트") + "' 에는 '" + name + "' 단계가 없습니다."); return; }
try {
const res = await api("PUT", "/project/api/tasks/" + taskId, { stage_id: target.id });
mergeTask(res.task);
const m = tasks.find(function (x) { return x.id === taskId; });
if (m) { m.stage_id = target.id; m.stage_name = name; }
renderBoard();
} catch (err) { alert("이동 실패: " + err.message); }
});
}
// ── 리스트 (정렬·컬럼 너비 조절) ── // ── 리스트 (정렬·컬럼 너비 조절) ──
const LIST_COLS = [ const LIST_COLS = [
{ key: "project", label: "프로젝트", text: function (t) { return t.project_name || "-"; }, sort: function (t) { return (t.project_name || "").toLowerCase(); } }, { key: "project", label: "프로젝트", text: function (t) { return t.project_name || "-"; }, sort: function (t) { return (t.project_name || "").toLowerCase(); } },
{ key: "title", label: "업무", text: function (t) { return t.title; }, sort: function (t) { return (t.title || "").toLowerCase(); } }, { key: "title", label: "업무", text: function (t) { return t.title; }, sort: function (t) { return (t.title || "").toLowerCase(); } },
{ key: "assignee", label: "담당자", text: function (t) { return t.assignee_email ? idOf(t.assignee_email) : "-"; }, sort: function (t) { return idOf(t.assignee_email || ""); } }, { key: "assignee", label: "담당자", text: function (t) { return t.assignee_email ? idOf(t.assignee_email) : "-"; }, sort: function (t) { return idOf(t.assignee_email || ""); } },
{ key: "stage", label: "단계", text: function (t) { return t.stage_name || "-"; }, sort: function (t) { return t.stage_name || ""; } }, { key: "stage", label: "세션", text: function (t) { return t.stage_name || "-"; }, sort: function (t) { return t.stage_name || ""; } },
{ key: "priority", label: "우선순위", text: function (t) { return priorityLabels[t.priority] || t.priority || "-"; }, sort: function (t) { return ({ low: 0, normal: 1, high: 2 })[t.priority]; } }, { key: "priority", label: "우선순위", text: function (t) { return priorityLabels[t.priority] || t.priority || "-"; }, sort: function (t) { return ({ low: 0, normal: 1, high: 2 })[t.priority]; } },
{ key: "start", label: "시작", text: function (t) { return t.start_date || "-"; }, sort: function (t) { return t.start_date || ""; } }, { key: "start", label: "시작", text: function (t) { return t.start_date || "-"; }, sort: function (t) { return t.start_date || ""; } },
{ key: "due", label: "마감", text: function (t) { return t.due_date || "-"; }, sort: function (t) { return t.due_date || ""; } }, { key: "due", label: "마감", text: function (t) { return t.due_date || "-"; }, sort: function (t) { return t.due_date || ""; } },
@@ -1098,7 +1350,7 @@
thead.innerHTML = ""; thead.innerHTML = "";
thead.appendChild(trh); thead.appendChild(trh);
let rows = tasks.slice(); let rows = visibleTasks();
if (listSort.key) { if (listSort.key) {
const col = LIST_COLS.find(function (c) { return c.key === listSort.key; }); const col = LIST_COLS.find(function (c) { return c.key === listSort.key; });
rows.sort(function (a, b) { rows.sort(function (a, b) {
@@ -1147,6 +1399,28 @@
const taskModal = el("pj-modal-task"); const taskModal = el("pj-modal-task");
wireModalClose(taskModal); wireModalClose(taskModal);
let currentTaskId = null; let currentTaskId = null;
const descEditor = initDescEditor();
const subtaskBlock = initSubtaskBlock();
const linksBlock = initLinksBlock();
async function loadTaskExtras(task) {
if (!task || (!subtaskBlock && !linksBlock)) return;
try {
const res = await api("GET", "/project/api/tasks/" + task.id);
const detail = res.task;
if (subtaskBlock) {
subtaskBlock.setContext({ taskId: task.id, projectId: task.project_id, canDelete: canDelete });
subtaskBlock.render(detail.subtasks || []);
}
if (linksBlock) {
linksBlock.setContext({
taskId: task.id, projectId: task.project_id,
onChange: function () { loadTaskExtras(task); },
});
linksBlock.render(detail.links || []);
}
} catch (_) { /* 조용히 무시 — 본편집은 계속 가능해야 한다 */ }
}
// 단계 select 를 해당 업무가 속한 프로젝트의 단계로 채운다(전체 뷰에서 타 프로젝트 업무 편집 대비). // 단계 select 를 해당 업무가 속한 프로젝트의 단계로 채운다(전체 뷰에서 타 프로젝트 업무 편집 대비).
function fillStageSelect(projId) { function fillStageSelect(projId) {
@@ -1177,7 +1451,7 @@
el("pj-task-modal-title").textContent = task ? "업무 편집" : "새 업무"; el("pj-task-modal-title").textContent = task ? "업무 편집" : "새 업무";
el("pj-t-id").value = task ? task.id : ""; el("pj-t-id").value = task ? task.id : "";
el("pj-t-title").value = task ? task.title : ""; el("pj-t-title").value = task ? task.title : "";
el("pj-t-desc").value = task ? (task.description || "") : ""; if (descEditor) { descEditor.setTaskId(task ? task.id : null); descEditor.setHtml(task ? (task.description || "") : ""); }
fillAssigneeSelect(task ? task.project_id : projectId, task ? (task.assignee_email || "") : ""); fillAssigneeSelect(task ? task.project_id : projectId, task ? (task.assignee_email || "") : "");
fillStageSelect(task ? task.project_id : projectId); fillStageSelect(task ? task.project_id : projectId);
el("pj-t-stage").value = task && task.stage_id ? task.stage_id : (stages[0] ? stages[0].id : ""); el("pj-t-stage").value = task && task.stage_id ? task.stage_id : (stages[0] ? stages[0].id : "");
@@ -1192,11 +1466,13 @@
el("pj-t-stime").disabled = !useTime; el("pj-t-stime").disabled = !useTime;
el("pj-t-dtime").disabled = !useTime; el("pj-t-dtime").disabled = !useTime;
el("pj-delete-task").hidden = !task; el("pj-delete-task").hidden = !task;
// 댓글·첨부 기존 업무 편집 시에만 // 댓글·첨부·하위업무·연결은 기존 업무 편집 시에만
currentTaskId = task ? task.id : null; currentTaskId = task ? task.id : null;
el("pj-attach-block").hidden = !task; el("pj-attach-block").hidden = !task;
el("pj-comment-block").hidden = !task; el("pj-comment-block").hidden = !task;
if (task) { loadAttachments(task.id); loadComments(task.id); } if (subtaskBlock) el("pj-subtask-block").hidden = !task;
if (linksBlock) el("pj-links-block").hidden = !task;
if (task) { loadAttachments(task.id); loadComments(task.id); loadTaskExtras(task); }
openModal(taskModal); openModal(taskModal);
} }
@@ -1353,7 +1629,7 @@
const useTime = el("pj-t-usetime").checked; const useTime = el("pj-t-usetime").checked;
const payload = { const payload = {
title: el("pj-t-title").value.trim(), title: el("pj-t-title").value.trim(),
description: el("pj-t-desc").value.trim(), description: descEditor ? descEditor.getHtml() : "",
assignee_email: assigneeSel.value, assignee_email: assigneeSel.value,
assignee_name: assigneeName, assignee_name: assigneeName,
stage_id: parseInt(el("pj-t-stage").value, 10) || null, stage_id: parseInt(el("pj-t-stage").value, 10) || null,
@@ -1569,6 +1845,275 @@
} }
} }
// ════════════════════════════════════════════════════════════
// 업무 모달 공용 확장 — 하위 업무 · 연결된 프로젝트(멀티호밍) · 설명 이미지 편집기
// 홈(index.html)과 프로젝트(project.html) 페이지가 업무 모달의 요소 id 를
// 그대로 공유하므로(각자 모달을 따로 갖지만 id는 같다) 여기 한 번만 만들어
// 양쪽 initHome()/initProject() 에서 그대로 쓴다.
// ════════════════════════════════════════════════════════════
// ── 설명(contenteditable) — 붙여넣기/업로드로 이미지를 끼워 넣는다 ──
function initDescEditor() {
const box = el("pj-t-desc");
if (!box) return null;
const imgInput = el("pj-desc-img-input");
const hint = el("pj-desc-hint");
let taskId = null;
function setTaskId(id) {
taskId = id;
const enabled = !!id;
if (imgInput) {
imgInput.disabled = !enabled;
if (imgInput.parentElement) imgInput.parentElement.style.opacity = enabled ? "1" : ".5";
}
if (hint) hint.textContent = enabled ? "" : "저장 후 이미지를 추가할 수 있습니다.";
}
function insertHtmlAtCursor(html) {
box.focus();
let inserted = false;
try { inserted = document.execCommand("insertHTML", false, html); } catch (_) { inserted = false; }
if (!inserted) {
const sel = window.getSelection();
if (sel && sel.rangeCount && box.contains(sel.anchorNode)) {
const range = sel.getRangeAt(0);
range.deleteContents();
const frag = range.createContextualFragment(html);
range.insertNode(frag);
range.collapse(false);
} else {
box.innerHTML += html;
}
}
}
async function insertImageFile(file) {
if (!taskId) { alert("먼저 저장한 뒤 이미지를 추가할 수 있습니다."); return; }
const fd = new FormData();
fd.append("file", file);
try {
const res = await fetch("/project/api/tasks/" + taskId + "/attachments", { method: "POST", body: fd });
if (!res.ok) { let m = res.statusText; try { m = (await res.json()).detail; } catch (_) {} throw new Error(m); }
const data = await res.json();
insertHtmlAtCursor('<img src="/project/api/attachments/' + data.attachment.id + '/inline" alt="">');
} catch (e) { alert("이미지 업로드 실패: " + e.message); }
}
box.addEventListener("paste", function (e) {
const items = (e.clipboardData && e.clipboardData.items) || [];
for (let i = 0; i < items.length; i++) {
if (items[i].type && items[i].type.indexOf("image/") === 0) {
e.preventDefault();
const file = items[i].getAsFile();
if (file) insertImageFile(file);
return;
}
}
// 이미지가 아니면 서식 없는 텍스트로만 붙여넣기(외부 사이트 서식 유입 방지).
e.preventDefault();
const text = (e.clipboardData || window.clipboardData).getData("text/plain");
insertHtmlAtCursor(escapeHtml(text).replace(/\n/g, "<br>"));
});
if (imgInput) imgInput.addEventListener("change", function () {
if (imgInput.files.length) insertImageFile(imgInput.files[0]);
imgInput.value = "";
});
return {
setTaskId: setTaskId,
setHtml: function (html) { box.innerHTML = html || ""; },
getHtml: function () { return box.innerHTML; },
};
}
// ── 하위 업무(인라인) — 체크박스·제목·담당자·마감일을 한 줄에서 바로 저장 ──
function initSubtaskBlock() {
const listEl = el("pj-subtask-list");
if (!listEl) return null;
const input = el("pj-subtask-input");
const addBtn = el("pj-subtask-add");
let taskId = null, projectId = null, canDeleteFn = function () { return false; }, onChange = null;
async function membersOptionsHtml(pid) {
try {
const res = await api("GET", "/project/api/projects/" + pid + "/members");
return (res.members || []).map(function (m) {
return '<option value="' + m.user_email + '">' + escapeHtml(idOf(m.user_email)) + "</option>";
}).join("");
} catch (_) { return ""; }
}
function rowEl(sub, optionsHtml) {
const li = document.createElement("li");
li.className = "pj-subtask-row" + (sub.completed_at ? " is-done" : "");
li.dataset.id = sub.id;
li.innerHTML =
'<input type="checkbox" class="pj-subtask-done"' + (sub.completed_at ? " checked" : "") + " />" +
'<input type="text" class="pj-subtask-title" />' +
'<select class="pj-subtask-assignee"><option value="">(미지정)</option>' + optionsHtml + "</select>" +
'<input type="date" class="pj-subtask-due" />' +
'<button type="button" class="pj-icon-btn pj-subtask-del" title="삭제" hidden>×</button>';
const titleInp = li.querySelector(".pj-subtask-title");
const doneChk = li.querySelector(".pj-subtask-done");
const assigneeSel = li.querySelector(".pj-subtask-assignee");
const dueInp = li.querySelector(".pj-subtask-due");
const delBtn = li.querySelector(".pj-subtask-del");
titleInp.value = sub.title || "";
assigneeSel.value = sub.assignee_email || "";
dueInp.value = sub.due_date || "";
if (canDeleteFn(sub.created_by)) delBtn.hidden = false;
async function patch(fields) {
try { await api("PUT", "/project/api/tasks/" + sub.id, fields); if (onChange) onChange(); }
catch (e) { alert("저장 실패: " + e.message); }
}
titleInp.addEventListener("change", function () {
const v = titleInp.value.trim();
if (!v) { titleInp.value = sub.title || ""; return; }
sub.title = v; patch({ title: v });
});
doneChk.addEventListener("change", function () {
li.classList.toggle("is-done", doneChk.checked);
patch({ completed: doneChk.checked });
});
assigneeSel.addEventListener("change", function () {
const opt = assigneeSel.selectedOptions[0];
patch({ assignee_email: assigneeSel.value, assignee_name: opt ? opt.textContent : "" });
});
dueInp.addEventListener("change", function () { patch({ due_date: dueInp.value || null }); });
delBtn.addEventListener("click", async function () {
if (!confirm("이 하위 업무를 삭제할까요?")) return;
try {
await api("DELETE", "/project/api/tasks/" + sub.id);
li.remove();
if (onChange) onChange();
} catch (e) { alert("삭제 실패: " + e.message); }
});
return li;
}
async function render(subtasks) {
listEl.innerHTML = subtasks.length ? "" : "<li class='pj-empty-sm'>없음</li>";
if (!subtasks.length) return;
const optionsHtml = projectId ? await membersOptionsHtml(projectId) : "";
listEl.innerHTML = "";
subtasks.forEach(function (sub) { listEl.appendChild(rowEl(sub, optionsHtml)); });
}
if (addBtn) addBtn.addEventListener("click", async function () {
const title = (input.value || "").trim();
if (!title) return;
if (!taskId) { alert("먼저 업무를 저장한 뒤 하위 업무를 추가할 수 있습니다."); return; }
try {
const res = await api("POST", "/project/api/projects/" + projectId + "/tasks",
{ title: title, parent_task_id: taskId });
input.value = "";
const optionsHtml = await membersOptionsHtml(projectId);
const empty = listEl.querySelector(".pj-empty-sm");
if (empty) empty.remove();
listEl.appendChild(rowEl(res.task, optionsHtml));
if (onChange) onChange();
} catch (e) { alert("추가 실패: " + e.message); }
});
if (input) input.addEventListener("keydown", function (e) {
if (e.key === "Enter") { e.preventDefault(); if (addBtn) addBtn.click(); }
});
return {
setContext: function (opts) {
taskId = opts.taskId; projectId = opts.projectId;
canDeleteFn = opts.canDelete || function () { return false; };
onChange = opts.onChange || null;
},
render: render,
};
}
// ── 연결된 프로젝트(멀티호밍, 아사나식) — 업무 하나를 다른 프로젝트의 세션에도 연결 ──
function initLinksBlock() {
const chipsEl = el("pj-link-chips");
if (!chipsEl) return null;
const addBtn = el("pj-link-add-btn");
const form = el("pj-link-form");
const projSel = el("pj-link-project");
const stageSel = el("pj-link-stage");
const saveBtn = el("pj-link-save");
const cancelBtn = el("pj-link-cancel");
let taskId = null, homeProjectId = null, onChange = null;
function chipEl(link) {
const span = document.createElement("span");
span.className = "pj-link-chip";
span.innerHTML =
'<span style="color:' + (link.project_color || "#4573d2") + '">●</span> ' +
escapeHtml(link.project_name || "") +
(link.stage_name ? " · " + escapeHtml(link.stage_name) : "") +
' <button type="button" class="pj-link-del" title="연결 해제">×</button>';
span.querySelector(".pj-link-del").addEventListener("click", async function () {
if (!confirm("'" + (link.project_name || "") + "' 연결을 해제할까요?")) return;
try {
await api("DELETE", "/project/api/tasks/" + taskId + "/links/" + link.project_id);
span.remove();
if (onChange) onChange();
} catch (e) { alert("해제 실패: " + e.message); }
});
return span;
}
function render(links) {
chipsEl.innerHTML = links.length ? "" : "<span class='pj-empty-sm'>없음</span>";
links.forEach(function (l) { chipsEl.appendChild(chipEl(l)); });
}
if (addBtn) addBtn.addEventListener("click", async function () {
form.hidden = false;
projSel.innerHTML = "<option value=''>불러오는 중…</option>";
stageSel.innerHTML = "<option value=''>프로젝트를 먼저 선택하세요</option>";
try {
const res = await api("GET", "/project/api/projects");
const list = (res.projects || []).filter(function (p) {
return p.id !== homeProjectId && !p.parent_id;
});
projSel.innerHTML = "<option value=''>프로젝트 선택…</option>" + list.map(function (p) {
return '<option value="' + p.id + '">' + escapeHtml(p.name) + "</option>";
}).join("");
} catch (e) { projSel.innerHTML = "<option value=''>불러오기 실패</option>"; }
});
if (cancelBtn) cancelBtn.addEventListener("click", function () { form.hidden = true; });
if (projSel) projSel.addEventListener("change", async function () {
const pid = parseInt(projSel.value, 10);
if (!pid) { stageSel.innerHTML = "<option value=''>프로젝트를 먼저 선택하세요</option>"; return; }
stageSel.innerHTML = "<option value=''>불러오는 중…</option>";
try {
const res = await api("GET", "/project/api/projects/" + pid + "/stages");
stageSel.innerHTML = (res.stages || []).map(function (s) {
return '<option value="' + s.id + '">' + escapeHtml(s.name) + "</option>";
}).join("");
} catch (e) { stageSel.innerHTML = "<option value=''>불러오기 실패</option>"; }
});
if (saveBtn) saveBtn.addEventListener("click", async function () {
const pid = parseInt(projSel.value, 10);
if (!pid) { alert("프로젝트를 선택하세요."); return; }
if (!taskId) { alert("먼저 업무를 저장한 뒤 연결할 수 있습니다."); return; }
try {
await api("POST", "/project/api/tasks/" + taskId + "/links",
{ project_id: pid, stage_id: stageSel.value ? parseInt(stageSel.value, 10) : null });
form.hidden = true;
if (onChange) onChange();
} catch (e) { alert("추가 실패: " + e.message); }
});
return {
setContext: function (opts) {
taskId = opts.taskId; homeProjectId = opts.projectId; onChange = opts.onChange || null;
form.hidden = true;
},
render: render,
};
}
function idOf(email) { function idOf(email) {
return String(email == null ? "" : email).split("@")[0]; return String(email == null ? "" : email).split("@")[0];
} }
+79 -14
View File
@@ -1,7 +1,7 @@
# 프로젝트 관리 모듈 (아사나식) — `app/modules/project/` # 프로젝트 관리 모듈 (아사나식) — `app/modules/project/`
> 회사(dbxcorp.co.kr) 직원이 프로젝트·서브프로젝트·업무를 달력/타임라인/보드로 > 회사(dbxcorp.co.kr) 직원이 프로젝트→세션→업무 3단계 구조를 달력/타임라인/
> 관리하는 아사나(Asana) 스타일 협업 모듈. DB는 `project_db` 전용. > 보드로 관리하는 아사나(Asana) 스타일 협업 모듈. DB는 `project_db` 전용.
## 1. 개요 ## 1. 개요
@@ -12,16 +12,66 @@
| 연결 env | `PROJECT_DB_URL` (미설정 시 "설정 필요" 안내, 앱은 죽지 않음) | | 연결 env | `PROJECT_DB_URL` (미설정 시 "설정 필요" 안내, 앱은 죽지 않음) |
| 진입 권한 | 권한키 `project` (관리자 페이지 토글로 직원별 부여, admin 자동) | | 진입 권한 | 권한키 `project` (관리자 페이지 토글로 직원별 부여, admin 자동) |
| 멤버 배정 후보 | `project` 권한 보유 등록 사용자 자동 목록 (`GET /project/api/assignable-users`) | | 멤버 배정 후보 | `project` 권한 보유 등록 사용자 자동 목록 (`GET /project/api/assignable-users`) |
| 관리 권한 | 프로젝트 생성/삭제·사용자 배정 = ERP 관리(`is_admin`) / 서브프로젝트·업무·단계 = 배정 멤버 또는 owner | | 생성 권한 | **프로젝트·세션·업무(하위 업무 포함) 생성은 로그인해 이 모듈에 접근할 수 있는 사용자 누구나.** 프로젝트를 만든 사람은 자동으로 owner 가 되어 그 프로젝트를 관리(`_can_manage`)할 수 있다. |
| 수정/이동 권한 | 서브프로젝트·업무·세션 CRUD(이름변경·완료토글·순서변경 등) = 그 프로젝트의 배정 멤버 또는 owner 또는 admin(`_require_manage`) |
| 삭제 권한 | **프로젝트·세션·업무 모두 "만든 사람만"**(슈퍼관리자 예외, 생성자 정보 없는 과거 데이터는 admin 허용) — `_can_delete` 하나로 통일 |
| 멤버 배정 | 여전히 관리자(`is_admin`) 전용. 비관리자가 "새 프로젝트" 모달을 열면 멤버 선택란은 안내 문구만 뜨고 시도하지 않는다(불필요한 403 방지) |
| 메일 알림 | `app/mail.py` (stdlib smtplib). 업무 배정/완료 시 관리자에게 발송 | | 메일 알림 | `app/mail.py` (stdlib smtplib). 업무 배정/완료 시 관리자에게 발송 |
## 2. 데이터 모델 (`scripts/sql/project_db_init.sql`) ## 1-1. 프로젝트 → 세션 → 업무 (하위 업무 포함) 3단계 구조
**"세션"은 새 개념이 아니라 기존 "단계"(칸반 컬럼)를 그대로 재사용한다** —
아사나도 보드 뷰의 컬럼과 리스트 뷰의 구분선이 같은 "섹션" 하나다. DB
컬럼/테이블 이름(`project_stages`, `stage_id`)은 그대로 두고, 사용자에게
보이는 문구만 "세션"으로 바꿨다.
- **세션 관리** — 프로젝트 화면(`/project/p/{id}`)에서 "이 프로젝트" 스코프로
볼 때만 보드 컬럼에 관리 UI가 붙는다(이름변경·완료토글·삭제·"+ 세션 추가").
컬럼 헤더를 **드래그해서 순서를 바꿀 수 있다**(카드 드래그와 같은 네이티브
HTML5 DnD, `PUT /api/projects/{id}/stages/order` 재사용). "전체 프로젝트"
스코프(여러 프로젝트를 세션 '이름'으로 합쳐 보는 예전 방식)에서는 세션을
하나로 특정할 수 없어 관리 UI가 없다.
- **하위 업무(subtask)** — 새 테이블이 아니라 `tasks.parent_task_id` 재사용
(부모 삭제 시 CASCADE). 업무 편집 팝업 안에 인라인으로 표시되며(체크박스+
제목+담당자+마감일, 한 줄에서 바로 저장), 보드/캘린더/리스트에는 최상위
업무만 카드로 뜬다(하위 업무는 부모 카드에 "2/5" 진행 배지로만 나타난다).
세션을 갖지 않는다(칸반에 안 보이므로).
- **멀티호밍(다른 프로젝트에 연결)** — `task_project_links` 테이블. 업무 하나가
원래 소속(기본 홈, `tasks.project_id`/`stage_id`)과 무관하게 다른 프로젝트의
한 세션에도 동시에 나타날 수 있다(아사나의 "Add to project"). 업무 편집
팝업의 "연결된 프로젝트" 칩에서 추가/해제한다. 새 연결을 만들 때는 원
프로젝트·대상 프로젝트 양쪽 관리 권한이 필요하지만(무단으로 남의 업무를
끌어와 노출시키는 것 방지), **이미 연결된 업무를 그 프로젝트 보드 안에서
다른 세션으로 드래그하는 것은 대상 프로젝트 권한만 있으면 된다.**
`list_tasks(project_id=X)`(비재귀 단일 프로젝트 조회, 프로젝트 화면이 쓰는
바로 그 경로)가 자동으로 연결된 업무까지 포함해서 돌려준다 — 다른 호출
경로(홈/내 업무 등 `project_id` 없이 부르는 전체보기)는 원 소속만 세어
중복 노출을 막는다.
- **사이드바 프로젝트 스코프** — 프로젝트 화면 툴바에 "이 프로젝트"/"전체
프로젝트" 토글이 있다. 기본은 "이 프로젝트"(아사나처럼 지금 선택한
프로젝트의 세션만 보드/캘린더/타임라인/리스트에 나온다), "전체 프로젝트"는
예전처럼 접근 가능한 모든 프로젝트를 합쳐서 보여준다. 서버가 `tasks` 배열의
각 행에 `context_project_id`(어느 프로젝트를 보다가 담겼는지)를 표시해두고,
클라이언트가 이 값으로 스코프를 걸러낸다(순수 프론트 필터 — 서버 재조회 없음).
- **업무 설명 이미지 삽입** — 설명란이 `contenteditable` 로 바뀌어 이미지를
붙여넣거나 파일로 올릴 수 있다. 새 첨부 endpoint 없이 기존
`task_attachments` 업로드를 재사용하고, 표시는 `GET
/api/attachments/{id}/inline`(다운로드용 `/download``Content-Disposition:
attachment` 가 걸려 `<img>` 로 못 씀 — 그래서 인라인 전용 endpoint를 따로
뒀다)로 한다. 저장 전 서버가 `store.sanitize_description_html()` 로 허용
태그만 남기고 스크립트/이벤트속성/위험 URL 스킴을 제거한다(표준
`html.parser`, 외부 라이브러리 없음). 새 업무 작성 중(저장 전, task_id 없음)
에는 이미지 삽입이 비활성화된다 — 첨부가 업무 id 를 필요로 하기 때문.
## 2. 데이터 모델 (`scripts/sql/project_db_init.sql` + `..._002_*`/`..._003_*`)
- `projects``parent_id`(self-FK, NULL=최상위 / NOT NULL=서브프로젝트, `ON DELETE CASCADE`), name, description, color, owner_email, start/due_date, status(active|archived). - `projects``parent_id`(self-FK, NULL=최상위 / NOT NULL=서브프로젝트, `ON DELETE CASCADE`), name, description, color, owner_email, start/due_date, status(active|archived).
- `project_members` — 프로젝트↔사용자 배정. role(manager|member), `UNIQUE(project_id, user_email)`. - `project_members` — 프로젝트↔사용자 배정. role(manager|member), `UNIQUE(project_id, user_email)`.
- `project_stages` — 진행단계(칸반 컬럼). 프로젝트 생성 시 기본 4단계 seed(할 일/진행 중/검토/완료). `is_done_stage`=TRUE 단계로 옮기면 업무 완료 처리. - `project_stages`(="세션") — 진행단계(칸반 컬럼). 프로젝트 생성 시 기본 4단계 seed(할 일/진행 중/검토/완료). `is_done_stage`=TRUE 단계로 옮기면 업무 완료 처리. `created_by`(003) — 삭제를 "만든 사람만"으로 제한하기 위함.
- `tasks` — 업무. stage_id, title, description, assignee_email/name, priority(low|normal|high), start/due_date, completed_at. - `tasks` — 업무. stage_id, title, description(HTML — 이미지 삽입 지원), assignee_email/name, priority(low|normal|high), start/due_date, completed_at, `parent_task_id`(003, self-FK `ON DELETE CASCADE`, NULL=최상위 업무).
- `task_comments` — 댓글(스켈레톤 테이블, UI는 추후). - `task_project_links`(003) — 멀티호밍. `(task_id, project_id)` UNIQUE, `stage_id` nullable(그 프로젝트 안에서의 세션).
- `task_comments` — 댓글.
- `task_attachments` — 첨부(설명란 이미지도 이걸 재사용).
- `project_activity` — 활동 이력(created/assigned/completed/stage_changed). 메일 트리거 근거 + 타임라인. - `project_activity` — 활동 이력(created/assigned/completed/stage_changed). 메일 트리거 근거 + 타임라인.
## 3. 화면 / 뷰 ## 3. 화면 / 뷰
@@ -42,8 +92,11 @@
좌측 트리의 업무 클릭도 페이지 이동 없이 바로 팝업(이미 로드된 전체 업무 데이터 사용). 좌측 트리의 업무 클릭도 페이지 이동 없이 바로 팝업(이미 로드된 전체 업무 데이터 사용).
- **달력** — FullCalendar. 업무를 시작~마감 기간으로 표시. 클릭 편집. - **달력** — FullCalendar. 업무를 시작~마감 기간으로 표시. 클릭 편집.
- **타임라인** — vis-timeline(간트형). 기간 있는 업무만. - **타임라인** — vis-timeline(간트형). 기간 있는 업무만.
- **보드** — 단계별 칸반. 카드 드래그로 단계 이동(`PUT /api/tasks/{id}` `stage_id`). - **보드** — 세션별 칸반. 카드 드래그로 세션 이동(`PUT /api/tasks/{id}` `stage_id`,
멀티호밍된 업무는 `POST /api/tasks/{id}/links`). 세션 컬럼 자체도 드래그로
재정렬(§1-1).
- **리스트** — 표. - **리스트** — 표.
- 4개 뷰 모두 툴바의 "이 프로젝트"/"전체 프로젝트" 스코프 토글을 따른다(§1-1).
- 라이브러리는 현재 CDN 로드(스켈레톤). 추후 `app/static/vendor/` self-host 권장. - 라이브러리는 현재 CDN 로드(스켈레톤). 추후 `app/static/vendor/` self-host 권장.
## 4. 메일 알림 ## 4. 메일 알림
@@ -64,18 +117,30 @@ docker exec -i postgres-db psql -U postgres -v app_password="$APP_PWD" \
# PROJECT_DB_URL=postgresql://project_app:<APP_PWD>@postgres-db:5432/project_db # PROJECT_DB_URL=postgresql://project_app:<APP_PWD>@postgres-db:5432/project_db
# (메일 쓰려면 SMTP_* 추가) # (메일 쓰려면 SMTP_* 추가)
# 3) 재배포 (git pull 후 반드시 --build) # 3) 마이그레이션 002·003 적용 (첨부/알림, 세션 생성자·하위업무·멀티호밍)
docker exec -i postgres-db psql -U postgres -d project_db \
< scripts/sql/project_db_002_attachments_notifications.sql
docker exec -i postgres-db psql -U postgres -d project_db \
< scripts/sql/project_db_003_sections_subtasks_links.sql
# 4) 재배포 (git pull 후 반드시 --build)
cd /opt/www/main && docker compose up -d --build web cd /opt/www/main && docker compose up -d --build web
``` ```
## 6. 댓글 · 첨부 · 알림센터 · 단계편집 (구현됨) ⚠️ 003 안에는 기존 `tasks.description`(일반 텍스트)을 안전한 HTML로 바꾸는
**1회성** `UPDATE` 문이 있다(설명란이 contenteditable 로 바뀌어 이제
description 을 항상 HTML로 취급하기 때문). 두 번 실행하면 이중 이스케이프되니
꼭 한 번만 돌릴 것.
## 6. 댓글 · 첨부 · 알림센터 · 세션 편집 (구현됨)
- **댓글** `task_comments` — 업무 모달 하단. 등록/삭제(본인·관리자). 새 댓글 시 관련자 인앱 알림. - **댓글** `task_comments` — 업무 모달 하단. 등록/삭제(본인·관리자). 새 댓글 시 관련자 인앱 알림.
- **첨부** `task_attachments` — 업무 모달. 파일 업로드(최대 20MB)/다운로드/삭제. 실제 파일은 `DATA_DIR/project/<task_id>/<uuid>.<ext>`, DB엔 메타만. 첨부 디렉토리는 운영 볼륨(DATA_DIR)에 저장돼 재배포에도 보존. - **첨부** `task_attachments` — 업무 모달. 파일 업로드(최대 20MB)/다운로드/삭제. 실제 파일은 `DATA_DIR/project/<task_id>/<uuid>.<ext>`, DB엔 메타만. 첨부 디렉토리는 운영 볼륨(DATA_DIR)에 저장돼 재배포에도 보존. 설명란 이미지 삽입도 이 표를 재사용(§1-1).
- **단계 편집** — 보드 칸반 헤더에서 단계 이름변경/완료토글/좌우 이동(순서)/삭제, 트레일링 "+ 단계 추가". `PUT .../stages/order`, `PUT .../stages/{id}`. - **세션 편집** — 보드 칸반 헤더에서 이름변경/완료토글/삭제, 트레일링 "+ 세션 추가", **컬럼 드래그로 순서 변경**. `PUT .../stages/order`, `PUT .../stages/{id}`, 삭제는 만든 사람만.
- **알림센터(인앱)** `project_notifications` — 배정/완료/댓글 시 수신자별 알림 생성. 우측 상단 벨(미읽음 배지) → `/project/inbox`. 항목 클릭=읽음, "모두 읽음". 본인 행동은 알림 제외. - **알림센터(인앱)** `project_notifications` — 배정/완료/댓글 시 수신자별 알림 생성. 우측 상단 벨(미읽음 배지) → `/project/inbox`. 항목 클릭=읽음, "모두 읽음". 본인 행동은 알림 제외.
- 마이그레이션: `scripts/sql/project_db_002_attachments_notifications.sql` (첨부·알림 테이블 + 권한). - 마이그레이션: `scripts/sql/project_db_002_attachments_notifications.sql` (첨부·알림 테이블 + 권한), `scripts/sql/project_db_003_sections_subtasks_links.sql` (세션 생성자·하위업무·멀티호밍).
## 7. 추후 단계 ## 7. 추후 단계
태그 · 하위업무(체크리스트) · 검색/필터 · 업무 정렬 영속화 · 칸반 단계 드래그 정렬 · 멘션. 태그 · 검색/필터 · 업무 정렬 영속화 · 멘션 · 하위 업무의 하위 업무(다단계 중첩,
아사나도 UI상 1단계만 허용해 현재 의도적으로 안 함).