From 92ee239904455fc7a58c6be0cfbeac2591e26b65 Mon Sep 17 00:00:00 2001 From: king Date: Thu, 25 Jun 2026 16:48:28 +0900 Subject: [PATCH] =?UTF-8?q?feat(project):=20=EB=8B=AC=EB=A0=A5=20=EA=B5=AC?= =?UTF-8?q?=EA=B8=80=20=EC=BA=98=EB=A6=B0=EB=8D=94=EC=8B=9D=20=EA=B0=9C?= =?UTF-8?q?=ED=8E=B8=20=E2=80=94=20=EC=B9=A9=20=EC=A0=95=EB=A6=AC=C2=B7?= =?UTF-8?q?=ED=96=89=20=EC=A0=9C=ED=95=9C(+N)=C2=B7=EB=B6=80=EB=93=9C?= =?UTF-8?q?=EB=9F=AC=EC=9A=B4=20=EB=93=9C=EB=9E=98=EA=B7=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 막대를 클라이언트(JS)가 렌더 → 주별 레인 배치, 표시 행 MAX_LANES(3) 제한 - 넘치면 '+N 더' 칩, 클릭 시 그날 업무 팝오버(클릭하면 업무 모달) - 막대 라벨은 제목만(프로젝트는 색으로 구분), 전체는 툴팁 - 드래그 이동: 낙관적 업데이트(즉시 재렌더, 새로고침 제거) + 실패 시 롤백 - 주 셀 높이 확대, 칩 스타일 정리 Co-Authored-By: Claude Opus 4.8 --- .../project/templates/project/index.html | 4 +- .../project/templates/project/project.html | 19 +- app/static/project.css | 18 +- app/static/project.js | 202 ++++++++++++++---- 4 files changed, 179 insertions(+), 64 deletions(-) diff --git a/app/modules/project/templates/project/index.html b/app/modules/project/templates/project/index.html index c568f78..8fd89b7 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 a93fe34..22b0e86 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 %} - + @@ -147,19 +147,8 @@ {% endfor %} -
- {% for bar in week.bars %} -
- {{ bar.label }} -
- {% endfor %} -
+ +
{% endfor %} @@ -300,5 +289,5 @@ - + {% endblock %} diff --git a/app/static/project.css b/app/static/project.css index 45a13aa..7206575 100644 --- a/app/static/project.css +++ b/app/static/project.css @@ -183,7 +183,7 @@ .pj-wd:last-child { border-right: none; } /* 주(week) — 날짜 셀 위에 bar 레이어 오버레이. 6주가 높이 균등 분할. */ -.pj-week { position: relative; border-bottom: 1px solid #e5e5e5; flex: 1 1 0; min-height: 92px; display: flex; flex-direction: column; } +.pj-week { position: relative; border-bottom: 1px solid #e5e5e5; flex: 1 1 0; min-height: 134px; display: flex; flex-direction: column; } .pj-week:last-child { border-bottom: none; } .pj-week-days { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); flex: 1 1 auto; min-height: 0; } .pj-day { height: 100%; border-right: 1px solid #e5e5e5; padding: 6px 8px; box-sizing: border-box; } @@ -199,9 +199,21 @@ .pj-blue { color: #1d4ed8 !important; } /* bar 오버레이 — 날짜 숫자 아래(top)부터 7열 그리드로 겹쳐 그림 */ -.pj-week-bars { position: absolute; left: 0; right: 0; top: 38px; bottom: 4px; display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); grid-auto-rows: 22px; row-gap: 3px; pointer-events: none; } -.pj-bar { margin: 0 3px; padding: 0 8px; height: 20px; line-height: 20px; border-radius: 6px; font-size: 13px; color: #fff; text-decoration: none; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; align-self: center; pointer-events: auto; cursor: pointer; } +.pj-week-bars { position: absolute; left: 0; right: 0; top: 34px; bottom: 4px; display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); grid-auto-rows: 22px; row-gap: 2px; pointer-events: none; } +.pj-bar { margin: 0 3px; padding: 0 7px; height: 20px; line-height: 20px; border-radius: 5px; font-size: 12.5px; font-weight: 500; color: #fff; text-decoration: none; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; align-self: center; pointer-events: auto; cursor: pointer; } +.pj-bar:hover { filter: brightness(.93); } .pj-bar-label { pointer-events: none; } +/* "+N 더" 칩 — 넘친 업무. 클릭 시 그날 목록 팝오버 */ +.pj-bar-more { margin: 0 3px; height: 18px; line-height: 18px; align-self: center; font-size: 11.5px; font-weight: 600; color: #5a6772; cursor: pointer; pointer-events: auto; padding: 0 4px; border-radius: 5px; } +.pj-bar-more:hover { background: #eceef0; color: #1e1f21; } +/* 그날 업무 팝오버 */ +.pj-daypop { position: absolute; z-index: 2100; min-width: 200px; max-width: 280px; background: #fff; border: 1px solid #e2e5e9; border-radius: 12px; box-shadow: 0 8px 28px rgba(0,0,0,.16); padding: 8px; } +.pj-daypop-head { font-size: 12px; font-weight: 700; color: #525860; padding: 4px 6px 8px; } +.pj-daypop-item { display: flex; align-items: center; gap: 8px; padding: 6px 6px; border-radius: 8px; cursor: pointer; font-size: 13px; } +.pj-daypop-item:hover { background: #f2f4f6; } +.pj-daypop-item.is-done .pj-daypop-title { text-decoration: line-through; color: #9aa1a9; } +.pj-daypop-dot { width: 9px; height: 9px; border-radius: 50%; flex: 0 0 auto; } +.pj-daypop-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .pj-bar-l { border-top-left-radius: 0; border-bottom-left-radius: 0; margin-left: 0; } .pj-bar-r { border-top-right-radius: 0; border-bottom-right-radius: 0; margin-right: 0; } .pj-bar-done { opacity: .75; text-decoration: line-through; } diff --git a/app/static/project.js b/app/static/project.js index 262741b..7a8dfbd 100644 --- a/app/static/project.js +++ b/app/static/project.js @@ -188,17 +188,19 @@ }); function renderView(view) { - if (view === "calendar") wireCalendar(); + if (view === "calendar") renderCalendar(); else if (view === "timeline") renderTimeline(); else if (view === "board") renderBoard(); else if (view === "list") renderList(); } - // ── 달력 (휴가식 서버 렌더 그리드) ── - // bar 는 서버가 그려둠. 클릭=업무 모달, 드래그=일정 이동(관리 권한 시). - let calendarWired = false; + // ════════════════════════════════════════════════════════════ + // 달력 — 구글 캘린더식. 막대(칩)는 클라이언트가 그린다. + // · 한 주에 표시 행 수 제한(MAX_LANES) → 넘치면 "+N" 칩, 클릭 시 그날 목록 팝오버 + // · 드래그 이동: 낙관적 업데이트(즉시 다시 그림, 새로고침 없음) + 실패 시 롤백 + // ════════════════════════════════════════════════════════════ + const MAX_LANES = 3; - // 날짜 문자열 더하기(일 단위). 'YYYY-MM-DD' 입출력. function addDays(dstr, days) { if (!dstr) return null; const d = new Date(dstr + "T00:00:00"); @@ -209,54 +211,107 @@ function dayDiff(a, b) { return Math.round((new Date(b + "T00:00:00") - new Date(a + "T00:00:00")) / 86400000); } - function clearDropHL() { document.querySelectorAll(".pj-day.pj-drop-over").forEach(function (d) { d.classList.remove("pj-drop-over"); }); } - - // 화면 좌표 → 그 위치의 날짜 셀(있으면). function dayCellAt(x, y) { const elAt = document.elementFromPoint(x, y); return elAt ? elAt.closest(".pj-day[data-date]") : null; } - - async function moveTaskTo(taskId, dropDate) { - const t = tasks.find(function (x) { return String(x.id) === String(taskId); }); - if (!t) return; - const anchor = t.start_date || t.due_date; - if (!anchor) { alert("날짜가 없는 업무는 달력에서 이동할 수 없습니다."); return; } - const delta = dayDiff(anchor, dropDate); - if (delta === 0) return; - const payload = { - start_date: t.start_date ? addDays(t.start_date, delta) : null, - due_date: t.due_date ? addDays(t.due_date, delta) : null, - }; - try { - await api("PUT", "/project/api/tasks/" + taskId, payload); - location.reload(); - } catch (err) { alert("이동 실패: " + err.message); } + // 업무 표시 기간 [시작, 끝] (문자열, 끝>=시작). 둘 중 하나만 있으면 단일일. + function taskSpan(t) { + const s = t.start_date, d = t.due_date; + if (s && d) return s <= d ? [s, d] : [d, s]; + if (s) return [s, s]; + if (d) return [d, d]; + return null; } - // 네이티브 HTML5 드래그는 이 절대배치 grid 오버레이에서 불안정해서, - // 포인터(mousedown→mousemove→mouseup) 기반으로 직접 구현한다. - function wireCalendar() { - if (calendarWired) return; - calendarWired = true; + // 전체 달력 다시 그리기 — 서버가 그린 날짜 셀(.pj-day[data-date])을 읽어 주별로 막대 배치. + function renderCalendar() { const cal = document.querySelector(".pj-cal"); + if (!cal) return; + closeDayPop(); + 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; - document.querySelectorAll(".pj-bar[data-task-id]").forEach(function (bar) { + // 이 주에 걸치는 업무 → 막대 세그먼트 + const bars = []; + tasks.forEach(function (t) { + const sp = taskSpan(t); + if (!sp) return; + if (sp[1] < weekStart || sp[0] > weekEnd) return; + const segStart = sp[0] < weekStart ? weekStart : sp[0]; + const segEnd = sp[1] > weekEnd ? weekEnd : sp[1]; + bars.push({ + t: t, + startCol: dayDiff(weekStart, segStart), + span: dayDiff(segStart, segEnd) + 1, + cl: sp[0] < weekStart, cr: sp[1] > weekEnd, + }); + }); + // 레인(행) 그리디 배치 + 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); + }); + + 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 카운트 + for (var c = b.startCol; c < b.startCol + b.span; c++) overflow[c]++; + return; + } + const div = document.createElement("div"); + div.className = "pj-bar" + (b.t.completed_at ? " pj-bar-done" : "") + + (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.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) : ""); + div.innerHTML = '' + escapeHtml(b.t.title || "") + ""; + cont.appendChild(div); + }); + for (var col = 0; col < 7; col++) { + if (overflow[col] > 0) { + const m = document.createElement("div"); + m.className = "pj-bar-more"; + m.style.gridColumn = (col + 1) + " / span 1"; + m.style.gridRow = (MAX_LANES + 1); + m.dataset.date = dayEls[col].dataset.date; + m.textContent = "+" + overflow[col] + " 더"; + cont.appendChild(m); + } + } + }); + wireCalendarDrag(cal); + } + + // 드래그(포인터 기반). 안 움직이면 클릭=모달, +N 칩 클릭=그날 팝오버. + function wireCalendarDrag(cal) { + cal.querySelectorAll(".pj-bar[data-task-id]").forEach(function (bar) { bar.addEventListener("mousedown", function (e) { if (e.button !== 0) return; - e.preventDefault(); // 텍스트 선택/네이티브 드래그 방지 + e.preventDefault(); const taskId = bar.dataset.taskId; const sx = e.clientX, sy = e.clientY; let dragging = false, ghost = null; - function onMove(ev) { if (!dragging) { if (Math.abs(ev.clientX - sx) + Math.abs(ev.clientY - sy) < 5) return; dragging = true; - if (cal) cal.classList.add("pj-cal-dragging"); + cal.classList.add("pj-cal-dragging"); if (canManage) { ghost = document.createElement("div"); ghost.className = "pj-bar-ghost"; @@ -269,29 +324,89 @@ const cell = dayCellAt(ev.clientX, ev.clientY); if (cell) cell.classList.add("pj-drop-over"); } - function onUp(ev) { document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); - if (cal) cal.classList.remove("pj-cal-dragging"); + cal.classList.remove("pj-cal-dragging"); if (ghost) ghost.remove(); clearDropHL(); const t = tasks.find(function (x) { return String(x.id) === String(taskId); }); - if (!dragging) { // 안 움직였으면 클릭 = 모달 - if (t && canManage) openTaskModal(t); - return; - } + if (!dragging) { if (t && canManage) openTaskModal(t); return; } if (!canManage) return; const cell = dayCellAt(ev.clientX, ev.clientY); if (cell) moveTaskTo(taskId, cell.dataset.date); } - document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); }); }); } + // 낙관적 이동 — 즉시 다시 그리고 백그라운드 저장, 실패 시 롤백. + async function moveTaskTo(taskId, dropDate) { + const t = tasks.find(function (x) { return String(x.id) === String(taskId); }); + if (!t) return; + const anchor = t.start_date || t.due_date; + if (!anchor) { alert("날짜가 없는 업무는 달력에서 이동할 수 없습니다."); return; } + const delta = dayDiff(anchor, dropDate); + if (delta === 0) return; + const old = { s: t.start_date, d: t.due_date }; + t.start_date = t.start_date ? addDays(t.start_date, delta) : null; + t.due_date = t.due_date ? addDays(t.due_date, delta) : null; + renderCalendar(); + try { + await api("PUT", "/project/api/tasks/" + taskId, { start_date: t.start_date, due_date: t.due_date }); + } catch (err) { + t.start_date = old.s; t.due_date = old.d; renderCalendar(); + alert("이동 실패: " + err.message); + } + } + + // ── "+N 더" 그날 업무 팝오버 ── + let dayPop = null; + function closeDayPop() { + if (dayPop) { dayPop.remove(); dayPop = null; document.removeEventListener("mousedown", onDocDown, true); } + } + function onDocDown(e) { + if (dayPop && !dayPop.contains(e.target) && !e.target.closest(".pj-bar-more")) closeDayPop(); + } + function openDayPopover(dateStr, anchorEl) { + closeDayPop(); + const list = tasks.filter(function (t) { + const sp = taskSpan(t); return sp && sp[0] <= dateStr && dateStr <= sp[1]; + }); + dayPop = document.createElement("div"); + dayPop.className = "pj-daypop"; + const head = document.createElement("div"); + head.className = "pj-daypop-head"; head.textContent = dateStr; + dayPop.appendChild(head); + list.forEach(function (t) { + const row = document.createElement("div"); + row.className = "pj-daypop-item" + (t.completed_at ? " is-done" : ""); + row.innerHTML = + '' + + '' + escapeHtml(t.title || "") + ""; + row.addEventListener("click", function () { closeDayPop(); if (canManage) openTaskModal(t); }); + dayPop.appendChild(row); + }); + document.body.appendChild(dayPop); + const r = anchorEl.getBoundingClientRect(); + let left = window.scrollX + r.left; + const top = window.scrollY + r.bottom + 4; + left = Math.min(left, window.scrollX + document.documentElement.clientWidth - dayPop.offsetWidth - 8); + dayPop.style.left = Math.max(8, left) + "px"; + dayPop.style.top = top + "px"; + setTimeout(function () { document.addEventListener("mousedown", onDocDown, true); }, 0); + } + (function () { + const cal = document.querySelector(".pj-cal"); + if (cal) cal.addEventListener("click", function (e) { + const more = e.target.closest(".pj-bar-more"); + if (more) openDayPopover(more.dataset.date, more); + }); + })(); + // ── 타임라인 (vis-timeline) — 프로젝트별 행(그룹), 행마다 전체 일정 배경 + 업무 막대 ── function renderTimeline() { const node = el("pj-timeline"); @@ -546,8 +661,7 @@ } function refreshActiveView() { - // 달력은 서버 렌더라 변경 반영 위해 새로고침. 그 외 뷰는 즉시 재렌더. - if (currentView === "calendar") { location.reload(); return; } + // 모든 뷰 클라이언트 재렌더(달력 포함, 새로고침 없음). renderView(currentView); } @@ -889,8 +1003,8 @@ }); }); - // 최초 렌더 — 달력은 서버 렌더, 클릭 핸들러만 연결 - wireCalendar(); + // 최초 렌더 — 달력 막대 그리기 + 드래그 연결 + renderCalendar(); // 사이드 트리에서 업무 클릭(?task=ID)으로 들어오면 해당 업무 모달 자동 열기. // 단, 처리 후 URL 에서 task 파라미터를 제거한다 — 드래그 이동 후 location.reload()