feat(project): 댓글·첨부·단계편집·알림센터 + 라이브러리 self-host
아사나식 기능 4종 추가: - 댓글: 업무 모달에서 등록/삭제, 관련자 인앱 알림(task_comments) - 첨부: 업로드(20MB)/다운로드/삭제, 파일은 DATA_DIR/project/<task_id>/, DB엔 메타만(task_attachments) - 단계 편집: 보드 칸반에서 이름변경/완료토글/순서이동/삭제/추가 - 알림센터(인앱): 배정·완료·댓글 시 수신자별 알림, 벨 미읽음 배지, /project/inbox 페이지, 읽음/모두읽음(project_notifications) 라이브러리 self-host: vis-timeline + Material Symbols 를 static/vendor/ 로 내려받아 CDN 의존 제거. DB: scripts/sql/project_db_002_attachments_notifications.sql (서버서 적용 필요). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -159,3 +159,53 @@
|
||||
.pj-color-palette, #pj-f-colors { display: flex; gap: 8px; margin-top: 6px; }
|
||||
.pj-color-swatch { width: 24px; height: 24px; border-radius: 50%; border: 2px solid transparent; cursor: pointer; }
|
||||
.pj-color-swatch.is-active { border-color: #1e1f21; box-shadow: 0 0 0 2px #fff inset; }
|
||||
|
||||
/* ── 알림 벨 ── */
|
||||
.pj-toolbar-right { display: flex; align-items: center; gap: 12px; }
|
||||
.pj-bell { position: relative; display: inline-flex; align-items: center; justify-content: center; width: 38px; height: 38px; border-radius: 10px; color: #525860; text-decoration: none; }
|
||||
.pj-bell:hover { background: #eceef0; }
|
||||
.pj-bell-badge { position: absolute; top: 3px; right: 3px; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 999px; background: #e8384f; color: #fff; font-size: 10px; font-weight: 700; line-height: 16px; text-align: center; }
|
||||
|
||||
/* ── 업무 모달 댓글/첨부 ── */
|
||||
.pj-task-extra { border-top: 1px solid #eef0f2; margin-top: 14px; padding-top: 12px; }
|
||||
.pj-extra-head { display: flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 700; color: #525860; margin-bottom: 8px; }
|
||||
.pj-upload-btn { margin-left: auto; font-size: 12px; font-weight: 600; color: #4573d2; cursor: pointer; padding: 3px 8px; border: 1px solid #cdd9f0; border-radius: 7px; }
|
||||
.pj-upload-btn:hover { background: #eef3fc; }
|
||||
|
||||
.pj-attach-list, .pj-comment-list { list-style: none; margin: 0 0 6px; padding: 0; max-height: 160px; overflow-y: auto; }
|
||||
.pj-attach-item { display: flex; align-items: center; gap: 8px; padding: 5px 4px; font-size: 13px; }
|
||||
.pj-attach-item a { color: #2b4a85; text-decoration: none; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.pj-attach-item a:hover { text-decoration: underline; }
|
||||
.pj-attach-size { font-size: 11px; color: #9aa1a9; }
|
||||
|
||||
.pj-comment-item { display: flex; align-items: flex-start; gap: 8px; padding: 7px 2px; }
|
||||
.pj-comment-main { flex: 1; }
|
||||
.pj-comment-head { font-size: 12.5px; font-weight: 700; color: #1e1f21; }
|
||||
.pj-comment-time { font-weight: 400; color: #9aa1a9; font-size: 11px; margin-left: 6px; }
|
||||
.pj-comment-body { font-size: 13px; color: #333; margin-top: 1px; white-space: pre-wrap; }
|
||||
.pj-comment-form { display: flex; gap: 6px; }
|
||||
.pj-comment-form input { flex: 1; padding: 7px 10px; border: 1px solid #d8dce2; border-radius: 8px; font-size: 13px; font-family: inherit; }
|
||||
|
||||
/* ── 보드 단계 컨트롤 ── */
|
||||
.pj-col-head { position: relative; }
|
||||
.pj-col-name { font-weight: 700; }
|
||||
.pj-col-ctrls { display: none; gap: 1px; margin-left: 6px; }
|
||||
.pj-col:hover .pj-col-ctrls { display: inline-flex; }
|
||||
.pj-col-ctrls .pj-icon-btn { width: 20px; height: 20px; font-size: 13px; }
|
||||
.pj-icon-btn[disabled] { opacity: .3; cursor: default; }
|
||||
.pj-col-add { background: transparent; flex: 0 0 180px; }
|
||||
.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-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; }
|
||||
.pj-inbox-item:hover { border-color: #d8dce2; }
|
||||
.pj-inbox-item.is-unread { background: #f4f8ff; border-color: #d6e2fb; }
|
||||
.pj-inbox-icon { color: #4573d2; font-size: 22px; flex: 0 0 auto; }
|
||||
.pj-inbox-body { flex: 1; }
|
||||
.pj-inbox-title { font-size: 14px; font-weight: 600; }
|
||||
.pj-inbox-item.is-unread .pj-inbox-title::before { content: "●"; color: #4573d2; font-size: 9px; vertical-align: middle; margin-right: 6px; }
|
||||
.pj-inbox-sub { font-size: 12.5px; color: #525860; margin-top: 2px; }
|
||||
.pj-inbox-meta { font-size: 11px; color: #9aa1a9; margin-top: 4px; }
|
||||
.pj-inbox-go { align-self: center; padding: 4px 12px; font-size: 12px; }
|
||||
|
||||
+197
-5
@@ -171,26 +171,90 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ── 보드 (칸반) ──
|
||||
// ── 보드 (칸반) + 단계 편집 ──
|
||||
function renderBoard() {
|
||||
const board = el("pj-board");
|
||||
board.innerHTML = "";
|
||||
stages.forEach(function (stage) {
|
||||
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">' + escapeHtml(stage.name) +
|
||||
'<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></div>' +
|
||||
'<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);
|
||||
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) {
|
||||
@@ -272,6 +336,7 @@
|
||||
// ── 업무 모달 ──
|
||||
const taskModal = el("pj-modal-task");
|
||||
wireModalClose(taskModal);
|
||||
let currentTaskId = null;
|
||||
|
||||
function openTaskModal(task) {
|
||||
el("pj-task-modal-title").textContent = task ? "업무 편집" : "새 업무";
|
||||
@@ -284,9 +349,101 @@
|
||||
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">' + escapeHtml(c.body) + "</div></div>" +
|
||||
'<button type="button" class="pj-icon-btn pj-comment-del" data-id="' + c.id + '">×</button>';
|
||||
ul.appendChild(li);
|
||||
});
|
||||
ul.querySelectorAll(".pj-comment-del").forEach(function (b) {
|
||||
b.addEventListener("click", async function () {
|
||||
try { await api("DELETE", "/project/api/comments/" + b.dataset.id); loadComments(taskId); }
|
||||
catch (e) { alert("삭제 실패: " + e.message); }
|
||||
});
|
||||
});
|
||||
} catch (e) { ul.innerHTML = "<li class='pj-empty-sm'>불러오기 실패</li>"; }
|
||||
}
|
||||
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); });
|
||||
|
||||
@@ -404,8 +561,43 @@
|
||||
.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// ── 알림 벨 (미읽음 배지) ──
|
||||
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();
|
||||
});
|
||||
})();
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,26 @@
|
||||
/* Material Symbols Outlined — self-host (구글 CDN 대체).
|
||||
원본: https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined
|
||||
폰트 파일은 같은 폴더 material-symbols-outlined.woff2. */
|
||||
@font-face {
|
||||
font-family: 'Material Symbols Outlined';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url(material-symbols-outlined.woff2) format('woff2');
|
||||
}
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-family: 'Material Symbols Outlined';
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
word-wrap: normal;
|
||||
direction: ltr;
|
||||
-webkit-font-feature-settings: 'liga';
|
||||
-webkit-font-smoothing: antialiased;
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user