diff --git a/app/modules/project/templates/project/index.html b/app/modules/project/templates/project/index.html index 8fd89b7..1c6c0f4 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 %} @@ -164,5 +164,5 @@ - + {% endblock %} diff --git a/app/modules/project/templates/project/project.html b/app/modules/project/templates/project/project.html index 22b0e86..cc19d0a 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 %} - + @@ -289,5 +289,5 @@ - + {% endblock %} diff --git a/app/static/project.js b/app/static/project.js index 7a8dfbd..ca760be 100644 --- a/app/static/project.js +++ b/app/static/project.js @@ -227,18 +227,44 @@ return null; } + // 업무마다 '한 달 전체' 기준으로 고정 레인(행) 배정 → 여러 주에 걸쳐도 같은 줄 유지. + // (주마다 따로 배치하면 첫주/다음주의 순서가 바뀌는 문제 방지) + function computeLanes() { + const items = []; + tasks.forEach(function (t) { + const sp = taskSpan(t); + if (sp) items.push({ id: t.id, s: sp[0], e: sp[1] }); + }); + // 시작일 → 종료일 → id 순(결정적). 문자열 'YYYY-MM-DD' 사전식 비교. + items.sort(function (a, b) { + if (a.s !== b.s) return a.s < b.s ? -1 : 1; + if (a.e !== b.e) return a.e < b.e ? -1 : 1; + return a.id - b.id; + }); + const laneEnd = []; // lane 별 마지막 점유 종료일 + const map = {}; + items.forEach(function (it) { + for (var i = 0; i < laneEnd.length; i++) { + if (it.s > laneEnd[i]) { map[it.id] = i; laneEnd[i] = it.e; return; } + } + map[it.id] = laneEnd.length; laneEnd.push(it.e); + }); + return map; + } + // 전체 달력 다시 그리기 — 서버가 그린 날짜 셀(.pj-day[data-date])을 읽어 주별로 막대 배치. function renderCalendar() { const cal = document.querySelector(".pj-cal"); if (!cal) return; closeDayPop(); + const laneOf = computeLanes(); cal.querySelectorAll(".pj-week").forEach(function (weekEl) { const dayEls = weekEl.querySelectorAll(".pj-day[data-date]"); if (!dayEls.length) return; const weekStart = dayEls[0].dataset.date; const weekEnd = dayEls[dayEls.length - 1].dataset.date; - // 이 주에 걸치는 업무 → 막대 세그먼트 + // 이 주에 걸치는 업무 → 막대 세그먼트(레인은 전역 배정값 사용) const bars = []; tasks.forEach(function (t) { const sp = taskSpan(t); @@ -251,23 +277,24 @@ startCol: dayDiff(weekStart, segStart), span: dayDiff(segStart, segEnd) + 1, cl: sp[0] < weekStart, cr: sp[1] > weekEnd, + lane: laneOf[t.id] || 0, }); }); - // 레인(행) 그리디 배치 - bars.sort(function (a, b) { return a.startCol - b.startCol || b.span - a.span; }); - const laneEnd = []; - bars.forEach(function (b) { - for (var i = 0; i < laneEnd.length; i++) { - if (b.startCol > laneEnd[i]) { b.lane = i; laneEnd[i] = b.startCol + b.span - 1; return; } - } - b.lane = laneEnd.length; laneEnd.push(b.startCol + b.span - 1); - }); + + // 전역 레인 순서는 유지하되, 이 주에 실제 존재하는 레인만 0,1,2…로 압축 + // (빈 행 제거 → 헛된 '+N' 방지, 단 두 막대의 상하 순서는 항상 동일). + const present = {}; + bars.forEach(function (b) { present[b.lane] = true; }); + const lanesSorted = Object.keys(present).map(Number).sort(function (a, b) { return a - b; }); + const rankOf = {}; + lanesSorted.forEach(function (l, i) { rankOf[l] = i; }); const cont = weekEl.querySelector(".pj-week-bars"); cont.innerHTML = ""; const overflow = [0, 0, 0, 0, 0, 0, 0]; bars.forEach(function (b) { - if (b.lane >= MAX_LANES) { // 넘침 → 그 칸들 +N 카운트 + const row = rankOf[b.lane]; + if (row >= MAX_LANES) { // 넘침 → 그 칸들 +N 카운트 for (var c = b.startCol; c < b.startCol + b.span; c++) overflow[c]++; return; } @@ -276,7 +303,7 @@ (b.cl ? " pj-bar-l" : "") + (b.cr ? " pj-bar-r" : ""); div.dataset.taskId = b.t.id; div.style.gridColumn = (b.startCol + 1) + " / span " + b.span; - div.style.gridRow = (b.lane + 1); + div.style.gridRow = (row + 1); div.style.background = b.t.completed_at ? "#9aa5b1" : (b.t.project_color || "#4573d2"); div.title = "[" + (b.t.project_name || "") + "] " + (b.t.title || "") + (b.t.assignee_email ? " · " + idOf(b.t.assignee_email) : "");