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:
@@ -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}
|
||||
|
||||
@@ -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
@@ -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
@@ -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];
|
||||
}
|
||||
|
||||
+79
-14
@@ -1,7 +1,7 @@
|
||||
# 프로젝트 관리 모듈 (아사나식) — `app/modules/project/`
|
||||
|
||||
> 회사(dbxcorp.co.kr) 직원이 프로젝트·서브프로젝트·업무를 달력/타임라인/보드로
|
||||
> 관리하는 아사나(Asana) 스타일 협업 모듈. DB는 `project_db` 전용.
|
||||
> 회사(dbxcorp.co.kr) 직원이 프로젝트→세션→업무 3단계 구조를 달력/타임라인/
|
||||
> 보드로 관리하는 아사나(Asana) 스타일 협업 모듈. DB는 `project_db` 전용.
|
||||
|
||||
## 1. 개요
|
||||
|
||||
@@ -12,16 +12,66 @@
|
||||
| 연결 env | `PROJECT_DB_URL` (미설정 시 "설정 필요" 안내, 앱은 죽지 않음) |
|
||||
| 진입 권한 | 권한키 `project` (관리자 페이지 토글로 직원별 부여, admin 자동) |
|
||||
| 멤버 배정 후보 | `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). 업무 배정/완료 시 관리자에게 발송 |
|
||||
|
||||
## 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).
|
||||
- `project_members` — 프로젝트↔사용자 배정. role(manager|member), `UNIQUE(project_id, user_email)`.
|
||||
- `project_stages` — 진행단계(칸반 컬럼). 프로젝트 생성 시 기본 4단계 seed(할 일/진행 중/검토/완료). `is_done_stage`=TRUE 단계로 옮기면 업무 완료 처리.
|
||||
- `tasks` — 업무. stage_id, title, description, assignee_email/name, priority(low|normal|high), start/due_date, completed_at.
|
||||
- `task_comments` — 댓글(스켈레톤 테이블, UI는 추후).
|
||||
- `project_stages`(="세션") — 진행단계(칸반 컬럼). 프로젝트 생성 시 기본 4단계 seed(할 일/진행 중/검토/완료). `is_done_stage`=TRUE 단계로 옮기면 업무 완료 처리. `created_by`(003) — 삭제를 "만든 사람만"으로 제한하기 위함.
|
||||
- `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_project_links`(003) — 멀티호밍. `(task_id, project_id)` UNIQUE, `stage_id` nullable(그 프로젝트 안에서의 세션).
|
||||
- `task_comments` — 댓글.
|
||||
- `task_attachments` — 첨부(설명란 이미지도 이걸 재사용).
|
||||
- `project_activity` — 활동 이력(created/assigned/completed/stage_changed). 메일 트리거 근거 + 타임라인.
|
||||
|
||||
## 3. 화면 / 뷰
|
||||
@@ -42,8 +92,11 @@
|
||||
좌측 트리의 업무 클릭도 페이지 이동 없이 바로 팝업(이미 로드된 전체 업무 데이터 사용).
|
||||
- **달력** — FullCalendar. 업무를 시작~마감 기간으로 표시. 클릭 편집.
|
||||
- **타임라인** — 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 권장.
|
||||
|
||||
## 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
|
||||
# (메일 쓰려면 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
|
||||
```
|
||||
|
||||
## 6. 댓글 · 첨부 · 알림센터 · 단계편집 (구현됨)
|
||||
⚠️ 003 안에는 기존 `tasks.description`(일반 텍스트)을 안전한 HTML로 바꾸는
|
||||
**1회성** `UPDATE` 문이 있다(설명란이 contenteditable 로 바뀌어 이제
|
||||
description 을 항상 HTML로 취급하기 때문). 두 번 실행하면 이중 이스케이프되니
|
||||
꼭 한 번만 돌릴 것.
|
||||
|
||||
## 6. 댓글 · 첨부 · 알림센터 · 세션 편집 (구현됨)
|
||||
|
||||
- **댓글** `task_comments` — 업무 모달 하단. 등록/삭제(본인·관리자). 새 댓글 시 관련자 인앱 알림.
|
||||
- **첨부** `task_attachments` — 업무 모달. 파일 업로드(최대 20MB)/다운로드/삭제. 실제 파일은 `DATA_DIR/project/<task_id>/<uuid>.<ext>`, DB엔 메타만. 첨부 디렉토리는 운영 볼륨(DATA_DIR)에 저장돼 재배포에도 보존.
|
||||
- **단계 편집** — 보드 칸반 헤더에서 단계 이름변경/완료토글/좌우 이동(순서)/삭제, 트레일링 "+ 단계 추가". `PUT .../stages/order`, `PUT .../stages/{id}`.
|
||||
- **첨부** `task_attachments` — 업무 모달. 파일 업로드(최대 20MB)/다운로드/삭제. 실제 파일은 `DATA_DIR/project/<task_id>/<uuid>.<ext>`, DB엔 메타만. 첨부 디렉토리는 운영 볼륨(DATA_DIR)에 저장돼 재배포에도 보존. 설명란 이미지 삽입도 이 표를 재사용(§1-1).
|
||||
- **세션 편집** — 보드 칸반 헤더에서 이름변경/완료토글/삭제, 트레일링 "+ 세션 추가", **컬럼 드래그로 순서 변경**. `PUT .../stages/order`, `PUT .../stages/{id}`, 삭제는 만든 사람만.
|
||||
- **알림센터(인앱)** `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. 추후 단계
|
||||
|
||||
태그 · 하위업무(체크리스트) · 검색/필터 · 업무 정렬 영속화 · 칸반 단계 드래그 정렬 · 멘션.
|
||||
태그 · 검색/필터 · 업무 정렬 영속화 · 멘션 · 하위 업무의 하위 업무(다단계 중첩,
|
||||
아사나도 UI상 1단계만 허용해 현재 의도적으로 안 함).
|
||||
|
||||
Reference in New Issue
Block a user