diff --git a/app/modules/project/db.py b/app/modules/project/db.py
index 97f86ab..06e7df2 100644
--- a/app/modules/project/db.py
+++ b/app/modules/project/db.py
@@ -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}
diff --git a/app/modules/project/router.py b/app/modules/project/router.py
index d593e38..2d37f8d 100644
--- a/app/modules/project/router.py
+++ b/app/modules/project/router.py
@@ -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):
+ """설명란에 붙여넣은/업로드한 이미지를 ` ` 로 바로 보여주기 위한
+ 서빙. `/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)
diff --git a/app/modules/project/templates/project/inbox.html b/app/modules/project/templates/project/inbox.html
index 5b17706..984e000 100644
--- a/app/modules/project/templates/project/inbox.html
+++ b/app/modules/project/templates/project/inbox.html
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
-
+
{% endblock %}
@@ -45,5 +45,5 @@
{% endif %}
-
+
{% endblock %}
diff --git a/app/modules/project/templates/project/index.html b/app/modules/project/templates/project/index.html
index 501d602..2d0b8a1 100644
--- a/app/modules/project/templates/project/index.html
+++ b/app/modules/project/templates/project/index.html
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
-
+
{% endblock %}
@@ -244,12 +244,17 @@
제목
- 설명
+ 설명
+
- 업무 담당자 단계 우선순위 시작 마감
+ 업무 담당자 세션 우선순위 시작 마감
@@ -181,7 +187,12 @@
새 업무
제목
-
설명
+
설명
+
+ 이미지 추가
+
+
+
담당자
@@ -193,7 +204,7 @@
{% endfor %}
- 단계
+ 세션
{% for s in stages %}
{{ s.name }}
@@ -219,6 +230,34 @@
시작 시간
마감 시간
+
+
+
+
+
+
+
";
@@ -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('
');
+ } 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, "
"));
+ });
+
+ 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 '
' + escapeHtml(idOf(m.user_email)) + " ";
+ }).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 =
+ '
" +
+ '
' +
+ '
(미지정) ' + optionsHtml + "" +
+ '
' +
+ '
× ';
+ 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 ? "" : "
없음 ";
+ 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 =
+ '
● ' +
+ escapeHtml(link.project_name || "") +
+ (link.stage_name ? " · " + escapeHtml(link.stage_name) : "") +
+ '
× ';
+ 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 ? "" : "
없음 ";
+ links.forEach(function (l) { chipsEl.appendChild(chipEl(l)); });
+ }
+
+ if (addBtn) addBtn.addEventListener("click", async function () {
+ form.hidden = false;
+ projSel.innerHTML = "
불러오는 중… ";
+ stageSel.innerHTML = "
프로젝트를 먼저 선택하세요 ";
+ 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 = "
프로젝트 선택… " + list.map(function (p) {
+ return '
' + escapeHtml(p.name) + " ";
+ }).join("");
+ } catch (e) { projSel.innerHTML = "
불러오기 실패 "; }
+ });
+ 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 = "
프로젝트를 먼저 선택하세요 "; return; }
+ stageSel.innerHTML = "
불러오는 중… ";
+ try {
+ const res = await api("GET", "/project/api/projects/" + pid + "/stages");
+ stageSel.innerHTML = (res.stages || []).map(function (s) {
+ return '
' + escapeHtml(s.name) + " ";
+ }).join("");
+ } catch (e) { stageSel.innerHTML = "
불러오기 실패 "; }
+ });
+ 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];
}
diff --git a/docs/PROJECT_MODULE.md b/docs/PROJECT_MODULE.md
index 1edc4a6..ff796ea 100644
--- a/docs/PROJECT_MODULE.md
+++ b/docs/PROJECT_MODULE.md
@@ -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` 가 걸려 `
` 로 못 씀 — 그래서 인라인 전용 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:
@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//.`, DB엔 메타만. 첨부 디렉토리는 운영 볼륨(DATA_DIR)에 저장돼 재배포에도 보존.
-- **단계 편집** — 보드 칸반 헤더에서 단계 이름변경/완료토글/좌우 이동(순서)/삭제, 트레일링 "+ 단계 추가". `PUT .../stages/order`, `PUT .../stages/{id}`.
+- **첨부** `task_attachments` — 업무 모달. 파일 업로드(최대 20MB)/다운로드/삭제. 실제 파일은 `DATA_DIR/project//.`, 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단계만 허용해 현재 의도적으로 안 함).