feat(project): 좌/가운데/우 3칸 독립 스크롤 + 화면 양끝 배치, 설명 이미지 편의개선
- 프로젝트 상세 화면을 휴가/쿠팡 모듈처럼 "한 화면 채움" 레이아웃으로 변경. 좌측 프로젝트 목록/가운데 세션·리스트·달력·타임라인/우측 내 업무가 각자 안에서만 스크롤되고 서로 얽히지 않는다. 이 화면에서만 페이지 폭 제한을 풀어 좌측은 화면 맨 왼쪽, 우측 내 업무는 화면 맨 오른쪽에 붙는다. - 설명란 이미지: 파일 업로드 버튼으로 넣을 때도 커서 위치에 삽입되도록 선택범위를 기억했다가 복원(그동안은 항상 맨 끝에 붙었음). 이미지를 선택한 상태에서 Delete/Backspace 키로도 삭제 가능. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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=20260916i" />
|
<link rel="stylesheet" href="/static/project.css?v=20260916j" />
|
||||||
<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=20260916k" defer></script>
|
<script src="/static/project.js?v=20260916l" 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=20260916i" />
|
<link rel="stylesheet" href="/static/project.css?v=20260916j" />
|
||||||
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
|
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -376,5 +376,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=20260916k" defer></script>
|
<script src="/static/project.js?v=20260916l" 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=20260916i" />
|
<link rel="stylesheet" href="/static/project.css?v=20260916j" />
|
||||||
<!-- 구글 머티리얼 심볼(담당자 아이콘 등) — 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 -->
|
||||||
@@ -139,13 +139,17 @@
|
|||||||
<div id="pj-timeline"></div>
|
<div id="pj-timeline"></div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 보드(칸반=세션) — 아사나처럼 프로젝트에 들어오면 기본으로 보이는 화면.
|
<!-- 보드(칸반=세션) — 아사나처럼 프로젝트에 들어오면 기본으로 보이는 화면.
|
||||||
4x2 그리드로 배치하고, 세션이 늘어나 보드 안에서 스크롤이 생겨도
|
최대 4열 매이슨리로 배치하고, 세션이 늘어나면 pj-board 안에서만
|
||||||
"+ 세션 추가" 버튼은 스크롤 영역 밖(고정 위치)에 있어 항상 보인다. -->
|
세로 스크롤된다(좌측 프로젝트 목록·우측 내 업무와 스크롤이 서로
|
||||||
|
안 얽히게 각자 독립). "+ 세션 추가" 버튼은 스크롤 영역 밖(고정
|
||||||
|
위치)에 있어 항상 보인다. -->
|
||||||
<div class="pj-view" data-view="board">
|
<div class="pj-view" data-view="board">
|
||||||
<div class="pj-board-toolbar">
|
<div class="pj-board-toolbar">
|
||||||
<button type="button" class="pj-add-stage" id="pj-board-addstage" hidden>+ 세션 추가</button>
|
<button type="button" class="pj-add-stage" id="pj-board-addstage" hidden>+ 세션 추가</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="pj-board" id="pj-board"></div>
|
<div class="pj-board" id="pj-board">
|
||||||
|
<div class="pj-board-canvas" id="pj-board-canvas"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 리스트 -->
|
<!-- 리스트 -->
|
||||||
<div class="pj-view" data-view="list" hidden>
|
<div class="pj-view" data-view="list" hidden>
|
||||||
@@ -379,5 +383,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=20260916k" defer></script>
|
<script src="/static/project.js?v=20260916l" defer></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+33
-14
@@ -126,18 +126,29 @@
|
|||||||
.pj-mytask-title { font-size: 13.5px; font-weight: 600; }
|
.pj-mytask-title { font-size: 13.5px; font-weight: 600; }
|
||||||
.pj-mytask-meta { margin-left: auto; font-size: 11px; color: #9aa1a9; }
|
.pj-mytask-meta { margin-left: auto; font-size: 11px; color: #9aa1a9; }
|
||||||
|
|
||||||
/* ── 프로젝트 페이지 레이아웃 (좌: 프로젝트 트리 / 가운데: 뷰 / 우: 내 업무) ──
|
/* ── 프로젝트 페이지 레이아웃 (좌: 프로젝트 트리 / 가운데: 세션 등 뷰 / 우:
|
||||||
가운데 칸에 min-width:0 이 없으면 보드에 세션이 많이 늘어날 때 grid 트랙
|
내 업무) — 화면(=.erp-page) 자체는 스크롤하지 않고 3칸이 각자 안에서만
|
||||||
자체가 넓어져 페이지 전체가 가로 스크롤된다(overflow-x:auto 는 .pj-board
|
스크롤한다(하나가 길어져도 다른 칸이 같이 스크롤되지 않게). 화면 높이를
|
||||||
안에서만 작동해야 한다) — 반드시 필요한 규칙. */
|
그대로 쓰기 위해 휴가/쿠팡 모듈과 같은 패턴으로 .erp-page 의 flex 자식을
|
||||||
.pj-layout { display: grid; grid-template-columns: 230px 1fr 300px; gap: 22px; align-items: start; }
|
늘려 남은 높이를 다 차지하게 하고, 가운데 폭 제한(max-width:1550px)도
|
||||||
.pj-main { min-width: 0; }
|
이 화면에서만 풀어서 좌/우 칸이 진짜 화면 양 끝에 붙게 한다. */
|
||||||
.pj-project-mytasks { min-width: 0; width: 300px; }
|
.erp-page:has(> .pj-project-view) { max-width: none; }
|
||||||
|
.pj-project-view { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
|
||||||
|
.pj-project-view .pj-layout { flex: 1 1 auto; min-height: 0; }
|
||||||
|
.pj-layout { display: grid; grid-template-columns: 230px 1fr 300px; gap: 22px; height: 100%; min-height: 0; align-items: stretch; }
|
||||||
|
.pj-side { min-width: 0; min-height: 0; height: 100%; overflow-y: auto; padding-right: 4px; }
|
||||||
|
.pj-main { min-width: 0; min-height: 0; height: 100%; display: flex; flex-direction: column; }
|
||||||
|
.pj-project-mytasks { min-width: 0; min-height: 0; width: 300px; height: 100%; overflow-y: auto; padding-right: 4px; }
|
||||||
@media (max-width: 1280px) {
|
@media (max-width: 1280px) {
|
||||||
.pj-layout { grid-template-columns: 230px 1fr; }
|
.pj-layout { grid-template-columns: 230px 1fr; }
|
||||||
.pj-project-mytasks { display: none; }
|
.pj-project-mytasks { display: none; }
|
||||||
}
|
}
|
||||||
@media (max-width: 900px) { .pj-layout { grid-template-columns: 1fr; } }
|
@media (max-width: 900px) {
|
||||||
|
.erp-page:has(> .pj-project-view) { max-width: 1550px; }
|
||||||
|
.pj-project-view { display: block; }
|
||||||
|
.pj-layout { display: block; height: auto; }
|
||||||
|
.pj-side, .pj-main { height: auto; overflow: visible; }
|
||||||
|
}
|
||||||
|
|
||||||
.pj-side-block { margin-bottom: 22px; }
|
.pj-side-block { margin-bottom: 22px; }
|
||||||
.pj-side-head { display: flex; align-items: center; justify-content: space-between; font-size: 12px; font-weight: 700; color: #6b7280; text-transform: uppercase; letter-spacing: .04em; margin-bottom: 8px; }
|
.pj-side-head { display: flex; align-items: center; justify-content: space-between; font-size: 12px; font-weight: 700; color: #6b7280; text-transform: uppercase; letter-spacing: .04em; margin-bottom: 8px; }
|
||||||
@@ -175,7 +186,13 @@
|
|||||||
.pj-avatar.pj-gicon-sm { width: 18px; height: 18px; }
|
.pj-avatar.pj-gicon-sm { width: 18px; height: 18px; }
|
||||||
|
|
||||||
/* ── 툴바 + 탭 ── */
|
/* ── 툴바 + 탭 ── */
|
||||||
.pj-toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
|
.pj-toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; flex: none; }
|
||||||
|
/* 현재 활성 뷰(보드/리스트/달력/타임라인)가 pj-main 의 남은 세로 공간을
|
||||||
|
차지 — 뷰 하나만 자체 스크롤(다른 칸과 스크롤이 서로 안 얽히게). 보드는
|
||||||
|
내부에 pj-board 자체 스크롤 컨테이너가 따로 있어 여기선 넘침을 숨긴다. */
|
||||||
|
.pj-view { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
|
||||||
|
.pj-view[hidden] { display: none; }
|
||||||
|
.pj-view[data-view="board"]:not([hidden]) { display: flex; flex-direction: column; overflow: hidden; }
|
||||||
.pj-view-tabs { display: inline-flex; background: #eef0f2; border-radius: 10px; padding: 3px; gap: 2px; }
|
.pj-view-tabs { display: inline-flex; background: #eef0f2; border-radius: 10px; padding: 3px; gap: 2px; }
|
||||||
.pj-tab { border: none; background: transparent; padding: 7px 16px; border-radius: 8px; font-size: 13px; font-weight: 600; color: #525860; cursor: pointer; }
|
.pj-tab { border: none; background: transparent; padding: 7px 16px; border-radius: 8px; font-size: 13px; font-weight: 600; color: #525860; cursor: pointer; }
|
||||||
.pj-tab.is-active { background: var(--pj-accent); color: #fff; box-shadow: 0 1px 3px rgba(123,104,238,.35); }
|
.pj-tab.is-active { background: var(--pj-accent); color: #fff; box-shadow: 0 1px 3px rgba(123,104,238,.35); }
|
||||||
@@ -198,12 +215,14 @@
|
|||||||
세션이 바로 위 세션 아래에 붙는다). 세션이 많아지면 이 영역 안에서만
|
세션이 바로 위 세션 아래에 붙는다). 세션이 많아지면 이 영역 안에서만
|
||||||
세로 스크롤되고, "+ 세션 추가" 버튼은 이 영역 밖(pj-board-toolbar)에
|
세로 스크롤되고, "+ 세션 추가" 버튼은 이 영역 밖(pj-board-toolbar)에
|
||||||
있어 스크롤과 무관하게 항상 보인다. ── */
|
있어 스크롤과 무관하게 항상 보인다. ── */
|
||||||
.pj-board-toolbar { display: flex; justify-content: flex-end; margin-bottom: 10px; }
|
.pj-board-toolbar { display: flex; justify-content: flex-end; margin-bottom: 10px; flex: none; }
|
||||||
.pj-board-toolbar .pj-add-stage { width: auto; padding: 8px 18px; }
|
.pj-board-toolbar .pj-add-stage { width: auto; padding: 8px 18px; }
|
||||||
.pj-board {
|
/* pj-board = 스크롤 뷰포트(남은 세로 공간을 그대로 씀, 부모가 이미 flex로
|
||||||
position: relative; max-height: 74vh; overflow-y: auto; overflow-x: hidden;
|
높이를 정해준다). pj-board-canvas = 매이슨리 절대배치 캔버스 — 스크롤
|
||||||
padding-right: 4px; padding-bottom: 8px;
|
컨테이너 자체 높이와 콘텐츠(캔버스) 높이 계산을 분리해야 스크롤이
|
||||||
}
|
올바르게 생긴다(캔버스만 커지고 뷰포트는 flex 크기를 유지). */
|
||||||
|
.pj-board { flex: 1 1 auto; min-height: 0; overflow-y: auto; overflow-x: hidden; padding-right: 4px; padding-bottom: 8px; }
|
||||||
|
.pj-board-canvas { position: relative; }
|
||||||
/* 세션(보드 컬럼) — 배경이 페이지와 거의 같으면 구분이 안 되므로(#f6f5fb ≈
|
/* 세션(보드 컬럼) — 배경이 페이지와 거의 같으면 구분이 안 되므로(#f6f5fb ≈
|
||||||
흰 배경) 뚜렷한 회색 톤 + 테두리로 아사나처럼 컬럼 경계를 분명히 준다.
|
흰 배경) 뚜렷한 회색 톤 + 테두리로 아사나처럼 컬럼 경계를 분명히 준다.
|
||||||
position/width/left/top 은 layoutMasonry 가 매번 인라인으로 계산해 준다. */
|
position/width/left/top 은 layoutMasonry 가 매번 인라인으로 계산해 준다. */
|
||||||
|
|||||||
+38
-8
@@ -1102,13 +1102,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderBoard() {
|
function renderBoard() {
|
||||||
const board = el("pj-board");
|
// pj-board(바깥)는 overflow-y:auto 스크롤 뷰포트, pj-board-canvas(안쪽)가
|
||||||
board.innerHTML = "";
|
// 실제 매이슨리 절대배치 캔버스 — 스크롤 컨테이너와 콘텐츠 높이 계산을
|
||||||
|
// 분리해야 pj-board 의 높이가 부모(flex) 크기를 그대로 따라간다.
|
||||||
|
const canvas = el("pj-board-canvas");
|
||||||
|
canvas.innerHTML = "";
|
||||||
const vis = visibleTasks();
|
const vis = visibleTasks();
|
||||||
if (boardAddStageBtn) boardAddStageBtn.hidden = !(viewScope === "project" && canManage);
|
if (boardAddStageBtn) boardAddStageBtn.hidden = !(viewScope === "project" && canManage);
|
||||||
if (viewScope === "project") renderBoardByStage(board, vis);
|
if (viewScope === "project") renderBoardByStage(canvas, vis);
|
||||||
else renderBoardByName(board, vis);
|
else renderBoardByName(canvas, vis);
|
||||||
layoutMasonry(board);
|
layoutMasonry(canvas);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 세션(보드 컬럼) 매이슨리 배치 — 열은 왼쪽부터 순서대로 고정 배정하고,
|
// 세션(보드 컬럼) 매이슨리 배치 — 열은 왼쪽부터 순서대로 고정 배정하고,
|
||||||
@@ -1137,7 +1140,7 @@
|
|||||||
window.addEventListener("resize", function () {
|
window.addEventListener("resize", function () {
|
||||||
if (currentView !== "board") return;
|
if (currentView !== "board") return;
|
||||||
clearTimeout(boardResizeTimer);
|
clearTimeout(boardResizeTimer);
|
||||||
boardResizeTimer = setTimeout(function () { layoutMasonry(el("pj-board")); }, 120);
|
boardResizeTimer = setTimeout(function () { layoutMasonry(el("pj-board-canvas")); }, 120);
|
||||||
});
|
});
|
||||||
|
|
||||||
function renderBoardByName(board, vis) {
|
function renderBoardByName(board, vis) {
|
||||||
@@ -2050,6 +2053,29 @@
|
|||||||
positionImgTools();
|
positionImgTools();
|
||||||
});
|
});
|
||||||
document.addEventListener("mouseup", function () { dragging = false; });
|
document.addEventListener("mouseup", function () { dragging = false; });
|
||||||
|
document.addEventListener("keydown", function (e) {
|
||||||
|
if (!selectedImg) return;
|
||||||
|
if (e.key !== "Delete" && e.key !== "Backspace") return;
|
||||||
|
const active = document.activeElement;
|
||||||
|
if (active && ["INPUT", "TEXTAREA", "SELECT"].indexOf(active.tagName) >= 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
selectedImg.remove();
|
||||||
|
selectImg(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 업로드 버튼 클릭 → 파일 선택창이 뜨는 동안 contenteditable의 선택 범위가
|
||||||
|
// 풀린다 — 마지막으로 박스 안에 있던 커서 위치를 기억해뒀다가 삽입 직전에
|
||||||
|
// 되살린다(안 그러면 이미지가 항상 맨 끝에 붙어버린다).
|
||||||
|
let lastRange = null;
|
||||||
|
function saveSelection() {
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (sel && sel.rangeCount && box.contains(sel.anchorNode)) {
|
||||||
|
lastRange = sel.getRangeAt(0).cloneRange();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
box.addEventListener("keyup", saveSelection);
|
||||||
|
box.addEventListener("mouseup", saveSelection);
|
||||||
|
box.addEventListener("blur", saveSelection);
|
||||||
|
|
||||||
function setTaskId(id) {
|
function setTaskId(id) {
|
||||||
taskId = id;
|
taskId = id;
|
||||||
@@ -2063,10 +2089,13 @@
|
|||||||
|
|
||||||
function insertHtmlAtCursor(html) {
|
function insertHtmlAtCursor(html) {
|
||||||
box.focus();
|
box.focus();
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (lastRange && (!sel.rangeCount || !box.contains(sel.anchorNode))) {
|
||||||
|
try { sel.removeAllRanges(); sel.addRange(lastRange); } catch (_) { lastRange = null; }
|
||||||
|
}
|
||||||
let inserted = false;
|
let inserted = false;
|
||||||
try { inserted = document.execCommand("insertHTML", false, html); } catch (_) { inserted = false; }
|
try { inserted = document.execCommand("insertHTML", false, html); } catch (_) { inserted = false; }
|
||||||
if (!inserted) {
|
if (!inserted) {
|
||||||
const sel = window.getSelection();
|
|
||||||
if (sel && sel.rangeCount && box.contains(sel.anchorNode)) {
|
if (sel && sel.rangeCount && box.contains(sel.anchorNode)) {
|
||||||
const range = sel.getRangeAt(0);
|
const range = sel.getRangeAt(0);
|
||||||
range.deleteContents();
|
range.deleteContents();
|
||||||
@@ -2077,6 +2106,7 @@
|
|||||||
box.innerHTML += html;
|
box.innerHTML += html;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
saveSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 삽입 전 원본 크기를 확인해, 설명란보다 넓으면 90%로 줄여서 넣는다
|
// 삽입 전 원본 크기를 확인해, 설명란보다 넓으면 90%로 줄여서 넣는다
|
||||||
@@ -2133,7 +2163,7 @@
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
setTaskId: setTaskId,
|
setTaskId: setTaskId,
|
||||||
setHtml: function (html) { selectImg(null); box.innerHTML = html || ""; },
|
setHtml: function (html) { selectImg(null); lastRange = null; box.innerHTML = html || ""; },
|
||||||
getHtml: function () { return box.innerHTML; },
|
getHtml: function () { return box.innerHTML; },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user