feat(project): 타임라인 드래그 이동/기간조절 + 아사나 느낌 상호작용
- 막대 몸통 드래그로 일정 통째 이동, 양 끝 드래그로 시작/마감일만 조절
(vis-timeline editable.updateTime + onMove, PUT /api/tasks/{id} 로 저장,
실패 시 vis 표시만 원래 자리로 롤백 — 전체 재렌더 없이).
- 단일 클릭은 선택만(끝 손잡이가 나타나게) 하도록 바꾸고, 편집 열기는
더블클릭 또는 우클릭 메뉴의 "열기"로 옮김 — 기존엔 클릭 한 번에 모달이
떠서 손잡이가 나타나자마자 가려져 드래그를 할 수 없던 문제.
- 마우스 오버 시 그 업무의 날짜 구간을 전체 높이 배경 밴드로 강조(아사나
타임라인에서 막대에 올리면 헤더 날짜가 진해지는 것과 같은 목적).
- 막대를 둥근 알약형으로, 호버 시 살짝 뜨는 그림자 추가. "오늘" 버튼으로
현재 날짜로 바로 이동.
- 실제 드래그(핸들 좌표 기준)로 헤드리스 크롬에서 검증 — 기간 연장/이동
둘 다 서버에 정확히 저장되는 것까지 확인.
This commit is contained in:
@@ -423,7 +423,25 @@
|
||||
.pj-bar-ghost { position: fixed; z-index: 2000; pointer-events: none; padding: 2px 10px; border-radius: 6px; background: #1e1f21; color: #fff; font-size: 12px; box-shadow: 0 4px 14px rgba(0,0,0,.3); white-space: nowrap; }
|
||||
|
||||
/* ── 타임라인 컨테이너 ── */
|
||||
.pj-tl-toolbar { display: flex; justify-content: flex-end; margin-bottom: 8px; }
|
||||
#pj-timeline { background: #fff; border-radius: 12px; padding: 6px; }
|
||||
/* 아사나 느낌의 둥근 알약형 막대 + 살짝 뜨는 호버. vis 기본 각진 스타일 위에 덮어씀. */
|
||||
#pj-timeline .vis-item.vis-range {
|
||||
border-radius: 10px !important; border: none !important;
|
||||
box-shadow: 0 1px 2px rgba(16,24,40,.12); transition: box-shadow .15s, transform .15s;
|
||||
}
|
||||
#pj-timeline .vis-item.vis-point .vis-item-content { border-radius: 10px; }
|
||||
#pj-timeline .vis-item:hover { box-shadow: 0 3px 10px rgba(16,24,40,.22); transform: translateY(-1px); cursor: grab; }
|
||||
#pj-timeline .vis-item.vis-dragging { cursor: grabbing; }
|
||||
#pj-timeline .vis-item .vis-item-content { padding: 4px 10px; font-size: 12.5px; font-weight: 600; }
|
||||
/* 드래그로 끄는 양 끝 손잡이 — 기본보다 조금 더 잘 보이게 */
|
||||
#pj-timeline .vis-item .vis-drag-left, #pj-timeline .vis-item .vis-drag-right {
|
||||
width: 10px; cursor: ew-resize;
|
||||
}
|
||||
/* 마우스 오버 시 그 업무 날짜 구간을 전체 높이로 살짝 강조(헤더 날짜 위치를
|
||||
눈으로 바로 짚을 수 있게) — pointer-events 는 vis 배경 아이템 기본값(none)
|
||||
그대로라 밑에 깔린 막대의 호버 감지를 방해하지 않는다. */
|
||||
#pj-timeline .pj-tl-hover-band { background: var(--pj-accent-tint) !important; opacity: .8; }
|
||||
|
||||
/* ── 모달 ── */
|
||||
.pj-modal { position: fixed; inset: 0; background: rgba(20,22,26,.46); display: flex; align-items: center; justify-content: center; z-index: 1000; }
|
||||
|
||||
+75
-4
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user