feat(project): 달력 구글 캘린더식 개편 — 칩 정리·행 제한(+N)·부드러운 드래그
- 막대를 클라이언트(JS)가 렌더 → 주별 레인 배치, 표시 행 MAX_LANES(3) 제한 - 넘치면 '+N 더' 칩, 클릭 시 그날 업무 팝오버(클릭하면 업무 모달) - 막대 라벨은 제목만(프로젝트는 색으로 구분), 전체는 툴팁 - 드래그 이동: 낙관적 업데이트(즉시 재렌더, 새로고침 제거) + 실패 시 롤백 - 주 셀 높이 확대, 칩 스타일 정리 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}
|
||||
<link rel="stylesheet" href="/static/project.css?v=20260625r" />
|
||||
<link rel="stylesheet" href="/static/project.css?v=20260625s" />
|
||||
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
|
||||
{% endblock %}
|
||||
|
||||
@@ -164,5 +164,5 @@
|
||||
<script>
|
||||
window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }};
|
||||
</script>
|
||||
<script src="/static/project.js?v=20260625r" defer></script>
|
||||
<script src="/static/project.js?v=20260625s" defer></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}
|
||||
<link rel="stylesheet" href="/static/project.css?v=20260625q" />
|
||||
<link rel="stylesheet" href="/static/project.css?v=20260625s" />
|
||||
<!-- 구글 머티리얼 심볼(담당자 아이콘 등) — self-host -->
|
||||
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
|
||||
<!-- 타임라인 vis-timeline — self-host -->
|
||||
@@ -147,19 +147,8 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="pj-week-bars">
|
||||
{% for bar in week.bars %}
|
||||
<div class="pj-bar {% if bar.completed %}pj-bar-done{% endif %}
|
||||
{% if bar.continues_left %}pj-bar-l{% endif %}
|
||||
{% if bar.continues_right %}pj-bar-r{% endif %}"
|
||||
data-task-id="{{ bar.id }}"
|
||||
style="grid-column: {{ bar.start_col + 1 }} / span {{ bar.span }}; grid-row: {{ bar.lane + 1 }};
|
||||
background: {% if bar.completed %}#9aa5b1{% else %}{{ bar.color }}{% endif %};"
|
||||
title="{{ bar.label }}">
|
||||
<span class="pj-bar-label">{{ bar.label }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<!-- 막대는 클라이언트(JS)가 그린다 — 깔끔한 칩 + 행 제한(+N) + 드래그 이동 -->
|
||||
<div class="pj-week-bars"></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -300,5 +289,5 @@
|
||||
</script>
|
||||
|
||||
<script src="/static/vendor/vis-timeline/vis-timeline-graph2d.min.js"></script>
|
||||
<script src="/static/project.js?v=20260625q" defer></script>
|
||||
<script src="/static/project.js?v=20260625s" defer></script>
|
||||
{% endblock %}
|
||||
|
||||
+15
-3
@@ -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; }
|
||||
|
||||
+158
-44
@@ -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 = '<span class="pj-bar-label">' + escapeHtml(b.t.title || "") + "</span>";
|
||||
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 =
|
||||
'<span class="pj-daypop-dot" style="background:' +
|
||||
(t.completed_at ? "#9aa5b1" : (t.project_color || "#4573d2")) + '"></span>' +
|
||||
'<span class="pj-daypop-title">' + escapeHtml(t.title || "") + "</span>";
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user