feat(project): 아사나식 프로젝트 관리 모듈 추가
- project_db 신규(projects/members/stages/tasks/comments/activity, project_app CRUD) - 프로젝트/서브프로젝트(self-FK), 업무·진행단계(칸반), 멤버 배정 - 메인 뷰 달력(FullCalendar)/타임라인(vis-timeline) 토글 + 보드/리스트 - 진입은 로그인 회사 직원 전원, 생성/배정은 관리자 전용 - 업무 배정·완료 시 관리자 메일 알림(app/mail.py, SMTP_* env, 미설정 시 skip) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,399 @@
|
||||
/* 프로젝트 관리(아사나식) 모듈 — 프론트엔드.
|
||||
* 두 페이지에서 공용:
|
||||
* - 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 calendar = null;
|
||||
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") renderCalendar();
|
||||
else if (view === "timeline") renderTimeline();
|
||||
else if (view === "board") renderBoard();
|
||||
else if (view === "list") renderList();
|
||||
}
|
||||
|
||||
// ── 달력 (FullCalendar) ──
|
||||
function taskEvents() {
|
||||
return tasks.filter(function (t) { return t.due_date || t.start_date; })
|
||||
.map(function (t) {
|
||||
const start = t.start_date || t.due_date;
|
||||
// FullCalendar end 는 배타적 → 마감일 포함 위해 +1일은 생략(단일일 표시)
|
||||
return {
|
||||
id: String(t.id),
|
||||
title: t.title,
|
||||
start: start,
|
||||
end: t.due_date && t.due_date !== start ? t.due_date : undefined,
|
||||
backgroundColor: t.completed_at ? "#9aa5b1" : (t.project_color || "#4573d2"),
|
||||
borderColor: "transparent",
|
||||
extendedProps: { task: t },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function renderCalendar() {
|
||||
const node = el("pj-calendar");
|
||||
if (calendar) { calendar.removeAllEvents(); calendar.addEventSource(taskEvents()); calendar.render(); return; }
|
||||
calendar = new FullCalendar.Calendar(node, {
|
||||
initialView: "dayGridMonth",
|
||||
locale: "ko",
|
||||
height: "auto",
|
||||
headerToolbar: { left: "prev,next today", center: "title", right: "dayGridMonth,dayGridWeek" },
|
||||
events: taskEvents(),
|
||||
eventClick: function (info) {
|
||||
info.jsEvent.preventDefault();
|
||||
if (canManage) openTaskModal(info.event.extendedProps.task);
|
||||
},
|
||||
});
|
||||
calendar.render();
|
||||
}
|
||||
|
||||
// ── 타임라인 (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) {
|
||||
const col = document.createElement("div");
|
||||
col.className = "pj-col";
|
||||
col.dataset.stageId = stage.id;
|
||||
col.innerHTML =
|
||||
'<div class="pj-col-head">' + escapeHtml(stage.name) +
|
||||
(stage.is_done_stage ? ' <span class="pj-chip pj-chip-sm">완료</span>' : "") +
|
||||
'<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) { 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);
|
||||
board.appendChild(col);
|
||||
});
|
||||
}
|
||||
|
||||
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-avatar pj-avatar-sm">' + escapeHtml((t.assignee_name || t.assignee_email)[0].toUpperCase()) + "</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>" + escapeHtml(t.assignee_name || 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" && calendar) { calendar.removeAllEvents(); calendar.addEventSource(taskEvents()); }
|
||||
else renderView(currentView);
|
||||
}
|
||||
|
||||
// ── 업무 모달 ──
|
||||
const taskModal = el("pj-modal-task");
|
||||
wireModalClose(taskModal);
|
||||
|
||||
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;
|
||||
openModal(taskModal);
|
||||
}
|
||||
|
||||
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 addMemberBtn = el("pj-add-member");
|
||||
if (addMemberBtn) addMemberBtn.addEventListener("click", async function () {
|
||||
const email = prompt("배정할 직원 이메일 (@dbxcorp.co.kr)");
|
||||
if (!email || !email.trim()) return;
|
||||
try {
|
||||
await api("POST", "/project/api/projects/" + projectId + "/members", {
|
||||
user_email: email.trim(), role: "member",
|
||||
});
|
||||
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); }
|
||||
});
|
||||
});
|
||||
|
||||
// 최초 렌더
|
||||
renderCalendar();
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
initHome();
|
||||
initProject();
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user