diff --git a/app/modules/project/templates/project/inbox.html b/app/modules/project/templates/project/inbox.html
index 0795d30..b78024d 100644
--- a/app/modules/project/templates/project/inbox.html
+++ b/app/modules/project/templates/project/inbox.html
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
-
+
{% endblock %}
@@ -45,5 +45,5 @@
{% endif %}
-
+
{% endblock %}
diff --git a/app/modules/project/templates/project/index.html b/app/modules/project/templates/project/index.html
index 10d282c..83d0567 100644
--- a/app/modules/project/templates/project/index.html
+++ b/app/modules/project/templates/project/index.html
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
-
+
{% endblock %}
@@ -393,5 +393,5 @@
-
+
{% endblock %}
diff --git a/app/modules/project/templates/project/project.html b/app/modules/project/templates/project/project.html
index d96ed96..d7060ec 100644
--- a/app/modules/project/templates/project/project.html
+++ b/app/modules/project/templates/project/project.html
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
-
+
@@ -438,5 +438,5 @@
-
+
{% endblock %}
diff --git a/app/static/project.css b/app/static/project.css
index 5ea68f9..989d3e3 100644
--- a/app/static/project.css
+++ b/app/static/project.css
@@ -701,3 +701,21 @@ label.pj-desc-fmtbtn { line-height: 26px; }
.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; }
+
+/* ── 우클릭 컨텍스트 메뉴 — 클릭 좌표 기준 고정 위치(.pj-daypop 은 앵커
+ 엘리먼트 기준이라 좌표 배치가 다름). z-index 는 .pj-daypop(2100) 보다
+ 위, .erp-dialog(9999) 보다 아래. ── */
+.pj-ctxmenu {
+ position: fixed; z-index: 2200; min-width: 180px;
+ background: #fff; border: 1px solid var(--pj-border);
+ border-radius: 10px; box-shadow: var(--pj-card-shadow-hover); padding: 6px;
+}
+.pj-ctxmenu-item {
+ display: flex; align-items: center; gap: 8px; width: 100%; text-align: left;
+ border: none; background: transparent; padding: 8px 10px; border-radius: 7px;
+ font-size: 13px; color: var(--pj-ink); cursor: pointer;
+}
+.pj-ctxmenu-item:hover { background: var(--pj-accent-tint); }
+.pj-ctxmenu-item.is-danger { color: #c22b10; }
+.pj-ctxmenu-item.is-danger:hover { background: #fbecea; }
+.pj-ctxmenu-sep { height: 1px; background: var(--pj-border); margin: 4px 2px; }
diff --git a/app/static/project.js b/app/static/project.js
index f21d456..f86013b 100644
--- a/app/static/project.js
+++ b/app/static/project.js
@@ -23,13 +23,7 @@
function el(id) { return document.getElementById(id); }
function openModal(m) { if (m) m.hidden = false; }
- // 실시간 새로고침 대기 중이던 변경사항 — 모든 모달이 닫힌 시점에 반영(입력 중 방해 방지).
- let pjPendingReload = false;
- function anyModalOpen() { return !!document.querySelector(".pj-modal:not([hidden])"); }
- function closeModal(m) {
- if (m) m.hidden = true;
- if (pjPendingReload && !anyModalOpen()) location.reload();
- }
+ function closeModal(m) { if (m) m.hidden = true; }
function wireModalClose(modal) {
if (!modal) return;
@@ -38,6 +32,88 @@
});
}
+ // ── 토스트 알림 — alert() 대신 쓰는 비차단 피드백 ──
+ let toastEl = null, toastTimer = null;
+ function showToast(msg, type) {
+ if (!toastEl) {
+ toastEl = document.createElement("div");
+ toastEl.className = "erp-toast";
+ toastEl.setAttribute("role", "status");
+ toastEl.setAttribute("aria-live", "polite");
+ document.body.appendChild(toastEl);
+ }
+ toastEl.textContent = msg;
+ toastEl.dataset.type = type || "info";
+ toastEl.dataset.visible = "true";
+ clearTimeout(toastTimer);
+ toastTimer = setTimeout(function () { toastEl.dataset.visible = "false"; }, 2400);
+ }
+
+ // ── 우클릭 컨텍스트 메뉴 — 하나만 열리는 공용 컴포넌트.
+ // openDayPopover(아래)의 위치잡기/외부클릭 닫기 방식을 그대로 따르되, 앵커
+ // 엘리먼트 대신 클릭 좌표(clientX/Y) 기준으로 뷰포트에 클램프한다.
+ let ctxMenuEl = null;
+ function closeContextMenu() {
+ if (!ctxMenuEl) return;
+ ctxMenuEl.remove();
+ ctxMenuEl = null;
+ document.removeEventListener("mousedown", onCtxDocDown, true);
+ document.removeEventListener("keydown", onCtxKeyDown, true);
+ window.removeEventListener("scroll", closeContextMenu, true);
+ window.removeEventListener("resize", closeContextMenu, true);
+ }
+ function onCtxDocDown(e) {
+ if (ctxMenuEl && !ctxMenuEl.contains(e.target)) closeContextMenu();
+ }
+ function onCtxKeyDown(e) {
+ if (e.key === "Escape") closeContextMenu();
+ }
+ // items: [{label, danger?, onClick} | null(구분선)]
+ function showContextMenu(x, y, items) {
+ closeContextMenu();
+ const menu = document.createElement("div");
+ menu.className = "pj-ctxmenu";
+ items.forEach(function (item) {
+ if (!item) {
+ const sep = document.createElement("div");
+ sep.className = "pj-ctxmenu-sep";
+ menu.appendChild(sep);
+ return;
+ }
+ const b = document.createElement("button");
+ b.type = "button";
+ b.className = "pj-ctxmenu-item" + (item.danger ? " is-danger" : "");
+ b.textContent = item.label;
+ b.addEventListener("click", function () { closeContextMenu(); item.onClick(); });
+ menu.appendChild(b);
+ });
+ document.body.appendChild(menu);
+ ctxMenuEl = menu;
+ let left = window.scrollX + x;
+ let top = window.scrollY + y;
+ left = Math.min(left, window.scrollX + document.documentElement.clientWidth - menu.offsetWidth - 8);
+ top = Math.min(top, window.scrollY + document.documentElement.clientHeight - menu.offsetHeight - 8);
+ menu.style.left = Math.max(8, left) + "px";
+ menu.style.top = Math.max(8, top) + "px";
+ setTimeout(function () {
+ document.addEventListener("mousedown", onCtxDocDown, true);
+ document.addEventListener("keydown", onCtxKeyDown, true);
+ window.addEventListener("scroll", closeContextMenu, true);
+ window.addEventListener("resize", closeContextMenu, true);
+ }, 0);
+ }
+
+ // ── 날짜 표기 'YYYY-MM-DD' → 'mm/dd(요일)' — 서버 _fmt_date_kr 과 동일 규칙 ──
+ function fmtDateKr(dstr) {
+ if (!dstr) return "";
+ const d = new Date(String(dstr).slice(0, 10) + "T00:00:00");
+ if (isNaN(d.getTime())) return dstr;
+ const mm = String(d.getMonth() + 1).padStart(2, "0");
+ const dd = String(d.getDate()).padStart(2, "0");
+ const wd = ["월", "화", "수", "목", "금", "토", "일"][(d.getDay() + 6) % 7];
+ return mm + "/" + dd + "(" + wd + ")";
+ }
+
// ── 색상 팔레트 렌더 ───────────────────────────────────────
function renderColorPalette(container, onPick, initial) {
const colors = window.PJ_COLORS || ["#4573d2"];
@@ -78,6 +154,288 @@
return !!cb && cb === me;
}
+ // 나중에 정의되는(블록 스코프) 모달 오픈 함수를 우클릭 메뉴 등 바깥에서도
+ // 부를 수 있도록 참조만 미리 선언해둔다(정의되는 즉시 아래서 채워짐).
+ let openNewTaskModalRef = null;
+ let openHomeTaskModalRef = null;
+ let openCommentsModalRef = null;
+
+ // ── 프로젝트 카드 수정/삭제 (카드 ✎ 버튼·우클릭 메뉴 공용) ──
+ const editModal = el("pj-modal-edit");
+ let getEditColor = null;
+ function openCardEditModal(card) {
+ el("pj-e-id").value = card.dataset.id;
+ el("pj-e-name").value = card.dataset.name || "";
+ el("pj-e-desc").value = card.dataset.desc || "";
+ el("pj-e-start").value = card.dataset.start || "";
+ el("pj-e-due").value = card.dataset.due || "";
+ el("pj-e-done").checked = card.dataset.status === "completed";
+ getEditColor = renderColorPalette(el("pj-e-colors"), null, card.dataset.color);
+ openModal(editModal);
+ }
+ async function deleteProjectCard(card) {
+ if (!canDelete(card.dataset.creator)) { alert("삭제는 만든 사람만 할 수 있습니다."); return; }
+ if (!confirm("이 프로젝트를 삭제할까요? 안의 업무도 모두 삭제됩니다.")) return;
+ try {
+ await api("DELETE", "/project/api/projects/" + card.dataset.id);
+ card.remove();
+ } catch (e) { showToast("삭제 실패: " + e.message, "error"); }
+ }
+ // 프로젝트 수정 저장 결과(row)를 기존 카드 DOM에 그대로 반영 — 새로고침 없음.
+ function patchProjectCardDom(card, p) {
+ card.dataset.name = p.name || "";
+ card.dataset.desc = p.description || "";
+ card.dataset.start = p.start_date || "";
+ card.dataset.due = p.due_date || "";
+ card.dataset.color = p.color || "#4573d2";
+ card.dataset.status = p.status || "active";
+ card.style.setProperty("--pj-color", p.color || "#4573d2");
+ card.classList.toggle("is-done", p.status === "completed");
+ const nameEl = card.querySelector(".pj-project-name");
+ if (nameEl) {
+ if (nameEl.firstChild && nameEl.firstChild.nodeType === Node.TEXT_NODE) {
+ nameEl.firstChild.textContent = p.name || "";
+ } else {
+ nameEl.insertBefore(document.createTextNode(p.name || ""), nameEl.firstChild);
+ }
+ let doneChip = nameEl.querySelector(".pj-chip");
+ if (p.status === "completed") {
+ if (!doneChip) {
+ doneChip = document.createElement("span");
+ doneChip.className = "pj-chip pj-chip-sm";
+ doneChip.textContent = "완료";
+ nameEl.appendChild(doneChip);
+ }
+ } else if (doneChip) doneChip.remove();
+ }
+ let descEl = card.querySelector(".pj-project-desc");
+ if (p.description) {
+ if (!descEl) {
+ descEl = document.createElement("div");
+ descEl.className = "pj-project-desc";
+ if (nameEl) nameEl.insertAdjacentElement("afterend", descEl);
+ }
+ descEl.textContent = p.description;
+ } else if (descEl) descEl.remove();
+ const meta = card.querySelector(".pj-project-meta");
+ if (meta) {
+ let dueChip = null;
+ meta.querySelectorAll(".pj-chip").forEach(function (c) {
+ if (c.textContent.indexOf("마감") === 0) dueChip = c;
+ });
+ if (p.due_date) {
+ const label = "마감 " + fmtDateKr(p.due_date);
+ if (dueChip) dueChip.textContent = label;
+ else {
+ dueChip = document.createElement("span");
+ dueChip.className = "pj-chip";
+ dueChip.textContent = label;
+ meta.insertBefore(dueChip, meta.firstChild);
+ }
+ } else if (dueChip) dueChip.remove();
+ }
+ // 진행중 ↔ 완료 그리드 이동. 완료 그리드가 화면에 아직 없으면(첫 완료
+ // 프로젝트) 다음 새로고침까지 원래 위치에 남는다 — 드문 경우라 허용.
+ const targetGrid = el(p.status === "completed" ? "pj-project-grid-done" : "pj-project-grid");
+ if (targetGrid && card.parentElement !== targetGrid) targetGrid.appendChild(card);
+ }
+ // 새 프로젝트 카드 DOM — project_card(p) 매크로(index.html)를 그대로 미러링.
+ // 방금 만든 프로젝트라 멤버/업무는 아직 없다(멤버는 생성 직후 별도 배정,
+ // 업무는 카드의 "업무 추가"로 나중에). 만든 사람은 자동 owner 라 관리 가능.
+ function projectCardHtml(p) {
+ const color = p.color || "#4573d2";
+ const html =
+ '
';
+ const wrap = document.createElement("div");
+ wrap.innerHTML = html;
+ return wrap.firstElementChild;
+ }
+ // 카드 하나에 수정/업무추가/우클릭 메뉴를 건다 — 최초 로드된 카드·새로
+ // 만든 카드 모두 이 함수 하나로 통일(initHome 맨 끝에서 전체 카드에 호출).
+ function wireProjectCard(card) {
+ const editBtn = card.querySelector(".pj-card-edit");
+ if (editBtn) editBtn.addEventListener("click", function (e) {
+ e.preventDefault(); e.stopPropagation();
+ openCardEditModal(card);
+ });
+ const addBtn = card.querySelector(".pj-card-addtask");
+ if (addBtn) addBtn.addEventListener("click", function (e) {
+ e.preventDefault(); e.stopPropagation();
+ if (openNewTaskModalRef) openNewTaskModalRef(card.dataset.id, card.dataset.name);
+ });
+ card.addEventListener("contextmenu", function (e) {
+ if (!card.querySelector(".pj-card-edit")) return; // 관리 권한 없는 카드는 메뉴 없음
+ e.preventDefault();
+ showContextMenu(e.clientX, e.clientY, [
+ { label: "열기", onClick: function () { location.href = "/project/p/" + card.dataset.id; } },
+ { label: "업무 추가", onClick: function () { if (openNewTaskModalRef) openNewTaskModalRef(card.dataset.id, card.dataset.name); } },
+ { label: "수정", onClick: function () { openCardEditModal(card); } },
+ null,
+ { label: "삭제", danger: true, onClick: function () { deleteProjectCard(card); } },
+ ]);
+ });
+ }
+
+ // ── 홈 카드 안 업무 리스트 patch (서버 재조회 없이) ──
+ function homeTaskRowHtml(t) {
+ const assigneeDisplay = t.assignee_name || (t.assignee_email ? idOf(t.assignee_email) : "");
+ const today = new Date().toISOString().slice(0, 10);
+ const overdue = !!(t.due_date && !t.completed_at && String(t.due_date).slice(0, 10) < today);
+ return '' +
+ '";
+ }
+ function updateHomeCardProgress(projectId) {
+ const card = document.querySelector('.pj-project-card[data-id="' + projectId + '"]');
+ if (!card) return;
+ const total = card.querySelectorAll(".pj-project-taskrow").length;
+ const done = card.querySelectorAll(".pj-project-taskrow.is-done").length;
+ const meta = card.querySelector(".pj-project-meta");
+ if (meta) {
+ let chip = null;
+ meta.querySelectorAll(".pj-chip").forEach(function (c) { if (c.textContent.indexOf("업무") === 0) chip = c; });
+ if (total) {
+ const label = "업무 " + done + "/" + total;
+ if (chip) chip.textContent = label;
+ else { chip = document.createElement("span"); chip.className = "pj-chip"; chip.textContent = label; meta.appendChild(chip); }
+ } else if (chip) chip.remove();
+ }
+ const body = card.querySelector(".pj-project-body");
+ let bar = card.querySelector(".pj-project-progress-bar");
+ if (total) {
+ if (!bar && body) {
+ const w = document.createElement("div");
+ w.className = "pj-project-progress";
+ w.innerHTML = '';
+ body.appendChild(w);
+ bar = w.firstElementChild;
+ }
+ if (bar) bar.style.width = Math.round(done * 100 / total) + "%";
+ } else if (bar) {
+ const prog = bar.closest(".pj-project-progress");
+ if (prog) prog.remove();
+ }
+ }
+ function upsertHomeTaskRow(t) {
+ const card = document.querySelector('.pj-project-card[data-id="' + t.project_id + '"]');
+ if (!card) { syncMyTaskPanel(t); return; }
+ homeTasks[t.id] = t;
+ let list = card.querySelector(".pj-project-tasklist");
+ const emptyMsg = card.querySelector(".pj-project-tasks-empty");
+ if (!list) {
+ list = document.createElement("ul");
+ list.className = "pj-project-tasklist";
+ if (emptyMsg) emptyMsg.replaceWith(list);
+ else { const body = card.querySelector(".pj-project-body"); if (body) body.appendChild(list); }
+ }
+ const wrap = document.createElement("div");
+ wrap.innerHTML = homeTaskRowHtml(t);
+ const newLi = wrap.firstElementChild;
+ newLi.querySelector(".pj-task-popup-link").addEventListener("click", function (e) {
+ e.preventDefault();
+ if (openHomeTaskModalRef) openHomeTaskModalRef(t);
+ });
+ const badge = newLi.querySelector(".pj-cmt-badge");
+ if (badge) {
+ badge.addEventListener("click", function (e) { e.preventDefault(); e.stopPropagation(); });
+ badge.addEventListener("dblclick", function (e) {
+ e.preventDefault(); e.stopPropagation();
+ if (openCommentsModalRef) openCommentsModalRef(t.id);
+ });
+ }
+ // 서버가 처음 그린 (macro)는 data-task-id 를 자체가 아니라
+ // 안쪽 .pj-task-popup-link 에만 붙인다 — 그 기준으로 찾아야 최초
+ // 페이지 로드 때부터 있던 행(아직 한 번도 patch 안 된 행)도 잡힌다.
+ const existingLink = list.querySelector('.pj-task-popup-link[data-task-id="' + t.id + '"]');
+ const existing = existingLink ? existingLink.closest("li") : null;
+ if (existing) existing.replaceWith(newLi); else list.appendChild(newLi);
+ updateHomeCardProgress(t.project_id);
+ syncMyTaskPanel(t);
+ }
+ function removeHomeTaskRow(id, projectId) {
+ const link = document.querySelector('.pj-project-taskrow .pj-task-popup-link[data-task-id="' + id + '"]');
+ const li = link ? link.closest("li") : null;
+ const pid = projectId || (li && li.closest(".pj-project-card") && li.closest(".pj-project-card").dataset.id);
+ if (li) {
+ const list = li.closest(".pj-project-tasklist");
+ li.remove();
+ if (list && !list.children.length) {
+ const empty = document.createElement("div");
+ empty.className = "pj-project-tasks-empty";
+ empty.textContent = "진행 중인 업무가 없습니다.";
+ list.replaceWith(empty);
+ }
+ }
+ delete homeTasks[id];
+ if (pid) updateHomeCardProgress(pid);
+ syncMyTaskPanel({ id: id, project_id: pid }, true);
+ }
+ // "내 업무" 패널(우측) 동기화 — 이 프로젝트에 내 업무 그룹이 이미 있을
+ // 때만 patch 한다(그룹 자체가 없던 경우는 드물어 다음 새로고침까지 생략).
+ function syncMyTaskPanel(t, removed) {
+ const group = document.querySelector('.pj-mytree-group[data-group-id="' + t.project_id + '"] .pj-mytree-tasks');
+ if (!group) return;
+ const isMine = !removed && t.assignee_email && String(t.assignee_email).toLowerCase() === me;
+ const existingLink = group.querySelector('.pj-task-popup-link[data-task-id="' + t.id + '"]');
+ const existingLi = existingLink ? existingLink.closest("li") : null;
+ if (!isMine) { if (existingLi) existingLi.remove(); return; }
+ const dates = t.start_date || t.due_date
+ ? '' +
+ (t.start_date && t.due_date ? fmtDateKr(t.start_date) + " ~ " + fmtDateKr(t.due_date)
+ : t.due_date ? "~ " + fmtDateKr(t.due_date) : fmtDateKr(t.start_date) + " ~") +
+ ""
+ : "";
+ const html = '' +
+ '";
+ const wrap = document.createElement("div");
+ wrap.innerHTML = html;
+ const newLi = wrap.firstElementChild;
+ newLi.querySelector(".pj-task-popup-link").addEventListener("click", function (e) {
+ e.preventDefault();
+ if (openHomeTaskModalRef) openHomeTaskModalRef(t);
+ });
+ if (existingLi) existingLi.replaceWith(newLi); else group.appendChild(newLi);
+ }
+
// ── 완료 프로젝트 섹션 접기/펼치기 (localStorage 기억) ──
// display 를 인라인 스타일로 직접 제어 — 외부 CSS 규칙과의 특이도/우선순위
// 문제를 원천 차단(어떤 스타일시트보다 인라인 style 이 항상 우선한다).
@@ -148,36 +506,25 @@
try { await api("POST", "/project/api/projects/" + pid + "/members", { user_email: email }); }
catch (_) {}
}
- location.reload();
- } catch (e) { alert("생성 실패: " + e.message); }
+ closeModal(modal);
+ if (grid) {
+ const card = projectCardHtml(res.project);
+ grid.insertBefore(card, grid.firstChild);
+ wireProjectCard(card);
+ }
+ } catch (e) { showToast("생성 실패: " + e.message, "error"); }
});
}
// ── 프로젝트 수정 모달 (진행중/완료 두 그리드 모두 대상) ──
- const editModal = el("pj-modal-edit");
if (editModal && grid) {
wireModalClose(editModal);
- let getEditColor = null;
- document.querySelectorAll(".pj-project-card .pj-card-edit").forEach(function (btn) {
- btn.addEventListener("click", function (e) {
- e.preventDefault(); e.stopPropagation();
- const card = btn.closest(".pj-project-card");
- el("pj-e-id").value = card.dataset.id;
- el("pj-e-name").value = card.dataset.name || "";
- el("pj-e-desc").value = card.dataset.desc || "";
- el("pj-e-start").value = card.dataset.start || "";
- el("pj-e-due").value = card.dataset.due || "";
- el("pj-e-done").checked = card.dataset.status === "completed";
- getEditColor = renderColorPalette(el("pj-e-colors"), null, card.dataset.color);
- openModal(editModal);
- });
- });
el("pj-save-edit").addEventListener("click", async function () {
const id = el("pj-e-id").value;
const name = el("pj-e-name").value.trim();
if (!name) { alert("이름을 입력하세요."); return; }
try {
- await api("PUT", "/project/api/projects/" + id, {
+ const res = await api("PUT", "/project/api/projects/" + id, {
name: name,
description: el("pj-e-desc").value.trim(),
start_date: el("pj-e-start").value || null,
@@ -185,16 +532,22 @@
color: getEditColor ? getEditColor() : undefined,
status: el("pj-e-done").checked ? "completed" : "active",
});
- location.reload();
- } catch (e) { alert("저장 실패: " + e.message); }
+ const card = document.querySelector('.pj-project-card[data-id="' + id + '"]');
+ if (card) patchProjectCardDom(card, res.project);
+ closeModal(editModal);
+ } catch (e) { showToast("저장 실패: " + e.message, "error"); }
});
el("pj-delete-project").addEventListener("click", async function () {
const id = el("pj-e-id").value;
const card = document.querySelector('.pj-project-card[data-id="' + id + '"]');
if (card && !canDelete(card.dataset.creator)) { alert("삭제는 만든 사람만 할 수 있습니다."); return; }
if (!confirm("이 프로젝트를 삭제할까요? 안의 업무도 모두 삭제됩니다.")) return;
- try { await api("DELETE", "/project/api/projects/" + id); location.reload(); }
- catch (e) { alert("삭제 실패: " + e.message); }
+ try {
+ await api("DELETE", "/project/api/projects/" + id);
+ if (card) card.remove();
+ closeModal(editModal);
+ }
+ catch (e) { showToast("삭제 실패: " + e.message, "error"); }
});
}
@@ -414,6 +767,7 @@
loadTaskExtras(task);
homeTaskSnapshot = homeTaskFormSnapshot();
}
+ openHomeTaskModalRef = openHomeTaskModal;
// 카드 이름 옆 "업무 추가" 버튼 — 같은 모달을 새 업무 작성용으로 초기화해 연다.
async function openHomeNewTaskModal(projectId, projectName) {
@@ -439,6 +793,7 @@
await Promise.all([fillAssignee(projectId, ""), fillStage(projectId, null)]);
homeTaskSnapshot = homeTaskFormSnapshot();
}
+ openNewTaskModalRef = openHomeNewTaskModal;
function setHomeCompleteBtn(done) {
const btn = el("pj-toggle-complete");
btn.textContent = done ? "↩ 완료 취소" : "✓ 완료로 표시";
@@ -450,16 +805,10 @@
if (!id) return;
const done = el("pj-toggle-complete").dataset.completed === "1";
try {
- await api("PUT", "/project/api/tasks/" + id, { completed: !done });
- location.reload();
- } catch (e) { alert("처리 실패: " + e.message); }
- });
- document.querySelectorAll(".pj-card-addtask").forEach(function (btn) {
- btn.addEventListener("click", function (e) {
- e.preventDefault(); e.stopPropagation();
- const card = btn.closest(".pj-project-card");
- openHomeNewTaskModal(btn.dataset.projectId, card ? card.dataset.name : "");
- });
+ const res = await api("PUT", "/project/api/tasks/" + id, { completed: !done });
+ setHomeCompleteBtn(!!res.task.completed_at);
+ upsertHomeTaskRow(res.task);
+ } catch (e) { showToast("처리 실패: " + e.message, "error"); }
});
el("pj-t-usetime").addEventListener("change", function () {
@@ -492,21 +841,29 @@
};
if (!payload.title) { alert("제목을 입력하세요."); return; }
try {
+ let res;
if (id) {
- await api("PUT", "/project/api/tasks/" + id, payload);
+ res = await api("PUT", "/project/api/tasks/" + id, payload);
} else {
if (!pid) return;
- await api("POST", "/project/api/projects/" + pid + "/tasks", payload);
+ res = await api("POST", "/project/api/projects/" + pid + "/tasks", payload);
}
- location.reload();
- } catch (e) { alert("저장 실패: " + e.message); }
+ upsertHomeTaskRow(res.task);
+ homeTaskSnapshot = null;
+ closeModal(homeTaskModal);
+ } catch (e) { showToast("저장 실패: " + e.message, "error"); }
});
el("pj-delete-task").addEventListener("click", async function () {
const id = el("pj-t-id").value;
if (!id) return;
+ const pid = el("pj-t-project-id").value;
if (!confirm("이 업무를 삭제할까요?")) return;
- try { await api("DELETE", "/project/api/tasks/" + id); location.reload(); }
- catch (e) { alert("삭제 실패: " + e.message); }
+ try {
+ await api("DELETE", "/project/api/tasks/" + id);
+ removeHomeTaskRow(id, pid);
+ closeModal(homeTaskModal);
+ }
+ catch (e) { showToast("삭제 실패: " + e.message, "error"); }
});
document.querySelectorAll(".pj-task-popup-link").forEach(function (a) {
@@ -603,6 +960,7 @@
const n = await renderChat(taskId);
if (n !== null) { markSeen(taskId, n); syncBadge(taskId, n); }
}
+ openCommentsModalRef = openCommentsModal;
document.querySelectorAll(".pj-cmt-badge").forEach(function (b) {
b.addEventListener("click", function (e) { e.preventDefault(); e.stopPropagation(); });
@@ -628,6 +986,9 @@
if (e.key === "Enter") { e.preventDefault(); el("pj-cm-send").click(); }
});
}
+
+ // 최초 로드된 카드 + 이후 새로 만든 카드 모두 여기서 한 번에 배선.
+ document.querySelectorAll(".pj-project-card").forEach(wireProjectCard);
}
// ════════════════════════════════════════════════════════════
@@ -661,10 +1022,10 @@
const currentProject = data.project || {};
let stages = data.stages || [];
let tasks = data.tasks || [];
- const members = data.members || [];
+ let members = data.members || [];
const priorityLabels = data.priorityLabels || {};
// 전체 프로젝트 집계 데이터 — 4개 뷰는 전 프로젝트를 보여준다.
- const projectsMeta = data.projects || [];
+ let projectsMeta = data.projects || [];
const stagesByProject = data.stagesByProject || {};
const boardStages = data.boardStages || [];
const avatars = data.avatars || {};
@@ -892,6 +1253,11 @@
// 드래그(포인터 기반). 안 움직이면 클릭=모달, +N 칩 클릭=그날 팝오버.
function wireCalendarDrag(cal) {
cal.querySelectorAll(".pj-bar[data-task-id]").forEach(function (bar) {
+ if (canManage) bar.addEventListener("contextmenu", function (e) {
+ e.preventDefault();
+ const t = tasks.find(function (x) { return String(x.id) === String(bar.dataset.taskId); });
+ if (t) showContextMenu(e.clientX, e.clientY, taskContextItems(t));
+ });
bar.addEventListener("mousedown", function (e) {
if (e.button !== 0) return;
e.preventDefault();
@@ -1118,6 +1484,13 @@
const t = tasks.find(function (x) { return x.id === props.items[0]; });
if (t) openTaskModal(t);
});
+ // 배경 블록("bg"+그룹id)이나 빈 공간 우클릭은 무시 — 업무 아이템일 때만 메뉴.
+ timeline.on("contextmenu", function (props) {
+ if (props.event && props.event.preventDefault) props.event.preventDefault();
+ if (!canManage || props.item == null || String(props.item).indexOf("bg") === 0) return;
+ const t = tasks.find(function (x) { return x.id === props.item; });
+ if (t) showContextMenu(props.event.clientX, props.event.clientY, taskContextItems(t));
+ });
// 사용자가 직접 확대/이동했을 때만 저장(autofit 등 프로그램 변경은 무시).
timeline.on("rangechanged", function (p) { if (p.byUser) saveTLWindow(p.start, p.end); });
}
@@ -1315,37 +1688,47 @@
}
function wireColumnControls(col, s) {
- const renameBtn = col.querySelector(".pj-col-rename");
- if (renameBtn) renameBtn.addEventListener("click", async function (e) {
- e.stopPropagation();
+ async function renameStage() {
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();
+ } catch (err) { showToast("이름 변경 실패: " + err.message, "error"); }
+ }
+ async function toggleDoneStage() {
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();
+ } catch (err) { showToast("변경 실패: " + err.message, "error"); }
+ }
+ async function deleteStage() {
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); }
+ } catch (err) { showToast("삭제 실패: " + err.message, "error"); }
+ }
+ const renameBtn = col.querySelector(".pj-col-rename");
+ if (renameBtn) renameBtn.addEventListener("click", function (e) { e.stopPropagation(); renameStage(); });
+ const doneBtn = col.querySelector(".pj-col-toggledone");
+ if (doneBtn) doneBtn.addEventListener("click", function (e) { e.stopPropagation(); toggleDoneStage(); });
+ const delBtn = col.querySelector(".pj-col-del");
+ if (delBtn) delBtn.addEventListener("click", function (e) { e.stopPropagation(); deleteStage(); });
+ const head = col.querySelector(".pj-col-head");
+ if (head) head.addEventListener("contextmenu", function (e) {
+ e.preventDefault();
+ showContextMenu(e.clientX, e.clientY, [
+ { label: "이름 변경", onClick: renameStage },
+ { label: s.is_done_stage ? "완료 세션 해제" : "완료 세션으로 지정", onClick: toggleDoneStage },
+ null,
+ { label: "삭제", danger: true, onClick: deleteStage },
+ ]);
});
}
@@ -1455,6 +1838,10 @@
card.classList.add("is-dragging");
});
card.addEventListener("dragend", function () { card.classList.remove("is-dragging"); });
+ card.addEventListener("contextmenu", function (e) {
+ e.preventDefault();
+ showContextMenu(e.clientX, e.clientY, taskContextItems(t));
+ });
}
return card;
}
@@ -1547,7 +1934,14 @@
}
return "" + inner + " | ";
}).join("");
- if (canManage) { tr.style.cursor = "pointer"; tr.addEventListener("click", function () { openTaskModal(t); }); }
+ if (canManage) {
+ tr.style.cursor = "pointer";
+ tr.addEventListener("click", function () { openTaskModal(t); });
+ tr.addEventListener("contextmenu", function (e) {
+ e.preventDefault();
+ showContextMenu(e.clientX, e.clientY, taskContextItems(t));
+ });
+ }
return tr;
}
@@ -1631,6 +2025,38 @@
if (typeof renderTree === "function") renderTree();
}
+ // 업무 우클릭 메뉴 공용 항목 — 보드 카드·리스트 행·달력 막대·타임라인
+ // 아이템 4곳이 전부 이 함수 하나를 쓴다(열기/완료토글/삭제, 각각 기존
+ // 버튼과 동일한 API 경로·권한 검사를 그대로 재사용).
+ function taskContextItems(t) {
+ return [
+ { label: "열기", onClick: function () { openTaskModal(t); } },
+ {
+ label: t.completed_at ? "완료 취소" : "완료로 표시",
+ onClick: async function () {
+ try {
+ const res = await api("PUT", "/project/api/tasks/" + t.id, { completed: !t.completed_at });
+ mergeTask(res.task);
+ refreshActiveView();
+ } catch (e) { showToast("처리 실패: " + e.message, "error"); }
+ },
+ },
+ null,
+ {
+ label: "삭제", danger: true,
+ onClick: async function () {
+ if (!canDelete(t.created_by)) { alert(DELETE_DENIED); return; }
+ if (!confirm("이 업무를 삭제할까요?")) return;
+ try {
+ await api("DELETE", "/project/api/tasks/" + t.id);
+ tasks = tasks.filter(function (x) { return x.id !== t.id; });
+ refreshActiveView();
+ } catch (e) { showToast("삭제 실패: " + e.message, "error"); }
+ },
+ },
+ ];
+ }
+
// ── 업무 모달 ──
const taskModal = el("pj-modal-task");
let currentTaskId = null;
@@ -1998,17 +2424,21 @@
'' + initial + "" +
'' + escapeHtml(p.name) + "" + acts + "";
}
+ async function deleteTreeProject(id, name, createdBy) {
+ if (!canDelete(createdBy)) { alert(DELETE_DENIED); return; }
+ if (!confirm("'" + (name || "") + "' 프로젝트를 삭제할까요? 안의 업무도 모두 삭제됩니다.")) return;
+ try {
+ await api("DELETE", "/project/api/projects/" + id);
+ if (String(id) === String(projectId)) { location.href = "/project/"; return; }
+ projectsMeta = projectsMeta.filter(function (x) { return String(x.id) !== String(id); });
+ renderTree();
+ } catch (err) { showToast("삭제 실패: " + err.message, "error"); }
+ }
function wireTreeHandlers(rootUl) {
rootUl.querySelectorAll(".pj-tree-del").forEach(function (b) {
- b.addEventListener("click", async function (e) {
+ b.addEventListener("click", function (e) {
e.preventDefault(); e.stopPropagation();
- if (!canDelete(b.dataset.creator)) { alert(DELETE_DENIED); return; }
- if (!confirm("'" + (b.dataset.name || "") + "' 프로젝트를 삭제할까요? 안의 업무도 모두 삭제됩니다.")) return;
- try {
- await api("DELETE", "/project/api/projects/" + b.dataset.id);
- if (String(b.dataset.id) === String(projectId)) location.href = "/project/";
- else location.reload();
- } catch (err) { alert("삭제 실패: " + err.message); }
+ deleteTreeProject(b.dataset.id, b.dataset.name, b.dataset.creator);
});
});
rootUl.querySelectorAll(".pj-tree-edit").forEach(function (b) {
@@ -2017,6 +2447,24 @@
openProjectEditModal(b.dataset);
});
});
+ if (isAdmin) rootUl.querySelectorAll(".pj-tree-li").forEach(function (li) {
+ li.addEventListener("contextmenu", function (e) {
+ e.preventDefault();
+ const editBtn = li.querySelector(".pj-tree-edit");
+ const delBtn = li.querySelector(".pj-tree-del");
+ showContextMenu(e.clientX, e.clientY, [
+ { label: "열기", onClick: function () { location.href = "/project/p/" + li.dataset.id; } },
+ { label: "수정", onClick: function () { if (editBtn) openProjectEditModal(editBtn.dataset); } },
+ null,
+ {
+ label: "삭제", danger: true,
+ onClick: function () {
+ if (delBtn) deleteTreeProject(delBtn.dataset.id, delBtn.dataset.name, delBtn.dataset.creator);
+ },
+ },
+ ]);
+ });
+ });
}
function renderTree() {
const rootUl = el("pj-tree-root");
@@ -2024,6 +2472,17 @@
rootUl.innerHTML = projectsMeta.map(treeProjLi).join("");
wireTreeHandlers(rootUl);
}
+ function mergeProject(p) {
+ const i = projectsMeta.findIndex(function (x) { return String(x.id) === String(p.id); });
+ if (i >= 0) projectsMeta[i] = Object.assign({}, projectsMeta[i], p);
+ else projectsMeta.push(p);
+ renderTree();
+ if (String(p.id) === String(projectId)) {
+ Object.assign(currentProject, p);
+ const titleEl = document.querySelector(".pj-project-title");
+ if (titleEl) titleEl.textContent = currentProject.name || "";
+ }
+ }
renderTree();
// 새 최상위 프로젝트 (관리자)
@@ -2033,9 +2492,10 @@
if (!name || !name.trim()) return;
const due = prompt("마감일 (YYYY-MM-DD, 없으면 취소 없이 비워두고 확인)") || "";
try {
- await api("POST", "/project/api/projects", { name: name.trim(), due_date: due.trim() || null });
- location.reload();
- } catch (e) { alert("생성 실패: " + e.message); }
+ const res = await api("POST", "/project/api/projects", { name: name.trim(), due_date: due.trim() || null });
+ stagesByProject[res.project.id] = [];
+ mergeProject(res.project);
+ } catch (e) { showToast("생성 실패: " + e.message, "error"); }
});
// ── 좌측 프로젝트 목록 ✎ 버튼 — 프로젝트 수정 모달(홈 화면과 동일).
@@ -2060,7 +2520,7 @@
const name = el("pj-e-name").value.trim();
if (!name) { alert("이름을 입력하세요."); return; }
try {
- await api("PUT", "/project/api/projects/" + id, {
+ const res = await api("PUT", "/project/api/projects/" + id, {
name: name,
description: el("pj-e-desc").value.trim(),
start_date: el("pj-e-start").value || null,
@@ -2068,25 +2528,58 @@
color: getEditColor ? getEditColor() : undefined,
status: el("pj-e-done").checked ? "completed" : "active",
});
- location.reload();
- } catch (e) { alert("저장 실패: " + e.message); }
+ mergeProject(res.project);
+ closeModal(editModal);
+ } catch (e) { showToast("저장 실패: " + e.message, "error"); }
});
- el("pj-delete-project").addEventListener("click", async function () {
+ el("pj-delete-project").addEventListener("click", function () {
const id = el("pj-e-id").value;
const p = projectsMeta.find(function (x) { return String(x.id) === String(id); });
- if (p && !canDelete(p.created_by)) { alert(DELETE_DENIED); return; }
- if (!confirm("이 프로젝트를 삭제할까요? 안의 업무도 모두 삭제됩니다.")) return;
- try {
- await api("DELETE", "/project/api/projects/" + id);
- if (String(id) === String(projectId)) location.href = "/project/";
- else location.reload();
- } catch (e) { alert("삭제 실패: " + e.message); }
+ closeModal(editModal);
+ deleteTreeProject(id, p && p.name, p && p.created_by);
});
}
// ── 멤버 배정/제외 (관리자) ──
const memberModal = el("pj-modal-member");
wireModalClose(memberModal);
+ function memberRowHtml(m) {
+ const url = avatars[String(m.user_email).toLowerCase()];
+ return "" +
+ (url
+ ? '
'
+ : 'account_circle') +
+ '' + escapeHtml(idOf(m.user_email)) + "" +
+ (m.role === "manager" ? '관리' : "") +
+ (isAdmin ? '' : "") +
+ "";
+ }
+ async function removeMember(email) {
+ if (!confirm("이 멤버를 프로젝트에서 제외할까요?")) return;
+ try {
+ await api("DELETE", "/project/api/projects/" + projectId + "/members/" + encodeURIComponent(email));
+ members = members.filter(function (m) { return m.user_email !== email; });
+ renderMemberList();
+ } catch (e) { showToast("제외 실패: " + e.message, "error"); }
+ }
+ function renderMemberList() {
+ const ul = el("pj-member-list");
+ if (!ul) return;
+ ul.innerHTML = members.length ? members.map(memberRowHtml).join("") : "없음";
+ ul.querySelectorAll(".pj-del-member").forEach(function (b) {
+ b.addEventListener("click", function () { removeMember(b.dataset.email); });
+ });
+ if (isAdmin) ul.querySelectorAll("li[data-email]").forEach(function (li) {
+ li.addEventListener("contextmenu", function (e) {
+ e.preventDefault();
+ showContextMenu(e.clientX, e.clientY, [
+ { label: "제외", danger: true, onClick: function () { removeMember(li.dataset.email); } },
+ ]);
+ });
+ });
+ }
+ renderMemberList();
const addMemberBtn = el("pj-add-member");
if (addMemberBtn) addMemberBtn.addEventListener("click", async function () {
const sel = el("pj-m-user");
@@ -2114,20 +2607,13 @@
if (!sel.value) { alert("직원을 선택하세요."); return; }
const name = sel.selectedOptions[0] ? (sel.selectedOptions[0].dataset.name || "") : "";
try {
- await api("POST", "/project/api/projects/" + projectId + "/members", {
+ const res = 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); }
- });
+ members.push(res.member);
+ renderMemberList();
+ closeModal(memberModal);
+ } catch (e) { showToast("배정 실패: " + e.message, "error"); }
});
// 최초 렌더 — 아사나처럼 프로젝트에 들어오면 보드(세션)가 기본 화면이다
@@ -2135,8 +2621,8 @@
renderView(currentView);
// 사이드 트리에서 업무 클릭(?task=ID)으로 들어오면 해당 업무 모달 자동 열기.
- // 단, 처리 후 URL 에서 task 파라미터를 제거한다 — 드래그 이동 후 location.reload()
- // 시 이 파라미터가 남아 모달이 다시 뜨는 것을 방지.
+ // 단, 처리 후 URL 에서 task 파라미터를 제거한다 — 같은 페이지에 머무는 동안
+ // 다시 열리지 않도록.
const params = new URLSearchParams(location.search);
const taskParam = params.get("task");
if (taskParam) {
@@ -2146,6 +2632,34 @@
const t = tasks.find(function (x) { return String(x.id) === String(taskParam); });
if (t) openTaskModal(t);
}
+
+ // 8초 주기 실시간 동기화(initLiveRefresh)가 전체 새로고침 대신 부를 함수 —
+ // 이 프로젝트의 업무·세션·멤버만 재조회해 로컬 상태에 patch(입력 중인
+ // 모달 폼은 건드리지 않는다). "전체 프로젝트" 스코프에 걸린 다른
+ // 프로젝트의 변경이나 세션 이름변경/재정렬/삭제·멤버 제외·댓글 수정/삭제는
+ // live-version 자체가 감지 못하는 기존 사각지대 — 다음 새로고침까지 반영 안 됨.
+ softSyncProjectRef = async function () {
+ try {
+ const [tRes, sRes, mRes] = await Promise.all([
+ api("GET", "/project/api/projects/" + projectId + "/tasks"),
+ api("GET", "/project/api/projects/" + projectId + "/stages"),
+ api("GET", "/project/api/projects/" + projectId + "/members"),
+ ]);
+ // 서버 페이지 렌더 시엔 router.py 가 각 업무에 context_project_id(=지금
+ // 보는 프로젝트 id)를 붙여준다(멀티호밍 스코프 필터의 기준) — 이 API
+ // 응답엔 그 필드가 없으므로 직접 붙여야 visibleTasks() 필터에서
+ // 안 사라진다.
+ (tRes.tasks || []).forEach(function (t) {
+ t.context_project_id = projectId;
+ mergeTask(t);
+ });
+ stages = sRes.stages || [];
+ stagesByProject[projectId] = stages;
+ members = mRes.members || [];
+ renderMemberList();
+ refreshActiveView();
+ } catch (_) {}
+ };
}
// ════════════════════════════════════════════════════════════
@@ -2639,25 +3153,43 @@
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" });
+ try {
+ await fetch("/project/api/notifications/read-all", { method: "POST" });
+ if (inbox) inbox.querySelectorAll(".pj-inbox-item.is-unread").forEach(function (li) {
li.classList.remove("is-unread");
- } catch (_) {}
+ });
+ }
+ catch (e) { showToast("실패: " + e.message, "error"); }
+ });
+ async function markRead(li) {
+ 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 (_) {}
+ }
+ if (inbox) inbox.querySelectorAll(".pj-inbox-item").forEach(function (li) {
+ li.addEventListener("click", function (e) {
+ if (e.target.closest("a")) return; // '이동' 링크는 통과
+ markRead(li);
+ });
+ li.addEventListener("contextmenu", function (e) {
+ if (!li.classList.contains("is-unread")) return; // 이미 읽었으면 메뉴 없음
+ e.preventDefault();
+ showContextMenu(e.clientX, e.clientY, [
+ { label: "읽음으로 표시", onClick: function () { markRead(li); } },
+ ]);
});
});
}
// ── 실시간 새로고침 ──
// 다른 사용자가 프로젝트/업무/댓글/멤버/단계를 추가·수정하면, 같은 화면을 열어둔
- // 모든 사용자가 자동으로 새로고침되도록 주기적으로 서버 버전을 확인한다.
- // 모달을 열어 입력 중이면 방해하지 않고, 모달이 모두 닫힐 때(closeModal) 반영한다.
+ // 모든 사용자에게 8초 주기로 반영한다. 전체 새로고침 대신 배경 목록만 조용히
+ // 다시 받아 patch 한다(입력 중인 모달 폼은 건드리지 않음) — initProject() 가
+ // 끝나면서 softSyncProjectRef 를 채워준다(홈 페이지는 채우지 않아 벨 배지만
+ // 갱신). initLiveRefresh 는 어느 페이지든(프로젝트/홈) 항상 이 하나로 처리.
+ let softSyncProjectRef = null;
function initLiveRefresh() {
if (!document.querySelector(".pj-wrap, .pj-project-view")) return;
let baseline = null;
@@ -2669,8 +3201,9 @@
if (baseline === null) {
baseline = d.ts;
} else if (d.ts !== baseline) {
- if (anyModalOpen()) { pjPendingReload = true; }
- else { location.reload(); return; }
+ baseline = d.ts;
+ initBell();
+ if (softSyncProjectRef) softSyncProjectRef();
}
}
} catch (_) {}