Files
dbx-main/app/static/project.js
T
king 5f85826965 feat(project): 댓글 수정 기능 + 달력 드래그 드롭 수정
- 댓글 수정: PUT /api/comments/{id}(본인·관리자), 모달서 인라인 편집(textarea)
- 달력 드래그 드롭이 안 되던 문제 수정: 드롭 대상을 day 셀 대신 주(week)
  단위로 받고 마우스 X 로 날짜 칸을 계산. bar 오버레이/pointer-events 간섭
  없이 안정적으로 드롭됨. dragover 에서 dropEffect=move 명시.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:42:04 +09:00

727 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 프로젝트 관리(아사나식) 모듈 — 프론트엔드.
* 두 페이지에서 공용:
* - index.html (홈): 새 프로젝트 모달
* - project.html: 달력/타임라인/보드/리스트 뷰 + 업무 CRUD + 서브/멤버 관리
* 의존: FullCalendar(전역), vis(전역). 둘 다 project.html 에서만 로드.
*/
(function () {
"use strict";
// ── 공용 fetch ─────────────────────────────────────────────
async function api(method, url, body) {
const opts = { method, headers: { "Content-Type": "application/json" } };
if (body !== undefined) opts.body = JSON.stringify(body);
const res = await fetch(url, opts);
if (!res.ok) {
let msg = res.statusText;
try { msg = (await res.json()).detail || msg; } catch (_) {}
throw new Error(msg);
}
if (res.status === 204) return null;
return res.json();
}
function el(id) { return document.getElementById(id); }
function openModal(m) { if (m) m.hidden = false; }
function closeModal(m) { if (m) m.hidden = true; }
function wireModalClose(modal) {
if (!modal) return;
modal.addEventListener("click", function (e) {
if (e.target === modal || e.target.hasAttribute("data-close")) closeModal(modal);
});
}
// ── 색상 팔레트 렌더 ───────────────────────────────────────
function renderColorPalette(container, onPick) {
const colors = window.PJ_COLORS || ["#4573d2"];
let selected = colors[0];
container.innerHTML = "";
colors.forEach(function (c) {
const b = document.createElement("button");
b.type = "button";
b.className = "pj-color-swatch";
b.style.background = c;
if (c === selected) b.classList.add("is-active");
b.addEventListener("click", function () {
selected = c;
container.querySelectorAll(".pj-color-swatch").forEach(function (x) {
x.classList.remove("is-active");
});
b.classList.add("is-active");
if (onPick) onPick(c);
});
container.appendChild(b);
});
return function () { return selected; };
}
// ════════════════════════════════════════════════════════════
// 홈 페이지 — 새 프로젝트
// ════════════════════════════════════════════════════════════
function initHome() {
const newBtn = el("pj-new-project");
const modal = el("pj-modal-project");
if (!newBtn || !modal) return;
wireModalClose(modal);
const getColor = renderColorPalette(el("pj-f-colors"));
newBtn.addEventListener("click", function () { openModal(modal); });
el("pj-save-project").addEventListener("click", async function () {
const name = el("pj-f-name").value.trim();
if (!name) { alert("이름을 입력하세요."); return; }
try {
await api("POST", "/project/api/projects", {
name: name,
description: el("pj-f-desc").value.trim(),
start_date: el("pj-f-start").value || null,
due_date: el("pj-f-due").value || null,
color: getColor(),
});
location.reload();
} catch (e) { alert("생성 실패: " + e.message); }
});
}
// ════════════════════════════════════════════════════════════
// 프로젝트 페이지
// ════════════════════════════════════════════════════════════
function initProject() {
const root = document.querySelector(".pj-project-view");
if (!root) return;
const projectId = parseInt(root.dataset.projectId, 10);
const canManage = root.dataset.canManage === "true";
const isAdmin = root.dataset.isAdmin === "true";
const dataEl = el("pj-data");
const data = JSON.parse(dataEl.textContent);
let stages = data.stages || [];
let tasks = data.tasks || [];
const members = data.members || [];
const priorityLabels = data.priorityLabels || {};
let timeline = null;
let currentView = "calendar";
// ── 뷰 토글 ──
const tabs = el("pj-view-tabs");
tabs.addEventListener("click", function (e) {
const btn = e.target.closest(".pj-tab");
if (!btn) return;
const view = btn.dataset.view;
tabs.querySelectorAll(".pj-tab").forEach(function (t) {
t.classList.toggle("is-active", t === btn);
});
document.querySelectorAll(".pj-view").forEach(function (v) {
v.hidden = v.dataset.view !== view;
});
currentView = view;
renderView(view);
});
function renderView(view) {
if (view === "calendar") wireCalendar();
else if (view === "timeline") renderTimeline();
else if (view === "board") renderBoard();
else if (view === "list") renderList();
}
// ── 달력 (휴가식 서버 렌더 그리드) ──
// bar 는 서버가 그려둠. 클릭=업무 모달, 드래그=일정 이동(관리 권한 시).
let calendarWired = false;
// 날짜 문자열 더하기(일 단위). 'YYYY-MM-DD' 입출력.
function addDays(dstr, days) {
if (!dstr) return null;
const d = new Date(dstr + "T00:00:00");
d.setDate(d.getDate() + days);
const y = d.getFullYear(), m = String(d.getMonth() + 1).padStart(2, "0"), dd = String(d.getDate()).padStart(2, "0");
return y + "-" + m + "-" + dd;
}
function dayDiff(a, b) {
return Math.round((new Date(b + "T00:00:00") - new Date(a + "T00:00:00")) / 86400000);
}
let dragTaskId = null;
// 한 주 안에서 clientX → 0..6 열 인덱스 → 그 칸의 날짜.
function dateAtPointer(week, clientX) {
const cells = week.querySelectorAll(".pj-week-days .pj-day[data-date]");
if (!cells.length) return null;
const rect = week.getBoundingClientRect();
let col = Math.floor((clientX - rect.left) / (rect.width / 7));
col = Math.max(0, Math.min(cells.length - 1, col));
return { date: cells[col].dataset.date, cell: cells[col] };
}
function clearDropHL() {
document.querySelectorAll(".pj-day.pj-drop-over").forEach(function (d) { d.classList.remove("pj-drop-over"); });
}
async function moveTaskTo(taskId, dropDate) {
const t = tasks.find(function (x) { return String(x.id) === String(taskId); });
if (!t) return;
const anchor = t.start_date || t.due_date;
if (!anchor) { alert("날짜가 없는 업무는 달력에서 이동할 수 없습니다."); return; }
const delta = dayDiff(anchor, dropDate);
if (delta === 0) return;
const payload = {
start_date: t.start_date ? addDays(t.start_date, delta) : null,
due_date: t.due_date ? addDays(t.due_date, delta) : null,
};
try {
await api("PUT", "/project/api/tasks/" + taskId, payload);
location.reload();
} catch (err) { alert("이동 실패: " + err.message); }
}
function wireCalendar() {
if (calendarWired) return;
calendarWired = true;
const cal = document.querySelector(".pj-cal");
document.querySelectorAll(".pj-bar[data-task-id]").forEach(function (bar) {
bar.addEventListener("click", function (e) {
e.preventDefault();
if (!canManage) return;
const t = tasks.find(function (x) { return String(x.id) === bar.dataset.taskId; });
if (t) openTaskModal(t);
});
if (canManage) {
bar.draggable = true;
bar.addEventListener("dragstart", function (e) {
dragTaskId = bar.dataset.taskId;
e.dataTransfer.effectAllowed = "move";
try { e.dataTransfer.setData("text/plain", bar.dataset.taskId); } catch (_) {}
if (cal) cal.classList.add("pj-cal-dragging");
});
bar.addEventListener("dragend", function () {
dragTaskId = null;
if (cal) cal.classList.remove("pj-cal-dragging");
clearDropHL();
});
}
});
if (!canManage) return;
// 드롭 대상은 주(week) 단위로 받고, 마우스 X 로 어느 날짜 칸인지 계산.
// (bar 오버레이/pointer-events 간섭 없이 안정적으로 드롭됨)
document.querySelectorAll(".pj-week").forEach(function (week) {
week.addEventListener("dragover", function (e) {
if (!dragTaskId) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
const hit = dateAtPointer(week, e.clientX);
if (hit) { clearDropHL(); hit.cell.classList.add("pj-drop-over"); }
});
week.addEventListener("drop", function (e) {
e.preventDefault();
const taskId = dragTaskId || e.dataTransfer.getData("text/plain");
clearDropHL();
if (!taskId) return;
const hit = dateAtPointer(week, e.clientX);
if (hit) moveTaskTo(taskId, hit.date);
});
});
}
// ── 타임라인 (vis-timeline) ──
function renderTimeline() {
const node = el("pj-timeline");
const items = tasks.filter(function (t) { return t.start_date || t.due_date; })
.map(function (t) {
const s = t.start_date || t.due_date;
const e = t.due_date || t.start_date;
return {
id: t.id,
content: t.title,
start: s,
end: e !== s ? e : undefined,
type: e !== s ? "range" : "point",
style: "background-color:" + (t.completed_at ? "#9aa5b1" : (t.project_color || "#4573d2")) + ";color:#fff;border:none;",
};
});
if (timeline) { timeline.destroy(); timeline = null; }
if (!items.length) { node.innerHTML = '<div class="pj-empty">기간이 설정된 업무가 없습니다.</div>'; return; }
timeline = new vis.Timeline(node, new vis.DataSet(items), {
locale: "ko", stack: true, height: "520px", zoomKey: "ctrlKey",
margin: { item: 8 },
});
timeline.on("select", function (props) {
if (!canManage || !props.items.length) return;
const t = tasks.find(function (x) { return x.id === props.items[0]; });
if (t) openTaskModal(t);
});
}
// ── 보드 (칸반) + 단계 편집 ──
function renderBoard() {
const board = el("pj-board");
board.innerHTML = "";
stages.forEach(function (stage, idx) {
const col = document.createElement("div");
col.className = "pj-col";
col.dataset.stageId = stage.id;
let controls = "";
if (canManage) {
controls =
'<span class="pj-col-ctrls">' +
'<button type="button" class="pj-icon-btn pj-st-move" data-dir="-1" title="왼쪽으로"' + (idx === 0 ? " disabled" : "") + '></button>' +
'<button type="button" class="pj-icon-btn pj-st-move" data-dir="1" title="오른쪽으로"' + (idx === stages.length - 1 ? " disabled" : "") + '></button>' +
'<button type="button" class="pj-icon-btn pj-st-rename" title="이름 변경">✎</button>' +
'<button type="button" class="pj-icon-btn pj-st-done" title="완료단계 토글">✓</button>' +
'<button type="button" class="pj-icon-btn pj-st-del" title="단계 삭제">×</button>' +
"</span>";
}
col.innerHTML =
'<div class="pj-col-head"><span class="pj-col-name">' + escapeHtml(stage.name) + "</span>" +
(stage.is_done_stage ? ' <span class="pj-chip pj-chip-sm">완료</span>' : "") +
'<span class="pj-col-count"></span>' + controls + "</div>" +
'<div class="pj-col-body"></div>';
const body = col.querySelector(".pj-col-body");
const colTasks = tasks.filter(function (t) { return t.stage_id === stage.id; });
col.querySelector(".pj-col-count").textContent = colTasks.length;
colTasks.forEach(function (t) { body.appendChild(taskCard(t)); });
if (canManage) {
enableDrop(body, stage.id);
wireStageControls(col, stage, idx);
}
board.appendChild(col);
});
if (canManage) {
const add = document.createElement("div");
add.className = "pj-col pj-col-add";
add.innerHTML = '<button type="button" class="pj-add-stage" id="pj-add-stage">+ 단계 추가</button>';
add.querySelector("#pj-add-stage").addEventListener("click", addStage);
board.appendChild(add);
}
}
async function reloadStages() {
const res = await api("GET", "/project/api/projects/" + projectId + "/stages");
stages = res.stages;
}
function wireStageControls(col, stage, idx) {
col.querySelectorAll(".pj-st-move").forEach(function (b) {
b.addEventListener("click", async function () {
const dir = parseInt(b.dataset.dir, 10);
const j = idx + dir;
if (j < 0 || j >= stages.length) return;
const ids = stages.map(function (s) { return s.id; });
const tmp = ids[idx]; ids[idx] = ids[j]; ids[j] = tmp;
try {
const res = await api("PUT", "/project/api/projects/" + projectId + "/stages/order", { ordered_ids: ids });
stages = res.stages; renderBoard();
} catch (e) { alert("이동 실패: " + e.message); }
});
});
col.querySelector(".pj-st-rename").addEventListener("click", async function () {
const name = prompt("단계 이름", stage.name);
if (!name || !name.trim()) return;
try { await api("PUT", "/project/api/projects/" + projectId + "/stages/" + stage.id, { name: name.trim() }); await reloadStages(); renderBoard(); }
catch (e) { alert("변경 실패: " + e.message); }
});
col.querySelector(".pj-st-done").addEventListener("click", async function () {
try { await api("PUT", "/project/api/projects/" + projectId + "/stages/" + stage.id, { is_done_stage: !stage.is_done_stage }); await reloadStages(); renderBoard(); }
catch (e) { alert("변경 실패: " + e.message); }
});
col.querySelector(".pj-st-del").addEventListener("click", async function () {
if (!confirm("이 단계를 삭제할까요? (이 단계의 업무는 단계 미지정으로 남습니다)")) return;
try { await api("DELETE", "/project/api/projects/" + projectId + "/stages/" + stage.id); await reloadStages(); renderBoard(); }
catch (e) { alert("삭제 실패: " + e.message); }
});
}
async function addStage() {
const name = prompt("새 단계 이름");
if (!name || !name.trim()) return;
try { await api("POST", "/project/api/projects/" + projectId + "/stages", { name: name.trim() }); await reloadStages(); renderBoard(); }
catch (e) { alert("추가 실패: " + e.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;
card.innerHTML =
'<div class="pj-card-title">' + escapeHtml(t.title) + "</div>" +
'<div class="pj-card-meta">' +
(t.priority && t.priority !== "normal"
? '<span class="pj-pri pj-pri-' + t.priority + '">' + (priorityLabels[t.priority] || t.priority) + "</span>"
: "") +
(t.assignee_email
? '<span class="pj-assignee"><span class="material-symbols-outlined pj-gicon pj-gicon-sm">account_circle</span>' +
escapeHtml(idOf(t.assignee_email)) + "</span>"
: "") +
(t.due_date ? '<span class="pj-card-due">' + t.due_date + "</span>" : "") +
"</div>";
card.addEventListener("click", function () { if (canManage) openTaskModal(t); });
if (canManage) {
card.addEventListener("dragstart", function (e) {
e.dataTransfer.setData("text/plain", String(t.id));
card.classList.add("is-dragging");
});
card.addEventListener("dragend", function () { card.classList.remove("is-dragging"); });
}
return card;
}
function enableDrop(body, stageId) {
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_id === stageId) return;
try {
const res = await api("PUT", "/project/api/tasks/" + taskId, { stage_id: stageId });
mergeTask(res.task);
renderBoard();
} catch (err) { alert("이동 실패: " + err.message); }
});
}
// ── 리스트 ──
function renderList() {
const tbody = el("pj-list").querySelector("tbody");
tbody.innerHTML = "";
tasks.forEach(function (t) {
const tr = document.createElement("tr");
if (t.completed_at) tr.className = "is-done";
tr.innerHTML =
"<td>" + escapeHtml(t.title) + "</td>" +
"<td>" + (t.assignee_email ? escapeHtml(idOf(t.assignee_email)) : "-") + "</td>" +
"<td>" + escapeHtml(t.stage_name || "-") + "</td>" +
"<td>" + (priorityLabels[t.priority] || t.priority || "-") + "</td>" +
"<td>" + (t.start_date || "-") + "</td>" +
"<td>" + (t.due_date || "-") + "</td>";
if (canManage) { tr.style.cursor = "pointer"; tr.addEventListener("click", function () { openTaskModal(t); }); }
tbody.appendChild(tr);
});
}
function mergeTask(task) {
const i = tasks.findIndex(function (x) { return x.id === task.id; });
if (i >= 0) tasks[i] = Object.assign({}, tasks[i], task);
else tasks.push(task);
}
function refreshActiveView() {
// 달력은 서버 렌더라 변경 반영 위해 새로고침. 그 외 뷰는 즉시 재렌더.
if (currentView === "calendar") { location.reload(); return; }
renderView(currentView);
}
// ── 업무 모달 ──
const taskModal = el("pj-modal-task");
wireModalClose(taskModal);
let currentTaskId = null;
function openTaskModal(task) {
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 || "") : "";
el("pj-t-assignee").value = task ? (task.assignee_email || "") : "";
el("pj-t-stage").value = task && task.stage_id ? task.stage_id : (stages[0] ? stages[0].id : "");
el("pj-t-priority").value = task ? (task.priority || "normal") : "normal";
el("pj-t-start").value = task ? (task.start_date || "") : "";
el("pj-t-due").value = task ? (task.due_date || "") : "";
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); }
openModal(taskModal);
}
// ── 첨부파일 ──
function fmtSize(n) {
if (n < 1024) return n + " B";
if (n < 1048576) return (n / 1024).toFixed(1) + " KB";
return (n / 1048576).toFixed(1) + " MB";
}
async function loadAttachments(taskId) {
const ul = el("pj-attach-list");
ul.innerHTML = "<li class='pj-empty-sm'>불러오는 중…</li>";
try {
const res = await api("GET", "/project/api/tasks/" + taskId + "/attachments");
if (!res.attachments.length) { ul.innerHTML = "<li class='pj-empty-sm'>없음</li>"; return; }
ul.innerHTML = "";
res.attachments.forEach(function (a) {
const li = document.createElement("li");
li.className = "pj-attach-item";
li.innerHTML =
'<a href="/project/api/attachments/' + a.id + '/download">' + escapeHtml(a.filename) + "</a>" +
'<span class="pj-attach-size">' + fmtSize(a.size_bytes) + "</span>" +
(canManage ? '<button type="button" class="pj-icon-btn pj-attach-del" data-id="' + a.id + '">×</button>' : "");
ul.appendChild(li);
});
ul.querySelectorAll(".pj-attach-del").forEach(function (b) {
b.addEventListener("click", async function () {
if (!confirm("이 첨부를 삭제할까요?")) return;
try { await api("DELETE", "/project/api/attachments/" + b.dataset.id); loadAttachments(taskId); }
catch (e) { alert("삭제 실패: " + e.message); }
});
});
} catch (e) { ul.innerHTML = "<li class='pj-empty-sm'>불러오기 실패</li>"; }
}
const attachInput = el("pj-attach-input");
if (attachInput) attachInput.addEventListener("change", async function () {
if (!currentTaskId || !attachInput.files.length) return;
const fd = new FormData();
fd.append("file", attachInput.files[0]);
try {
const res = await fetch("/project/api/tasks/" + currentTaskId + "/attachments", { method: "POST", body: fd });
if (!res.ok) { let m = res.statusText; try { m = (await res.json()).detail; } catch (_) {} throw new Error(m); }
attachInput.value = "";
loadAttachments(currentTaskId);
} catch (e) { alert("업로드 실패: " + e.message); }
});
// ── 댓글 ──
async function loadComments(taskId) {
const ul = el("pj-comment-list");
ul.innerHTML = "<li class='pj-empty-sm'>불러오는 중…</li>";
try {
const res = await api("GET", "/project/api/tasks/" + taskId + "/comments");
if (!res.comments.length) { ul.innerHTML = "<li class='pj-empty-sm'>댓글 없음</li>"; return; }
ul.innerHTML = "";
res.comments.forEach(function (c) {
const li = document.createElement("li");
li.className = "pj-comment-item";
li.innerHTML =
'<span class="material-symbols-outlined pj-gicon pj-gicon-sm">account_circle</span>' +
'<div class="pj-comment-main"><div class="pj-comment-head">' +
escapeHtml(idOf(c.author_email)) + ' <span class="pj-comment-time">' +
(c.created_at || "").slice(0, 16).replace("T", " ") + "</span></div>" +
'<div class="pj-comment-body"></div></div>' +
'<span class="pj-comment-acts">' +
'<button type="button" class="pj-icon-btn pj-comment-edit" title="수정">✎</button>' +
'<button type="button" class="pj-icon-btn pj-comment-del" title="삭제">×</button>' +
"</span>";
li.querySelector(".pj-comment-body").textContent = c.body; // XSS 안전(textContent)
li.querySelector(".pj-comment-del").addEventListener("click", async function () {
try { await api("DELETE", "/project/api/comments/" + c.id); loadComments(taskId); }
catch (e) { alert("삭제 실패: " + e.message); }
});
li.querySelector(".pj-comment-edit").addEventListener("click", function () {
editComment(li, c, taskId);
});
ul.appendChild(li);
});
} catch (e) { ul.innerHTML = "<li class='pj-empty-sm'>불러오기 실패</li>"; }
}
// 댓글 인라인 수정 — 본문을 textarea 로 교체, 저장/취소.
function editComment(li, c, taskId) {
const main = li.querySelector(".pj-comment-main");
const bodyEl = li.querySelector(".pj-comment-body");
const acts = li.querySelector(".pj-comment-acts");
if (li.querySelector(".pj-comment-edit-box")) return; // 중복 방지
acts.style.visibility = "hidden";
const box = document.createElement("div");
box.className = "pj-comment-edit-box";
const ta = document.createElement("textarea");
ta.rows = 2; ta.value = c.body;
const bar = document.createElement("div");
bar.className = "pj-comment-edit-actions";
const save = document.createElement("button");
save.type = "button"; save.className = "pj-btn pj-btn-primary"; save.textContent = "저장";
const cancel = document.createElement("button");
cancel.type = "button"; cancel.className = "pj-btn"; cancel.textContent = "취소";
bar.appendChild(cancel); bar.appendChild(save);
box.appendChild(ta); box.appendChild(bar);
bodyEl.style.display = "none";
main.appendChild(box);
ta.focus();
cancel.addEventListener("click", function () {
box.remove(); bodyEl.style.display = ""; acts.style.visibility = "";
});
save.addEventListener("click", async function () {
const text = ta.value.trim();
if (!text) { alert("내용을 입력하세요."); return; }
try {
await api("PUT", "/project/api/comments/" + c.id, { body: text });
loadComments(taskId);
} catch (e) { alert("수정 실패: " + e.message); }
});
}
async function sendComment() {
const inp = el("pj-comment-input");
const body = inp.value.trim();
if (!currentTaskId || !body) return;
try {
await api("POST", "/project/api/tasks/" + currentTaskId + "/comments", { body: body });
inp.value = "";
loadComments(currentTaskId);
} catch (e) { alert("등록 실패: " + e.message); }
}
el("pj-comment-send").addEventListener("click", sendComment);
el("pj-comment-input").addEventListener("keydown", function (e) {
if (e.key === "Enter") { e.preventDefault(); sendComment(); }
});
const newTaskBtn = el("pj-new-task");
if (newTaskBtn) newTaskBtn.addEventListener("click", function () { openTaskModal(null); });
el("pj-save-task").addEventListener("click", async function () {
const id = el("pj-t-id").value;
const assigneeSel = el("pj-t-assignee");
const assigneeName = assigneeSel.selectedOptions[0] ? (assigneeSel.selectedOptions[0].dataset.name || "") : "";
const payload = {
title: el("pj-t-title").value.trim(),
description: el("pj-t-desc").value.trim(),
assignee_email: assigneeSel.value,
assignee_name: assigneeName,
stage_id: parseInt(el("pj-t-stage").value, 10) || null,
priority: el("pj-t-priority").value,
start_date: el("pj-t-start").value || null,
due_date: el("pj-t-due").value || null,
};
if (!payload.title) { alert("제목을 입력하세요."); return; }
try {
let res;
if (id) res = await api("PUT", "/project/api/tasks/" + id, payload);
else res = await api("POST", "/project/api/projects/" + projectId + "/tasks", payload);
mergeTask(res.task);
closeModal(taskModal);
refreshActiveView();
} catch (e) { alert("저장 실패: " + e.message); }
});
el("pj-delete-task").addEventListener("click", async function () {
const id = el("pj-t-id").value;
if (!id || !confirm("이 업무를 삭제할까요?")) return;
try {
await api("DELETE", "/project/api/tasks/" + id);
tasks = tasks.filter(function (x) { return String(x.id) !== String(id); });
closeModal(taskModal);
refreshActiveView();
} catch (e) { alert("삭제 실패: " + e.message); }
});
// ── 서브프로젝트 추가/삭제 ──
const newSubBtn = el("pj-new-sub");
if (newSubBtn) newSubBtn.addEventListener("click", async function () {
const name = prompt("서브프로젝트 이름");
if (!name || !name.trim()) return;
try {
await api("POST", "/project/api/projects/" + projectId + "/subprojects", { name: name.trim() });
location.reload();
} catch (e) { alert("생성 실패: " + e.message); }
});
document.querySelectorAll(".pj-del-sub").forEach(function (b) {
b.addEventListener("click", async function () {
if (!confirm("이 서브프로젝트를 삭제할까요? (하위 업무도 모두 삭제)")) return;
try { await api("DELETE", "/project/api/projects/" + b.dataset.id); location.reload(); }
catch (e) { alert("삭제 실패: " + e.message); }
});
});
// ── 멤버 배정/제외 (관리자) ──
const memberModal = el("pj-modal-member");
wireModalClose(memberModal);
const addMemberBtn = el("pj-add-member");
if (addMemberBtn) addMemberBtn.addEventListener("click", async function () {
const sel = el("pj-m-user");
openModal(memberModal);
sel.innerHTML = '<option value="">불러오는 중…</option>';
try {
const res = await api("GET", "/project/api/assignable-users");
const existing = new Set(members.map(function (m) { return m.user_email; }));
const opts = res.users
.filter(function (u) { return !existing.has(u.email); })
.map(function (u) {
return '<option value="' + u.email + '" data-name="' + escapeHtml(u.name) + '">' +
escapeHtml(u.id) + " (" + escapeHtml(u.email) + ")</option>";
});
sel.innerHTML = opts.length
? opts.join("")
: '<option value="">배정 가능한 직원이 없습니다(권한 미부여 또는 전원 배정됨)</option>';
} catch (e) {
sel.innerHTML = '<option value="">불러오기 실패: ' + escapeHtml(e.message) + "</option>";
}
});
const saveMemberBtn = el("pj-save-member");
if (saveMemberBtn) saveMemberBtn.addEventListener("click", async function () {
const sel = el("pj-m-user");
if (!sel.value) { alert("직원을 선택하세요."); return; }
const name = sel.selectedOptions[0] ? (sel.selectedOptions[0].dataset.name || "") : "";
try {
await api("POST", "/project/api/projects/" + projectId + "/members", {
user_email: sel.value, user_name: name, role: el("pj-m-role").value,
});
location.reload();
} catch (e) { alert("배정 실패: " + e.message); }
});
document.querySelectorAll(".pj-del-member").forEach(function (b) {
b.addEventListener("click", async function () {
if (!confirm("이 멤버를 프로젝트에서 제외할까요?")) return;
try {
await api("DELETE", "/project/api/projects/" + projectId + "/members/" + encodeURIComponent(b.dataset.email));
location.reload();
} catch (e) { alert("제외 실패: " + e.message); }
});
});
// 최초 렌더 — 달력은 서버 렌더, 클릭 핸들러만 연결
wireCalendar();
}
function idOf(email) {
return String(email == null ? "" : email).split("@")[0];
}
function escapeHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
// ── 알림 벨 (미읽음 배지) ──
async function initBell() {
const badge = document.getElementById("pj-bell-badge");
if (!badge) return;
try {
const res = await fetch("/project/api/notifications/unread-count");
if (!res.ok) return;
const d = await res.json();
if (d.unread > 0) { badge.textContent = d.unread > 99 ? "99+" : d.unread; badge.hidden = false; }
} catch (_) {}
}
// ── 알림센터 페이지 ──
function initInbox() {
const inbox = document.getElementById("pj-inbox");
const readAll = document.getElementById("pj-readall");
if (!inbox && !readAll) return;
if (readAll) readAll.addEventListener("click", async function () {
try { await fetch("/project/api/notifications/read-all", { method: "POST" }); location.reload(); }
catch (e) { alert("실패: " + e.message); }
});
if (inbox) inbox.querySelectorAll(".pj-inbox-item").forEach(function (li) {
li.addEventListener("click", async function (e) {
if (e.target.closest("a")) return; // '이동' 링크는 통과
if (!li.classList.contains("is-unread")) return;
try {
await fetch("/project/api/notifications/" + li.dataset.id + "/read", { method: "POST" });
li.classList.remove("is-unread");
} catch (_) {}
});
});
}
document.addEventListener("DOMContentLoaded", function () {
initHome();
initProject();
initBell();
initInbox();
});
})();