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
+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];
}