feat(project): 타임라인 드래그 이동/기간조절 + 아사나 느낌 상호작용

- 막대 몸통 드래그로 일정 통째 이동, 양 끝 드래그로 시작/마감일만 조절
  (vis-timeline editable.updateTime + onMove, PUT /api/tasks/{id} 로 저장,
  실패 시 vis 표시만 원래 자리로 롤백 — 전체 재렌더 없이).
- 단일 클릭은 선택만(끝 손잡이가 나타나게) 하도록 바꾸고, 편집 열기는
  더블클릭 또는 우클릭 메뉴의 "열기"로 옮김 — 기존엔 클릭 한 번에 모달이
  떠서 손잡이가 나타나자마자 가려져 드래그를 할 수 없던 문제.
- 마우스 오버 시 그 업무의 날짜 구간을 전체 높이 배경 밴드로 강조(아사나
  타임라인에서 막대에 올리면 헤더 날짜가 진해지는 것과 같은 목적).
- 막대를 둥근 알약형으로, 호버 시 살짝 뜨는 그림자 추가. "오늘" 버튼으로
  현재 날짜로 바로 이동.
- 실제 드래그(핸들 좌표 기준)로 헤드리스 크롬에서 검증 — 기간 연장/이동
  둘 다 서버에 정확히 저장되는 것까지 확인.
This commit is contained in:
2026-09-16 17:34:09 +09:00
parent 8634b2602b
commit 62ab883d24
5 changed files with 102 additions and 10 deletions
+75 -4
View File
@@ -1614,10 +1614,60 @@
},
};
if (saved) { opts.start = new Date(saved.start); opts.end = new Date(saved.end); }
timeline = new vis.Timeline(node, new vis.DataSet(items), groups, opts);
timeline.on("select", function (props) {
if (!canManage || !props.items.length) return;
const t = tasks.find(function (x) { return x.id === props.items[0]; });
// ── 드래그로 이동/기간 조절 — 아사나처럼 막대 몸통을 끌면 통째로 이동,
// 양 끝을 끌면 시작·마감일만 조절. 세션(그룹) 간 드래그는 막아둔다(그룹
// 이동은 "다른 세션으로 옮기기"라 보드 드래그의 몫 — 타임라인은 날짜만).
opts.editable = { add: false, remove: false, updateGroup: false, updateTime: canManage };
opts.onMove = function (item, callback) {
const t = tasks.find(function (x) { return x.id === item.id; });
if (!t) { callback(null); return; }
const old = { start_date: t.start_date, due_date: t.due_date, start_time: t.start_time, due_time: t.due_time };
const useTime = !!(t.start_time || t.due_time);
function toParts(d) {
const dt = new Date(d);
const date = dt.getFullYear() + "-" + String(dt.getMonth() + 1).padStart(2, "0") + "-" + String(dt.getDate()).padStart(2, "0");
const time = String(dt.getHours()).padStart(2, "0") + ":" + String(dt.getMinutes()).padStart(2, "0");
return { date: date, time: time };
}
const payload = {};
if (item.type === "point") {
// 단일 날짜 항목 — 원래 채워져 있던 필드(시작 또는 마감, 혹은 둘 다
// 같은 날짜)를 이동한 날짜로 같이 옮긴다.
const p = toParts(item.start);
if (t.start_date) { payload.start_date = p.date; if (useTime) payload.start_time = p.time; }
if (t.due_date) { payload.due_date = p.date; if (useTime) payload.due_time = p.time; }
} else {
const ps = toParts(item.start), pe = toParts(item.end);
payload.start_date = ps.date;
payload.due_date = pe.date;
if (useTime) { payload.start_time = ps.time; payload.due_time = pe.time; }
}
Object.assign(t, payload);
callback(item); // vis 쪽 위치는 즉시 반영(낙관적) — 저장은 백그라운드.
api("PUT", "/project/api/tasks/" + t.id, payload).then(function (res) {
mergeTask(res.task);
}).catch(function (err) {
Object.assign(t, old);
showToast("일정 변경 실패: " + err.message, "error");
// vis 표시만 원래 자리로 되돌린다(전체 재렌더 없이 — 보던 화면 유지).
itemsDS.update({
id: item.id,
start: old.start_date + (old.start_time ? "T" + old.start_time : ""),
end: item.type === "point" ? undefined : (old.due_date || old.start_date) + (old.due_time ? "T" + old.due_time : ""),
});
});
};
const itemsDS = new vis.DataSet(items);
timeline = new vis.Timeline(node, itemsDS, groups, opts);
// 단일 클릭은 선택만(드래그로 기간 조절할 때 양 끝 손잡이가 나타나려면
// 먼저 선택 상태여야 한다 — vis 의 자체 동작) — 여기서 모달을 열면
// 손잡이가 나타나자마자 모달이 덮어써서 드래그를 할 수가 없다. 편집은
// 더블클릭이나 우클릭 메뉴의 "열기"로.
timeline.on("doubleClick", function (props) {
if (!canManage || props.item == null || String(props.item).indexOf("bg") === 0) return;
const t = tasks.find(function (x) { return x.id === props.item; });
if (t) openTaskModal(t);
});
// 배경 블록("bg"+그룹id)이나 빈 공간 우클릭은 무시 — 업무 아이템일 때만 메뉴.
@@ -1627,8 +1677,29 @@
const t = tasks.find(function (x) { return x.id === props.item; });
if (t) showContextMenu(props.event.clientX, props.event.clientY, taskContextItems(t));
});
// 마우스 오버 시 그 업무의 날짜 구간을 전체 높이 배경 밴드로 살짝 강조
// (아사나에서 막대에 마우스를 올리면 헤더 날짜 부분이 진하게 표시되는 것과
// 같은 목적 — vis 의 날짜 헤더 DOM을 직접 건드리는 대신, 같은 자리에
// 반투명 background 아이템을 하나 얹었다 뺐다 하는 방식으로 구현).
const HOVER_ID = "hover-highlight";
timeline.on("itemover", function (props) {
if (props.item == null || String(props.item).indexOf("bg") === 0 || props.item === HOVER_ID) return;
const it = itemsDS.get(props.item);
if (!it) return;
const start = it.start;
const end = it.end || new Date(new Date(it.start).getTime() + 86400000);
try { itemsDS.remove(HOVER_ID); } catch (_) {}
itemsDS.add({ id: HOVER_ID, type: "background", start: start, end: end, className: "pj-tl-hover-band" });
});
timeline.on("itemout", function (props) {
if (props.item === HOVER_ID) return;
try { itemsDS.remove(HOVER_ID); } catch (_) {}
});
// 사용자가 직접 확대/이동했을 때만 저장(autofit 등 프로그램 변경은 무시).
timeline.on("rangechanged", function (p) { if (p.byUser) saveTLWindow(p.start, p.end); });
const todayBtn = el("pj-tl-today");
if (todayBtn) todayBtn.onclick = function () { timeline.moveTo(new Date()); };
}
function loadTLWindow() {
try {