diff --git a/app/modules/project/templates/project/inbox.html b/app/modules/project/templates/project/inbox.html
index 5b76100..aace27d 100644
--- a/app/modules/project/templates/project/inbox.html
+++ b/app/modules/project/templates/project/inbox.html
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
-
+
{% endblock %}
@@ -45,5 +45,5 @@
{% endif %}
-
+
{% endblock %}
diff --git a/app/modules/project/templates/project/index.html b/app/modules/project/templates/project/index.html
index dad5632..b02cf60 100644
--- a/app/modules/project/templates/project/index.html
+++ b/app/modules/project/templates/project/index.html
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
-
+
{% endblock %}
@@ -292,7 +292,9 @@
-
+
@@ -343,6 +345,7 @@
+
프로젝트에서 열기
@@ -373,5 +376,5 @@
-
+
{% endblock %}
diff --git a/app/modules/project/templates/project/project.html b/app/modules/project/templates/project/project.html
index de8f7b5..7eb8dad 100644
--- a/app/modules/project/templates/project/project.html
+++ b/app/modules/project/templates/project/project.html
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
-
+
@@ -220,7 +220,9 @@
-
+
@@ -277,6 +279,7 @@
+
@@ -326,5 +329,5 @@
-
+
{% endblock %}
diff --git a/app/static/project.css b/app/static/project.css
index 6a0fe10..318bb66 100644
--- a/app/static/project.css
+++ b/app/static/project.css
@@ -310,16 +310,23 @@
.pj-modal { position: fixed; inset: 0; background: rgba(20,22,26,.46); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.pj-modal[hidden] { display: none; }
.pj-modal-card { background: #fff; border-radius: 16px; padding: 22px; width: min(480px, 92vw); box-shadow: 0 20px 60px rgba(0,0,0,.25); max-height: 90vh; overflow-y: auto; }
-/* 업무 편집 팝업만 넓게 — 메타/설명(좌) + 하위업무·첨부·연결·댓글(우) 2단.
- 우측 칼럼만 자체 스크롤 시켜 팝업 전체 스크롤은 웬만하면 안 생기게 한다. */
-.pj-task-modal-card { width: min(780px, 96vw); }
-.pj-modal-body { display: grid; grid-template-columns: 1.15fr .85fr; gap: 0 26px; align-items: start; }
-.pj-modal-col-main, .pj-modal-col-side { min-width: 0; }
-.pj-modal-col-side { max-height: 66vh; overflow-y: auto; padding-right: 4px; }
+/* 업무 편집 팝업만 고정 크기(800x1100) — 메타/설명(좌) + 하위업무·첨부·연결·
+ 댓글(우) 2단. 카드 자체는 스크롤 없이 고정하고, 좌우 칼럼이 각자 내부
+ 스크롤한다(우측은 늘 자체 스크롤, 좌측은 내용이 넘칠 때만). */
+.pj-task-modal-card {
+ width: 800px; max-width: 96vw; height: 1100px; max-height: 94vh;
+ display: flex; flex-direction: column; overflow: hidden;
+}
+.pj-task-modal-card > .pj-t-title-input { flex: none; }
+.pj-task-modal-card .pj-modal-body { flex: 1 1 auto; min-height: 0; }
+.pj-task-modal-card .pj-modal-actions { flex: none; }
+.pj-modal-body { display: grid; grid-template-columns: 1.15fr .85fr; gap: 0 26px; align-items: stretch; height: 100%; }
+.pj-modal-col-main, .pj-modal-col-side { min-width: 0; min-height: 0; overflow-y: auto; padding-right: 4px; }
.pj-modal-col-side .pj-task-extra:first-child { margin-top: 0; padding-top: 0; border-top: none; }
@media (max-width: 720px) {
- .pj-modal-body { grid-template-columns: 1fr; }
- .pj-modal-col-side { max-height: none; overflow-y: visible; padding-right: 0; }
+ .pj-task-modal-card { width: min(480px, 94vw); height: auto; max-height: 90vh; overflow-y: auto; display: block; }
+ .pj-modal-body { display: block; height: auto; }
+ .pj-modal-col-main, .pj-modal-col-side { overflow: visible; height: auto; padding-right: 0; }
.pj-modal-col-side .pj-task-extra:first-child { border-top: 1px solid #eef0f2; margin-top: 14px; padding-top: 12px; }
}
.pj-modal-card h3 { margin: 0 0 16px; font-size: 17px; }
@@ -359,8 +366,10 @@
.pj-form-row input[type="time"] { display: block; width: 100%; margin-top: 5px; padding: 8px 10px; border: 1px solid #d8dce2; border-radius: 8px; font-size: 13.5px; font-family: inherit; box-sizing: border-box; }
.pj-form-row input[type="time"]:disabled { background: #f2f2f2; color: #aaa; cursor: not-allowed; }
#pj-time-row:has(input:disabled) label { color: #aaa; }
-.pj-modal-actions { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
-.pj-modal-actions .pj-btn:last-child { }
+.pj-modal-actions { display: flex; align-items: center; gap: 8px; margin-top: 8px; flex-wrap: wrap; }
+.pj-btn-complete { background: #e9f7ef; color: #1e8e5a; border: 1px solid #bfe8d2; font-weight: 700; }
+.pj-btn-complete:hover { background: #d9f2e4; }
+.pj-btn-complete.is-done { background: #f2f2f4; color: #6b6f76; border-color: var(--pj-border); }
.pj-color-palette, #pj-f-colors { display: flex; gap: 8px; margin-top: 6px; }
.pj-color-swatch { width: 24px; height: 24px; border-radius: 50%; border: 2px solid transparent; cursor: pointer; }
@@ -476,9 +485,27 @@
line-height: 1.5; margin-bottom: 12px; background: #fff;
}
.pj-desc-editor:empty::before { content: attr(data-placeholder); color: #b7bdc5; }
-.pj-desc-editor img { max-width: 100%; border-radius: 6px; margin: 4px 0; display: block; }
+.pj-desc-editor img { max-width: 100%; border-radius: 6px; margin: 4px 0; display: block; cursor: pointer; }
+.pj-desc-editor img.pj-img-selected { outline: 2px solid var(--pj-accent); outline-offset: 1px; }
.pj-desc-editor[contenteditable="false"] { background: #f6f7f8; color: #9aa1a9; }
+/* 설명란 이미지 선택 시 오버레이(삭제 × / 우하단 크기조절 핸들) — wrap 은
+ position:relative, tools 는 선택된 img 의 offsetLeft/Top/Width/Height 로
+ 자리를 맞춘다(box 자체는 static 이라 offsetParent 가 wrap 이 된다). */
+.pj-desc-wrap { position: relative; }
+.pj-img-tools { position: absolute; pointer-events: none; }
+.pj-img-del {
+ position: absolute; top: -9px; right: -9px; pointer-events: auto;
+ width: 20px; height: 20px; border-radius: 50%; border: none; background: #1e1f21; color: #fff;
+ font-size: 13px; line-height: 1; cursor: pointer; display: flex; align-items: center; justify-content: center; padding: 0;
+}
+.pj-img-del:hover { background: #c22b10; }
+.pj-img-handle {
+ position: absolute; bottom: -6px; right: -6px; pointer-events: auto;
+ width: 13px; height: 13px; border-radius: 3px; background: var(--pj-accent); cursor: nwse-resize;
+ border: 2px solid #fff; box-shadow: 0 0 0 1px rgba(0,0,0,.25);
+}
+
/* ── 하위 업무(인라인) ── */
.pj-subtask-list { list-style: none; margin: 0 0 8px; padding: 0; }
.pj-subtask-row { display: flex; align-items: center; gap: 6px; padding: 4px 2px; }
@@ -518,8 +545,14 @@
}
.pj-link-chip .pj-link-del { border: none; background: transparent; cursor: pointer; color: #9aa1a9; font-size: 13px; line-height: 1; padding: 2px; }
.pj-link-chip .pj-link-del:hover { color: #c22b10; }
-.pj-link-form { display: flex; gap: 6px; align-items: center; }
-.pj-link-form select { flex: 1; padding: 7px 8px; border: 1px solid #d8dce2; border-radius: 8px; font-size: 13px; font-family: inherit; }
+/* 세로로 쌓기 — 옆 칼럼이 좁아 가로 배치면 select 글자가 잘린다. */
+.pj-link-form { display: flex; flex-direction: column; gap: 6px; align-items: stretch; }
+.pj-link-form select {
+ width: 100% !important; flex: none; margin: 0 !important;
+ padding: 6px 8px !important; border: 1px solid #d8dce2 !important; border-radius: 8px;
+ font-size: 11.5px !important; font-family: inherit; box-sizing: border-box;
+}
+.pj-link-form .pj-btn { align-self: flex-end; }
/* ── 알림센터 ── */
.pj-inbox { list-style: none; margin: 0; padding: 0; }
diff --git a/app/static/project.js b/app/static/project.js
index 336a8fa..4f649c1 100644
--- a/app/static/project.js
+++ b/app/static/project.js
@@ -348,6 +348,8 @@
el("pj-t-dtime").disabled = !useTime;
el("pj-t-open-project").href = "/project/p/" + task.project_id + "?task=" + task.id;
el("pj-delete-task").hidden = false;
+ el("pj-toggle-complete").hidden = false;
+ setHomeCompleteBtn(!!task.completed_at);
el("pj-t-open-project").hidden = false;
el("pj-attach-block").hidden = false;
el("pj-comment-block").hidden = false;
@@ -377,6 +379,7 @@
el("pj-t-stime").value = ""; el("pj-t-stime").disabled = true;
el("pj-t-dtime").value = ""; el("pj-t-dtime").disabled = true;
el("pj-delete-task").hidden = true;
+ el("pj-toggle-complete").hidden = true;
el("pj-t-open-project").hidden = true;
el("pj-attach-block").hidden = true;
el("pj-comment-block").hidden = true;
@@ -385,6 +388,21 @@
openModal(homeTaskModal);
await Promise.all([fillAssignee(projectId, ""), fillStage(projectId, null)]);
}
+ function setHomeCompleteBtn(done) {
+ const btn = el("pj-toggle-complete");
+ btn.textContent = done ? "↩ 완료 취소" : "✓ 완료로 표시";
+ btn.classList.toggle("is-done", done);
+ btn.dataset.completed = done ? "1" : "";
+ }
+ el("pj-toggle-complete").addEventListener("click", async function () {
+ const id = el("pj-t-id").value;
+ if (!id) return;
+ const done = el("pj-toggle-complete").dataset.completed === "1";
+ try {
+ await api("PUT", "/project/api/tasks/" + id, { completed: !done });
+ location.reload();
+ } catch (e) { alert("처리 실패: " + e.message); }
+ });
document.querySelectorAll(".pj-card-addtask").forEach(function (btn) {
btn.addEventListener("click", function (e) {
e.preventDefault(); e.stopPropagation();
@@ -1584,6 +1602,8 @@
el("pj-t-stime").disabled = !useTime;
el("pj-t-dtime").disabled = !useTime;
el("pj-delete-task").hidden = !task;
+ el("pj-toggle-complete").hidden = !task;
+ if (task) setCompleteBtn(!!task.completed_at);
// 댓글·첨부·하위업무·연결은 기존 업무 편집 시에만
currentTaskId = task ? task.id : null;
el("pj-attach-block").hidden = !task;
@@ -1594,6 +1614,24 @@
openModal(taskModal);
}
+ function setCompleteBtn(done) {
+ const btn = el("pj-toggle-complete");
+ btn.textContent = done ? "↩ 완료 취소" : "✓ 완료로 표시";
+ btn.classList.toggle("is-done", done);
+ btn.dataset.completed = done ? "1" : "";
+ }
+ el("pj-toggle-complete").addEventListener("click", async function () {
+ const id = el("pj-t-id").value;
+ if (!id) return;
+ const done = el("pj-toggle-complete").dataset.completed === "1";
+ try {
+ const res = await api("PUT", "/project/api/tasks/" + id, { completed: !done });
+ mergeTask(res.task);
+ setCompleteBtn(!!res.task.completed_at);
+ refreshActiveView();
+ } catch (e) { alert("처리 실패: " + e.message); }
+ });
+
// ── 첨부파일 ──
function fmtSize(n) {
if (n < 1024) return n + " B";
@@ -1906,10 +1944,70 @@
function initDescEditor() {
const box = el("pj-t-desc");
if (!box) return null;
+ const wrap = box.parentElement;
const imgInput = el("pj-desc-img-input");
const hint = el("pj-desc-hint");
let taskId = null;
+ // ── 설명란 이미지 선택 → 크기 조절 핸들 + 삭제 버튼 오버레이 ──
+ // 크기는 style이 아니라 width 속성으로 저장한다(서버 sanitize가 style은
+ // 버리지만 img width/height 속성은 허용 목록에 있어 저장 후에도 유지된다).
+ const imgTools = document.createElement("div");
+ imgTools.className = "pj-img-tools";
+ imgTools.hidden = true;
+ imgTools.innerHTML =
+ '' +
+ '';
+ wrap.appendChild(imgTools);
+ const imgDelBtn = imgTools.querySelector(".pj-img-del");
+ const imgHandle = imgTools.querySelector(".pj-img-handle");
+ let selectedImg = null;
+
+ function positionImgTools() {
+ if (!selectedImg || !selectedImg.isConnected) { selectedImg = null; imgTools.hidden = true; return; }
+ imgTools.style.left = selectedImg.offsetLeft + "px";
+ imgTools.style.top = selectedImg.offsetTop + "px";
+ imgTools.style.width = selectedImg.offsetWidth + "px";
+ imgTools.style.height = selectedImg.offsetHeight + "px";
+ imgTools.hidden = false;
+ }
+ function selectImg(img) {
+ if (selectedImg) selectedImg.classList.remove("pj-img-selected");
+ selectedImg = img || null;
+ if (selectedImg) { selectedImg.classList.add("pj-img-selected"); positionImgTools(); }
+ else { imgTools.hidden = true; }
+ }
+ box.addEventListener("click", function (e) {
+ if (e.target.tagName === "IMG") selectImg(e.target);
+ else selectImg(null);
+ });
+ document.addEventListener("click", function (e) {
+ if (selectedImg && !wrap.contains(e.target)) selectImg(null);
+ });
+ imgDelBtn.addEventListener("click", function () {
+ if (selectedImg) selectedImg.remove();
+ selectImg(null);
+ });
+ let dragging = false, dragStartX = 0, dragStartW = 0;
+ imgHandle.addEventListener("mousedown", function (e) {
+ if (!selectedImg) return;
+ e.preventDefault();
+ dragging = true;
+ dragStartX = e.clientX;
+ dragStartW = selectedImg.offsetWidth;
+ });
+ document.addEventListener("mousemove", function (e) {
+ if (!dragging || !selectedImg) return;
+ let newW = Math.round(dragStartW + (e.clientX - dragStartX));
+ newW = Math.max(40, Math.min(newW, wrap.clientWidth || 9999));
+ selectedImg.setAttribute("width", String(newW));
+ selectedImg.removeAttribute("height");
+ selectedImg.style.width = "";
+ selectedImg.style.height = "";
+ positionImgTools();
+ });
+ document.addEventListener("mouseup", function () { dragging = false; });
+
function setTaskId(id) {
taskId = id;
const enabled = !!id;
@@ -1973,7 +2071,7 @@
return {
setTaskId: setTaskId,
- setHtml: function (html) { box.innerHTML = html || ""; },
+ setHtml: function (html) { selectImg(null); box.innerHTML = html || ""; },
getHtml: function () { return box.innerHTML; },
};
}
diff --git a/app/templates/erp_base.html b/app/templates/erp_base.html
index 45b0a06..8929d05 100644
--- a/app/templates/erp_base.html
+++ b/app/templates/erp_base.html
@@ -16,6 +16,15 @@