d60ae9a210
담당자 표시를 이메일 아이디(@ 앞)로 통일. 아바타를 letter 원형에서 구글 Material Symbols account_circle 아이콘으로 교체(멤버 목록·보드 카드· 모달 선택지·리스트 뷰). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
388 lines
16 KiB
JavaScript
388 lines
16 KiB
JavaScript
/* 프로젝트 관리(아사나식) 모듈 — 프론트엔드.
|
|
* 두 페이지에서 공용:
|
|
* - 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;
|
|
function wireCalendar() {
|
|
if (calendarWired) return;
|
|
calendarWired = true;
|
|
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);
|
|
});
|
|
});
|
|
}
|
|
|
|
// ── 타임라인 (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-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);
|
|
|
|
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); }
|
|
});
|
|
});
|
|
|
|
// 최초 렌더 — 달력은 서버 렌더, 클릭 핸들러만 연결
|
|
wireCalendar();
|
|
}
|
|
|
|
function idOf(email) {
|
|
return String(email == null ? "" : email).split("@")[0];
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s == null ? "" : s)
|
|
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
.replace(/"/g, """).replace(/'/g, "'");
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", function () {
|
|
initHome();
|
|
initProject();
|
|
});
|
|
})();
|