feat(project): 달력 구글 캘린더식 개편 — 칩 정리·행 제한(+N)·부드러운 드래그
- 막대를 클라이언트(JS)가 렌더 → 주별 레인 배치, 표시 행 MAX_LANES(3) 제한 - 넘치면 '+N 더' 칩, 클릭 시 그날 업무 팝오버(클릭하면 업무 모달) - 막대 라벨은 제목만(프로젝트는 색으로 구분), 전체는 툴팁 - 드래그 이동: 낙관적 업데이트(즉시 재렌더, 새로고침 제거) + 실패 시 롤백 - 주 셀 높이 확대, 칩 스타일 정리 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+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