feat(project): 달력에서 bar 드래그로 일정 이동

서버 렌더 달력의 업무 bar 를 다른 날짜로 드래그하면 기간을 유지한 채
일정 이동(시작일 앵커, 없으면 마감일). 드롭 시 PUT /api/tasks/{id} 로
start/due 갱신 후 새로고침. 드래그 중엔 bar pointer-events 해제해 날짜
셀이 드롭을 받도록 하고, 드롭 대상 칸은 하이라이트.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 12:37:19 +09:00
parent 776f655db4
commit 15eabd0bbf
5 changed files with 67 additions and 8 deletions
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %} {% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260625e" /> <link rel="stylesheet" href="/static/project.css?v=20260625f" />
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" /> <link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
{% endblock %} {% endblock %}
@@ -45,5 +45,5 @@
{% endif %} {% endif %}
</div> </div>
<script src="/static/project.js?v=20260625e" defer></script> <script src="/static/project.js?v=20260625f" defer></script>
{% endblock %} {% endblock %}
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %} {% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260625e" /> <link rel="stylesheet" href="/static/project.css?v=20260625f" />
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" /> <link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
{% endblock %} {% endblock %}
@@ -101,5 +101,5 @@
<script> <script>
window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }}; window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }};
</script> </script>
<script src="/static/project.js?v=20260625e" defer></script> <script src="/static/project.js?v=20260625f" defer></script>
{% endblock %} {% endblock %}
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %} {% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260625e" /> <link rel="stylesheet" href="/static/project.css?v=20260625f" />
<!-- 구글 머티리얼 심볼(담당자 아이콘 등) — self-host --> <!-- 구글 머티리얼 심볼(담당자 아이콘 등) — self-host -->
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" /> <link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
<!-- 타임라인 vis-timeline — self-host --> <!-- 타임라인 vis-timeline — self-host -->
@@ -105,7 +105,8 @@
<div class="pj-week"> <div class="pj-week">
<div class="pj-week-days"> <div class="pj-week-days">
{% for cell in week.days %} {% for cell in week.days %}
<div class="pj-day {% if not cell.in_month %}pj-out{% endif %} {% if cell.is_today %}pj-today{% endif %}"> <div class="pj-day {% if not cell.in_month %}pj-out{% endif %} {% if cell.is_today %}pj-today{% endif %}"
data-date="{{ cell.date }}">
<span class="pj-day-num <span class="pj-day-num
{% if cell.is_sunday %}pj-red{% elif cell.is_saturday %}pj-blue{% endif %}">{{ cell.day }}</span> {% if cell.is_sunday %}pj-red{% elif cell.is_saturday %}pj-blue{% endif %}">{{ cell.day }}</span>
</div> </div>
@@ -255,5 +256,5 @@
</script> </script>
<script src="/static/vendor/vis-timeline/vis-timeline-graph2d.min.js"></script> <script src="/static/vendor/vis-timeline/vis-timeline-graph2d.min.js"></script>
<script src="/static/project.js?v=20260625e" defer></script> <script src="/static/project.js?v=20260625f" defer></script>
{% endblock %} {% endblock %}
+4
View File
@@ -137,6 +137,10 @@
.pj-bar-l { border-top-left-radius: 0; border-bottom-left-radius: 0; margin-left: 0; } .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-r { border-top-right-radius: 0; border-bottom-right-radius: 0; margin-right: 0; }
.pj-bar-done { opacity: .75; text-decoration: line-through; } .pj-bar-done { opacity: .75; text-decoration: line-through; }
/* 드래그 이동 — 드래그 중엔 bar 가 이벤트 가로채지 않게(날짜 셀이 드롭 받도록) */
.pj-cal-dragging .pj-bar { pointer-events: none; }
.pj-bar[draggable="true"] { cursor: grab; }
.pj-day.pj-drop-over { background: #e6ecfa; box-shadow: inset 0 0 0 2px #aebfe6; }
/* ── 타임라인 컨테이너 ── */ /* ── 타임라인 컨테이너 ── */
#pj-timeline { background: #fff; border-radius: 12px; padding: 6px; } #pj-timeline { background: #fff; border-radius: 12px; padding: 6px; }
+55 -1
View File
@@ -127,11 +127,26 @@
} }
// ── 달력 (휴가식 서버 렌더 그리드) ── // ── 달력 (휴가식 서버 렌더 그리드) ──
// bar 는 서버가 그려둠. 클릭하면 업무 모달 열기(관리 권한 시). // bar 는 서버가 그려둠. 클릭=업무 모달, 드래그=일정 이동(관리 권한 시).
let calendarWired = false; let calendarWired = false;
// 날짜 문자열 더하기(일 단위). 'YYYY-MM-DD' 입출력.
function addDays(dstr, days) {
if (!dstr) return null;
const d = new Date(dstr + "T00:00:00");
d.setDate(d.getDate() + days);
const y = d.getFullYear(), m = String(d.getMonth() + 1).padStart(2, "0"), dd = String(d.getDate()).padStart(2, "0");
return y + "-" + m + "-" + dd;
}
function dayDiff(a, b) {
return Math.round((new Date(b + "T00:00:00") - new Date(a + "T00:00:00")) / 86400000);
}
function wireCalendar() { function wireCalendar() {
if (calendarWired) return; if (calendarWired) return;
calendarWired = true; calendarWired = true;
const cal = document.querySelector(".pj-cal");
document.querySelectorAll(".pj-bar[data-task-id]").forEach(function (bar) { document.querySelectorAll(".pj-bar[data-task-id]").forEach(function (bar) {
bar.addEventListener("click", function (e) { bar.addEventListener("click", function (e) {
e.preventDefault(); e.preventDefault();
@@ -139,6 +154,45 @@
const t = tasks.find(function (x) { return String(x.id) === bar.dataset.taskId; }); const t = tasks.find(function (x) { return String(x.id) === bar.dataset.taskId; });
if (t) openTaskModal(t); if (t) openTaskModal(t);
}); });
if (canManage) {
bar.draggable = true;
bar.addEventListener("dragstart", function (e) {
e.dataTransfer.setData("text/plain", bar.dataset.taskId);
e.dataTransfer.effectAllowed = "move";
if (cal) cal.classList.add("pj-cal-dragging");
});
bar.addEventListener("dragend", function () {
if (cal) cal.classList.remove("pj-cal-dragging");
document.querySelectorAll(".pj-day.pj-drop-over").forEach(function (d) { d.classList.remove("pj-drop-over"); });
});
}
});
if (!canManage || !cal) return;
document.querySelectorAll(".pj-day[data-date]").forEach(function (cell) {
cell.addEventListener("dragover", function (e) { e.preventDefault(); cell.classList.add("pj-drop-over"); });
cell.addEventListener("dragleave", function () { cell.classList.remove("pj-drop-over"); });
cell.addEventListener("drop", async function (e) {
e.preventDefault();
cell.classList.remove("pj-drop-over");
const taskId = e.dataTransfer.getData("text/plain");
const t = tasks.find(function (x) { return String(x.id) === String(taskId); });
if (!t) return;
const dropDate = cell.dataset.date;
// 앵커(시작일 우선, 없으면 마감일) 기준 이동량 → 기간 유지하며 시프트
const anchor = t.start_date || t.due_date;
if (!anchor) 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); }
});
}); });
} }