feat(project): 내업무 트리·벨 애니메이션·타임라인 그룹·리스트 정렬/리사이즈·검정 탭
- 홈 '내 업무': 프로젝트별 그룹 트리, 업무 아래 날짜 기간 작은 글씨 - 알림 벨: 미읽음 있으면 종 흔들림 애니메이션 + 숫자 배지(기존) - 타임라인: 프로젝트 전체 기간을 연한 색 배경으로 칠하고(그룹 행) 그 안에 업무 막대 표시 → 업무명에 프로젝트명 불필요 - 리스트: 컬럼 헤더 클릭 정렬(오름/내림 아이콘), 컬럼 너비 드래그 조절+ localStorage 저장, 헤더 글씨 크고 굵게 - 상단 뷰 탭 선택 시 검은 배경 흰 글씨 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -321,6 +321,21 @@ async def index(request: Request) -> HTMLResponse:
|
|||||||
)
|
)
|
||||||
tree = _build_tree(projects)
|
tree = _build_tree(projects)
|
||||||
my_tasks = st.list_tasks(assignee_email=user["email"], limit=500)
|
my_tasks = st.list_tasks(assignee_email=user["email"], limit=500)
|
||||||
|
# 내 업무를 프로젝트별로 묶어 트리로 표시
|
||||||
|
my_groups: dict[int, dict[str, Any]] = {}
|
||||||
|
for t in my_tasks:
|
||||||
|
pid = t.get("project_id")
|
||||||
|
g = my_groups.get(pid)
|
||||||
|
if g is None:
|
||||||
|
g = {
|
||||||
|
"id": pid,
|
||||||
|
"name": t.get("project_name") or "프로젝트",
|
||||||
|
"color": t.get("project_color") or "#4573d2",
|
||||||
|
"tasks": [],
|
||||||
|
}
|
||||||
|
my_groups[pid] = g
|
||||||
|
g["tasks"].append(t)
|
||||||
|
my_projects = list(my_groups.values())
|
||||||
return render_template(
|
return render_template(
|
||||||
request,
|
request,
|
||||||
"project/index.html",
|
"project/index.html",
|
||||||
@@ -331,7 +346,7 @@ async def index(request: Request) -> HTMLResponse:
|
|||||||
"page_title": "프로젝트 관리",
|
"page_title": "프로젝트 관리",
|
||||||
"page_subtitle": "달력·타임라인·보드로 프로젝트를 관리하세요.",
|
"page_subtitle": "달력·타임라인·보드로 프로젝트를 관리하세요.",
|
||||||
"tree": tree,
|
"tree": tree,
|
||||||
"my_tasks": my_tasks,
|
"my_projects": my_projects,
|
||||||
"today": today_kst().isoformat(),
|
"today": today_kst().isoformat(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
{% block head_extra %}
|
{% block head_extra %}
|
||||||
<link rel="stylesheet" href="/static/project.css?v=20260625o" />
|
<link rel="stylesheet" href="/static/project.css?v=20260625p" />
|
||||||
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
|
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -45,5 +45,5 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/project.js?v=20260625o" defer></script>
|
<script src="/static/project.js?v=20260625p" defer></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
{% block head_extra %}
|
{% block head_extra %}
|
||||||
<link rel="stylesheet" href="/static/project.css?v=20260625o" />
|
<link rel="stylesheet" href="/static/project.css?v=20260625p" />
|
||||||
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
|
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -54,22 +54,38 @@
|
|||||||
<!-- 내 업무 -->
|
<!-- 내 업무 -->
|
||||||
<aside class="pj-home-side">
|
<aside class="pj-home-side">
|
||||||
<div class="pj-section-head"><h2>내 업무</h2></div>
|
<div class="pj-section-head"><h2>내 업무</h2></div>
|
||||||
{% if not my_tasks %}
|
{% if not my_projects %}
|
||||||
<div class="pj-empty pj-empty-sm">배정된 업무가 없습니다.</div>
|
<div class="pj-empty pj-empty-sm">배정된 업무가 없습니다.</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<ul class="pj-mytask-list">
|
<div class="pj-mytree">
|
||||||
{% for t in my_tasks %}
|
{% for p in my_projects %}
|
||||||
<li class="pj-mytask {% if t.completed_at %}is-done{% endif %}">
|
<div class="pj-mytree-group">
|
||||||
<a href="/project/p/{{ t.project_id }}">
|
<a class="pj-mytree-proj" href="/project/p/{{ p.id }}">
|
||||||
<span class="pj-mytask-dot" style="background: {{ t.project_color }}"></span>
|
<span class="material-symbols-outlined" style="color: {{ p.color }}; font-size:18px;">folder</span>
|
||||||
<span class="pj-mytask-title">{{ t.title }}</span>
|
<span class="pj-mytree-proj-name">{{ p.name }}</span>
|
||||||
<span class="pj-mytask-meta">
|
|
||||||
{{ t.project_name }}{% if t.due_date %} · {{ t.due_date }}{% endif %}
|
|
||||||
</span>
|
|
||||||
</a>
|
</a>
|
||||||
</li>
|
<ul class="pj-mytree-tasks">
|
||||||
|
{% for t in p.tasks %}
|
||||||
|
<li class="pj-mytree-task {% if t.completed_at %}is-done{% endif %}">
|
||||||
|
<a href="/project/p/{{ t.project_id }}?task={{ t.id }}">
|
||||||
|
<span class="material-symbols-outlined pj-mytree-ic" style="color: {{ t.project_color }};">{% if t.completed_at %}task_alt{% else %}radio_button_unchecked{% endif %}</span>
|
||||||
|
<span class="pj-mytree-body">
|
||||||
|
<span class="pj-mytree-title">{{ t.title }}</span>
|
||||||
|
{% if t.start_date or t.due_date %}
|
||||||
|
<span class="pj-mytree-dates">
|
||||||
|
{% if t.start_date and t.due_date %}{{ t.start_date }} ~ {{ t.due_date }}
|
||||||
|
{% elif t.due_date %}~ {{ t.due_date }}
|
||||||
|
{% else %}{{ t.start_date }} ~{% endif %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
@@ -100,5 +116,5 @@
|
|||||||
<script>
|
<script>
|
||||||
window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }};
|
window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }};
|
||||||
</script>
|
</script>
|
||||||
<script src="/static/project.js?v=20260625o" defer></script>
|
<script src="/static/project.js?v=20260625p" defer></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
{% block head_extra %}
|
{% block head_extra %}
|
||||||
<link rel="stylesheet" href="/static/project.css?v=20260625o" />
|
<link rel="stylesheet" href="/static/project.css?v=20260625p" />
|
||||||
<!-- 구글 머티리얼 심볼(담당자 아이콘 등) — self-host -->
|
<!-- 구글 머티리얼 심볼(담당자 아이콘 등) — self-host -->
|
||||||
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
|
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
|
||||||
<!-- 타임라인 vis-timeline — self-host -->
|
<!-- 타임라인 vis-timeline — self-host -->
|
||||||
@@ -297,5 +297,5 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script src="/static/vendor/vis-timeline/vis-timeline-graph2d.min.js"></script>
|
<script src="/static/vendor/vis-timeline/vis-timeline-graph2d.min.js"></script>
|
||||||
<script src="/static/project.js?v=20260625o" defer></script>
|
<script src="/static/project.js?v=20260625p" defer></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+33
-2
@@ -98,7 +98,18 @@
|
|||||||
.pj-toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
|
.pj-toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
|
||||||
.pj-view-tabs { display: inline-flex; background: #eef0f2; border-radius: 10px; padding: 3px; gap: 2px; }
|
.pj-view-tabs { display: inline-flex; background: #eef0f2; border-radius: 10px; padding: 3px; gap: 2px; }
|
||||||
.pj-tab { border: none; background: transparent; padding: 7px 16px; border-radius: 8px; font-size: 13px; font-weight: 600; color: #525860; cursor: pointer; }
|
.pj-tab { border: none; background: transparent; padding: 7px 16px; border-radius: 8px; font-size: 13px; font-weight: 600; color: #525860; cursor: pointer; }
|
||||||
.pj-tab.is-active { background: #fff; color: #1e1f21; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
|
.pj-tab.is-active { background: #1e1f21; color: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.18); }
|
||||||
|
|
||||||
|
/* 알림 벨 흔들림(미읽음 있을 때) */
|
||||||
|
@keyframes pj-bell-ring {
|
||||||
|
0%, 50%, 100% { transform: rotate(0); }
|
||||||
|
10%, 30% { transform: rotate(14deg); }
|
||||||
|
20%, 40% { transform: rotate(-14deg); }
|
||||||
|
}
|
||||||
|
.pj-bell.has-unread .material-symbols-outlined {
|
||||||
|
animation: pj-bell-ring 1.4s ease-in-out infinite;
|
||||||
|
transform-origin: top center;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── 보드(칸반) ── */
|
/* ── 보드(칸반) ── */
|
||||||
.pj-board { display: flex; gap: 14px; overflow-x: auto; padding-bottom: 8px; align-items: flex-start; }
|
.pj-board { display: flex; gap: 14px; overflow-x: auto; padding-bottom: 8px; align-items: flex-start; }
|
||||||
@@ -120,9 +131,29 @@
|
|||||||
|
|
||||||
/* ── 리스트 ── */
|
/* ── 리스트 ── */
|
||||||
.pj-table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
|
.pj-table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
|
||||||
.pj-table th { text-align: left; padding: 9px 10px; border-bottom: 2px solid #eef0f2; color: #6b7280; font-size: 12px; }
|
.pj-table th { text-align: left; padding: 9px 10px; border-bottom: 2px solid #eef0f2; color: #1e1f21; font-size: 14px; font-weight: 700; position: relative; }
|
||||||
.pj-table td { padding: 10px; border-bottom: 1px solid #f1f2f4; }
|
.pj-table td { padding: 10px; border-bottom: 1px solid #f1f2f4; }
|
||||||
.pj-table tr.is-done td { color: #9aa1a9; text-decoration: line-through; }
|
.pj-table tr.is-done td { color: #9aa1a9; text-decoration: line-through; }
|
||||||
|
.pj-table-fixed { table-layout: fixed; }
|
||||||
|
.pj-table-fixed th, .pj-table-fixed td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.pj-th-label { cursor: pointer; user-select: none; display: inline-flex; align-items: center; gap: 4px; }
|
||||||
|
.pj-th-label:hover { color: #4573d2; }
|
||||||
|
.pj-th-sort { font-size: 11px; color: #9aa1a9; }
|
||||||
|
.pj-th-resize { position: absolute; top: 0; right: -3px; width: 8px; height: 100%; cursor: col-resize; z-index: 2; }
|
||||||
|
.pj-th-resize:hover { background: linear-gradient(90deg, transparent 40%, #c9d4ea 40%, #c9d4ea 60%, transparent 60%); }
|
||||||
|
|
||||||
|
/* ── 내 업무 트리(홈) ── */
|
||||||
|
.pj-mytree-group { margin-bottom: 14px; }
|
||||||
|
.pj-mytree-proj { display: flex; align-items: center; gap: 7px; text-decoration: none; color: #1e1f21; font-weight: 700; font-size: 13.5px; padding: 4px 2px; border-radius: 7px; }
|
||||||
|
.pj-mytree-proj:hover { background: #f6f7f8; }
|
||||||
|
.pj-mytree-tasks { list-style: none; margin: 2px 0 0; padding: 0 0 0 8px; border-left: 2px solid #eef0f2; margin-left: 10px; }
|
||||||
|
.pj-mytree-task a { display: flex; align-items: flex-start; gap: 7px; padding: 6px 6px; border-radius: 7px; text-decoration: none; color: inherit; }
|
||||||
|
.pj-mytree-task a:hover { background: #f6f7f8; }
|
||||||
|
.pj-mytree-ic { font-size: 16px; flex: 0 0 auto; margin-top: 1px; }
|
||||||
|
.pj-mytree-body { display: flex; flex-direction: column; min-width: 0; }
|
||||||
|
.pj-mytree-title { font-size: 13px; font-weight: 600; }
|
||||||
|
.pj-mytree-dates { font-size: 11px; color: #9aa1a9; margin-top: 1px; }
|
||||||
|
.pj-mytree-task.is-done .pj-mytree-title { text-decoration: line-through; color: #9aa1a9; }
|
||||||
|
|
||||||
/* ── 달력 (휴가 모듈과 동일한 월간 그리드 룩) ── */
|
/* ── 달력 (휴가 모듈과 동일한 월간 그리드 룩) ── */
|
||||||
.pj-cal-head { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
|
.pj-cal-head { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
|
||||||
|
|||||||
+99
-14
@@ -227,6 +227,9 @@
|
|||||||
// ── 타임라인 (vis-timeline) ──
|
// ── 타임라인 (vis-timeline) ──
|
||||||
function renderTimeline() {
|
function renderTimeline() {
|
||||||
const node = el("pj-timeline");
|
const node = el("pj-timeline");
|
||||||
|
const proj = data.project || {};
|
||||||
|
const grp = "P" + projectId; // 프로젝트 행(그룹) id
|
||||||
|
const projColor = proj.color || "#4573d2";
|
||||||
const items = tasks.filter(function (t) { return t.start_date || t.due_date; })
|
const items = tasks.filter(function (t) { return t.start_date || t.due_date; })
|
||||||
.map(function (t) {
|
.map(function (t) {
|
||||||
const s = t.start_date || t.due_date;
|
const s = t.start_date || t.due_date;
|
||||||
@@ -237,18 +240,36 @@
|
|||||||
const isRange = (e !== s) || (!!t.start_time && !!t.due_time && t.start_time !== t.due_time);
|
const isRange = (e !== s) || (!!t.start_time && !!t.due_time && t.start_time !== t.due_time);
|
||||||
return {
|
return {
|
||||||
id: t.id,
|
id: t.id,
|
||||||
content: t.title,
|
group: grp,
|
||||||
|
content: t.title, // 프로젝트명 없이 업무명만
|
||||||
start: startISO,
|
start: startISO,
|
||||||
end: isRange ? endISO : undefined,
|
end: isRange ? endISO : undefined,
|
||||||
type: isRange ? "range" : "point",
|
type: isRange ? "range" : "point",
|
||||||
style: "background-color:" + (t.completed_at ? "#9aa5b1" : (t.project_color || "#4573d2")) + ";color:#fff;border:none;",
|
style: "background-color:" + (t.completed_at ? "#9aa5b1" : projColor) + ";color:#fff;border:none;",
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
if (timeline) { timeline.destroy(); timeline = null; }
|
if (timeline) { timeline.destroy(); timeline = null; }
|
||||||
if (!items.length) { node.innerHTML = '<div class="pj-empty">기간이 설정된 업무가 없습니다.</div>'; return; }
|
if (!items.length) { node.innerHTML = '<div class="pj-empty">기간이 설정된 업무가 없습니다.</div>'; return; }
|
||||||
|
|
||||||
|
// 프로젝트 전체 일정 = 업무들의 최소~최대 범위를 연한 색으로 배경 칠하기
|
||||||
|
let minMs = Infinity, maxMs = -Infinity;
|
||||||
|
items.forEach(function (it) {
|
||||||
|
const s = new Date(it.start).getTime();
|
||||||
|
const e = it.end ? new Date(it.end).getTime() : s;
|
||||||
|
if (s < minMs) minMs = s;
|
||||||
|
if (e > maxMs) maxMs = e;
|
||||||
|
});
|
||||||
|
maxMs += 86400000; // 마지막 날 하루를 덮도록 +1일
|
||||||
|
const bg = {
|
||||||
|
id: "pjbg", group: grp, type: "background",
|
||||||
|
start: new Date(minMs), end: new Date(maxMs),
|
||||||
|
style: "background-color:" + projColor + "22;",
|
||||||
|
};
|
||||||
|
const groups = new vis.DataSet([{ id: grp, content: proj.name || "프로젝트" }]);
|
||||||
|
|
||||||
const koWd = function (d) { return ["일", "월", "화", "수", "목", "금", "토"][d.getDay()]; };
|
const koWd = function (d) { return ["일", "월", "화", "수", "목", "금", "토"][d.getDay()]; };
|
||||||
const pad2 = function (n) { return ("0" + n).slice(-2); };
|
const pad2 = function (n) { return ("0" + n).slice(-2); };
|
||||||
timeline = new vis.Timeline(node, new vis.DataSet(items), {
|
timeline = new vis.Timeline(node, new vis.DataSet(items.concat([bg])), groups, {
|
||||||
stack: true, height: "520px",
|
stack: true, height: "520px",
|
||||||
horizontalScroll: true, // 마우스 휠 = 좌우 이동(패닝)
|
horizontalScroll: true, // 마우스 휠 = 좌우 이동(패닝)
|
||||||
verticalScroll: false,
|
verticalScroll: false,
|
||||||
@@ -405,20 +426,79 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 리스트 ──
|
// ── 리스트 (정렬·컬럼 너비 조절) ──
|
||||||
|
const LIST_COLS = [
|
||||||
|
{ key: "title", label: "업무", text: function (t) { return t.title; }, sort: function (t) { return (t.title || "").toLowerCase(); } },
|
||||||
|
{ key: "assignee", label: "담당자", text: function (t) { return t.assignee_email ? idOf(t.assignee_email) : "-"; }, sort: function (t) { return idOf(t.assignee_email || ""); } },
|
||||||
|
{ key: "stage", label: "단계", text: function (t) { return t.stage_name || "-"; }, sort: function (t) { return t.stage_name || ""; } },
|
||||||
|
{ key: "priority", label: "우선순위", text: function (t) { return priorityLabels[t.priority] || t.priority || "-"; }, sort: function (t) { return ({ low: 0, normal: 1, high: 2 })[t.priority]; } },
|
||||||
|
{ key: "start", label: "시작", text: function (t) { return t.start_date || "-"; }, sort: function (t) { return t.start_date || ""; } },
|
||||||
|
{ key: "due", label: "마감", text: function (t) { return t.due_date || "-"; }, sort: function (t) { return t.due_date || ""; } },
|
||||||
|
];
|
||||||
|
let listSort = { key: null, dir: 1 };
|
||||||
|
function listWidths() { try { return JSON.parse(localStorage.getItem("pj_list_widths") || "{}"); } catch (_) { return {}; } }
|
||||||
|
function saveListWidth(key, w) { const m = listWidths(); m[key] = w; try { localStorage.setItem("pj_list_widths", JSON.stringify(m)); } catch (_) {} }
|
||||||
|
|
||||||
|
function enableColResize(th, key) {
|
||||||
|
const handle = th.querySelector(".pj-th-resize");
|
||||||
|
if (!handle) return;
|
||||||
|
handle.addEventListener("mousedown", function (e) {
|
||||||
|
e.preventDefault(); e.stopPropagation();
|
||||||
|
const startX = e.clientX, startW = th.offsetWidth;
|
||||||
|
function mv(ev) { th.style.width = Math.max(60, startW + (ev.clientX - startX)) + "px"; }
|
||||||
|
function up() { document.removeEventListener("mousemove", mv); document.removeEventListener("mouseup", up); saveListWidth(key, th.offsetWidth); }
|
||||||
|
document.addEventListener("mousemove", mv);
|
||||||
|
document.addEventListener("mouseup", up);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function renderList() {
|
function renderList() {
|
||||||
const tbody = el("pj-list").querySelector("tbody");
|
const table = el("pj-list");
|
||||||
|
table.classList.add("pj-table-fixed");
|
||||||
|
const widths = listWidths();
|
||||||
|
|
||||||
|
const trh = document.createElement("tr");
|
||||||
|
LIST_COLS.forEach(function (col) {
|
||||||
|
const th = document.createElement("th");
|
||||||
|
th.dataset.key = col.key;
|
||||||
|
if (widths[col.key]) th.style.width = widths[col.key] + "px";
|
||||||
|
const arrow = listSort.key === col.key ? (listSort.dir > 0 ? "▲" : "▼") : "↕";
|
||||||
|
th.innerHTML =
|
||||||
|
'<span class="pj-th-label">' + escapeHtml(col.label) +
|
||||||
|
' <span class="pj-th-sort">' + arrow + "</span></span>" +
|
||||||
|
'<span class="pj-th-resize"></span>';
|
||||||
|
th.querySelector(".pj-th-label").addEventListener("click", function () {
|
||||||
|
if (listSort.key === col.key) listSort.dir = -listSort.dir;
|
||||||
|
else { listSort.key = col.key; listSort.dir = 1; }
|
||||||
|
renderList();
|
||||||
|
});
|
||||||
|
enableColResize(th, col.key);
|
||||||
|
trh.appendChild(th);
|
||||||
|
});
|
||||||
|
const thead = table.querySelector("thead");
|
||||||
|
thead.innerHTML = "";
|
||||||
|
thead.appendChild(trh);
|
||||||
|
|
||||||
|
let rows = tasks.slice();
|
||||||
|
if (listSort.key) {
|
||||||
|
const col = LIST_COLS.find(function (c) { return c.key === listSort.key; });
|
||||||
|
rows.sort(function (a, b) {
|
||||||
|
let va = col.sort(a), vb = col.sort(b);
|
||||||
|
if (va == null) va = -Infinity;
|
||||||
|
if (vb == null) vb = -Infinity;
|
||||||
|
if (va < vb) return -listSort.dir;
|
||||||
|
if (va > vb) return listSort.dir;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const tbody = table.querySelector("tbody");
|
||||||
tbody.innerHTML = "";
|
tbody.innerHTML = "";
|
||||||
tasks.forEach(function (t) {
|
rows.forEach(function (t) {
|
||||||
const tr = document.createElement("tr");
|
const tr = document.createElement("tr");
|
||||||
if (t.completed_at) tr.className = "is-done";
|
if (t.completed_at) tr.className = "is-done";
|
||||||
tr.innerHTML =
|
tr.innerHTML = LIST_COLS.map(function (c) {
|
||||||
"<td>" + escapeHtml(t.title) + "</td>" +
|
return "<td>" + escapeHtml(String(c.text(t))) + "</td>";
|
||||||
"<td>" + (t.assignee_email ? escapeHtml(idOf(t.assignee_email)) : "-") + "</td>" +
|
}).join("");
|
||||||
"<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); }); }
|
if (canManage) { tr.style.cursor = "pointer"; tr.addEventListener("click", function () { openTaskModal(t); }); }
|
||||||
tbody.appendChild(tr);
|
tbody.appendChild(tr);
|
||||||
});
|
});
|
||||||
@@ -795,11 +875,16 @@
|
|||||||
async function initBell() {
|
async function initBell() {
|
||||||
const badge = document.getElementById("pj-bell-badge");
|
const badge = document.getElementById("pj-bell-badge");
|
||||||
if (!badge) return;
|
if (!badge) return;
|
||||||
|
const bell = badge.closest(".pj-bell");
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/project/api/notifications/unread-count");
|
const res = await fetch("/project/api/notifications/unread-count");
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
if (d.unread > 0) { badge.textContent = d.unread > 99 ? "99+" : d.unread; badge.hidden = false; }
|
if (d.unread > 0) {
|
||||||
|
badge.textContent = d.unread > 99 ? "99+" : d.unread;
|
||||||
|
badge.hidden = false;
|
||||||
|
if (bell) bell.classList.add("has-unread"); // 종 흔들림 애니메이션
|
||||||
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user