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
elif not done and prev.get("completed_at"):
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:
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
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)
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% 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" />
{% endblock %}
@@ -45,5 +45,5 @@
{% endif %}
</div>
<script src="/static/project.js?v=20260915g" defer></script>
<script src="/static/project.js?v=20260916a" defer></script>
{% endblock %}
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% 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" />
{% endblock %}
@@ -244,12 +244,17 @@
<input type="hidden" id="pj-t-id" />
<input type="hidden" id="pj-t-project-id" />
<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">
<label>담당자
<select id="pj-t-assignee"><option value="">불러오는 중…</option></select>
</label>
<label>단계
<label>세션
<select id="pj-t-stage"><option value="">불러오는 중…</option></select>
</label>
</div>
@@ -271,6 +276,29 @@
<label>시작 시간<input type="time" id="pj-t-stime" disabled /></label>
<label>마감 시간<input type="time" id="pj-t-dtime" disabled /></label>
</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-extra-head">
<span class="material-symbols-outlined pj-gicon pj-gicon-sm">attach_file</span> 첨부파일
@@ -321,5 +349,5 @@
<script>
window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }};
</script>
<script src="/static/project.js?v=20260915g" defer></script>
<script src="/static/project.js?v=20260916a" defer></script>
{% endblock %}
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260915i" />
<link rel="stylesheet" href="/static/project.css?v=20260916a" />
<!-- 구글 머티리얼 심볼(담당자 아이콘 등) — self-host -->
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
<!-- 타임라인 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="list">리스트</button>
</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>
<!-- 달력 (휴가 모듈과 동일한 월간 그리드) -->
@@ -166,7 +172,7 @@
<div class="pj-view" data-view="list" hidden>
<table class="pj-table" id="pj-list">
<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>
<tbody></tbody>
</table>
@@ -181,7 +187,12 @@
<h3 id="pj-task-modal-title">새 업무</h3>
<input type="hidden" id="pj-t-id" />
<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">
<label>담당자
<select id="pj-t-assignee">
@@ -193,7 +204,7 @@
{% endfor %}
</select>
</label>
<label>단계
<label>세션
<select id="pj-t-stage">
{% for s in stages %}
<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-dtime" disabled /></label>
</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-extra-head">
@@ -292,5 +331,5 @@
</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 %}
+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[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-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; }
@@ -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: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-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");
if (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) {
const sel = el("pj-t-assignee");
@@ -314,7 +336,7 @@
el("pj-t-id").value = task.id;
el("pj-t-project-id").value = task.project_id;
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-start").value = task.start_date || "";
el("pj-t-due").value = task.due_date || "";
@@ -329,6 +351,8 @@
el("pj-t-open-project").hidden = false;
el("pj-attach-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);
await Promise.all([
fillAssignee(task.project_id, task.assignee_email || ""),
@@ -336,6 +360,7 @@
]);
loadHomeAttachments(task.id);
loadHomeComments(task.id);
loadTaskExtras(task);
}
// 카드 이름 옆 "업무 추가" 버튼 — 같은 모달을 새 업무 작성용으로 초기화해 연다.
@@ -344,7 +369,7 @@
el("pj-t-id").value = "";
el("pj-t-project-id").value = projectId;
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-start").value = "";
el("pj-t-due").value = "";
@@ -355,6 +380,8 @@
el("pj-t-open-project").hidden = true;
el("pj-attach-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);
await Promise.all([fillAssignee(projectId, ""), fillStage(projectId, null)]);
}
@@ -382,7 +409,7 @@
const useTime = el("pj-t-usetime").checked;
const payload = {
title: el("pj-t-title").value.trim(),
description: el("pj-t-desc").value.trim(),
description: descEditor ? descEditor.getHtml() : "",
assignee_email: assigneeSel.value,
assignee_name: assigneeName,
stage_id: parseInt(el("pj-t-stage").value, 10) || null,
@@ -580,6 +607,19 @@
let timeline = null;
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");
tabs.addEventListener("click", function (e) {
@@ -596,6 +636,20 @@
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) {
if (view === "calendar") renderCalendar();
else if (view === "timeline") renderTimeline();
@@ -640,7 +694,7 @@
// (주마다 따로 배치하면 첫주/다음주의 순서가 바뀌는 문제 방지)
function computeLanes() {
const items = [];
tasks.forEach(function (t) {
visibleTasks().forEach(function (t) {
const sp = taskSpan(t);
if (sp) items.push({ id: t.id, s: sp[0], e: sp[1] });
});
@@ -675,7 +729,7 @@
// 이 주에 걸치는 업무 → 막대 세그먼트(레인은 전역 배정값 사용)
const bars = [];
tasks.forEach(function (t) {
visibleTasks().forEach(function (t) {
const sp = taskSpan(t);
if (!sp) return;
if (sp[1] < weekStart || sp[0] > weekEnd) return;
@@ -808,7 +862,7 @@
}
function openDayPopover(dateStr, anchorEl) {
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];
});
dayPop = document.createElement("div");
@@ -847,7 +901,7 @@
function renderTimeline() {
const node = el("pj-timeline");
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; }
// 프로젝트 메타(시작/마감/색) 조회용
@@ -961,13 +1015,23 @@
} catch (_) {}
}
// ── 보드 (칸반) — 전체 프로젝트를 단계 '이름' 기준 컬럼으로 묶음 ──
// ── 보드 (칸반 = 세션) ──
// scope==="project" → 이 프로젝트의 실제 세션(id 기준) + 세션 관리(이름변경·
// 완료토글·삭제·추가·드래그 재정렬).
// scope==="all" → 여러 프로젝트를 세션 '이름' 기준으로 합쳐 보여준다(기존
// 동작 그대로 — 세션 하나를 특정할 수 없어 관리 컨트롤은 없다).
function renderBoard() {
const board = el("pj-board");
board.innerHTML = "";
// 컬럼 이름 = 서버가 정렬해준 전체 단계 이름 + 단계 미지정 업무용 '미지정'
const vis = visibleTasks();
if (viewScope === "project") renderBoardByStage(board, vis);
else renderBoardByName(board, vis);
}
function renderBoardByName(board, vis) {
// 컬럼 이름 = 서버가 정렬해준 전체 세션 이름 + 세션 미지정 업무용 '미지정'
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("미지정");
names.forEach(function (name) {
const col = document.createElement("div");
@@ -978,7 +1042,7 @@
'<span class="pj-col-count"></span></div>' +
'<div class="pj-col-body"></div>';
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;
});
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) {
const card = document.createElement("div");
card.className = "pj-card" + (t.completed_at ? " is-done" : "");
card.draggable = canManage;
card.dataset.taskId = t.id;
// 전체보기는 항상 원 프로젝트 배지를 보여준다. 프로젝트 스코프에서는
// "이 프로젝트가 원 소속"인 카드는 굳이 표시하지 않고, 멀티호밍으로
// 들어온(다른 프로젝트가 원 소속인) 카드만 아사나처럼 배지를 붙인다.
const showProjTag = viewScope === "all" || t.project_id !== projectId;
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-title">' + escapeHtml(t.title) + "</div>" +
@@ -1006,6 +1279,9 @@
? '<span class="pj-assignee">' + avatarHtml(t.assignee_email, "pj-gicon-sm") +
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.comment_count ? '<span class="pj-card-cmt">' + cmtBadge(t) + "</span>" : "") +
"</div>";
@@ -1020,36 +1296,12 @@
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 = [
{ 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: "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: "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 || ""; } },
@@ -1098,7 +1350,7 @@
thead.innerHTML = "";
thead.appendChild(trh);
let rows = tasks.slice();
let rows = visibleTasks();
if (listSort.key) {
const col = LIST_COLS.find(function (c) { return c.key === listSort.key; });
rows.sort(function (a, b) {
@@ -1147,6 +1399,28 @@
const taskModal = el("pj-modal-task");
wireModalClose(taskModal);
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 를 해당 업무가 속한 프로젝트의 단계로 채운다(전체 뷰에서 타 프로젝트 업무 편집 대비).
function fillStageSelect(projId) {
@@ -1177,7 +1451,7 @@
el("pj-task-modal-title").textContent = task ? "업무 편집" : "새 업무";
el("pj-t-id").value = task ? task.id : "";
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 || "") : "");
fillStageSelect(task ? task.project_id : projectId);
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-dtime").disabled = !useTime;
el("pj-delete-task").hidden = !task;
// 댓글·첨부 기존 업무 편집 시에만
// 댓글·첨부·하위업무·연결은 기존 업무 편집 시에만
currentTaskId = task ? task.id : null;
el("pj-attach-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);
}
@@ -1353,7 +1629,7 @@
const useTime = el("pj-t-usetime").checked;
const payload = {
title: el("pj-t-title").value.trim(),
description: el("pj-t-desc").value.trim(),
description: descEditor ? descEditor.getHtml() : "",
assignee_email: assigneeSel.value,
assignee_name: assigneeName,
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) {
return String(email == null ? "" : email).split("@")[0];
}