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" %}
|
||||
|
||||
{% 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" />
|
||||
{% endblock %}
|
||||
|
||||
@@ -45,5 +45,5 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script src="/static/project.js?v=20260916k" defer></script>
|
||||
<script src="/static/project.js?v=20260916l" defer></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% 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" />
|
||||
{% endblock %}
|
||||
|
||||
@@ -376,5 +376,5 @@
|
||||
<script>
|
||||
window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }};
|
||||
</script>
|
||||
<script src="/static/project.js?v=20260916k" defer></script>
|
||||
<script src="/static/project.js?v=20260916l" defer></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}
|
||||
<link rel="stylesheet" href="/static/project.css?v=20260916i" />
|
||||
<link rel="stylesheet" href="/static/project.css?v=20260916j" />
|
||||
<!-- 구글 머티리얼 심볼(담당자 아이콘 등) — self-host -->
|
||||
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
|
||||
<!-- 타임라인 vis-timeline — self-host -->
|
||||
@@ -139,13 +139,17 @@
|
||||
<div id="pj-timeline"></div>
|
||||
</div>
|
||||
<!-- 보드(칸반=세션) — 아사나처럼 프로젝트에 들어오면 기본으로 보이는 화면.
|
||||
4x2 그리드로 배치하고, 세션이 늘어나 보드 안에서 스크롤이 생겨도
|
||||
"+ 세션 추가" 버튼은 스크롤 영역 밖(고정 위치)에 있어 항상 보인다. -->
|
||||
최대 4열 매이슨리로 배치하고, 세션이 늘어나면 pj-board 안에서만
|
||||
세로 스크롤된다(좌측 프로젝트 목록·우측 내 업무와 스크롤이 서로
|
||||
안 얽히게 각자 독립). "+ 세션 추가" 버튼은 스크롤 영역 밖(고정
|
||||
위치)에 있어 항상 보인다. -->
|
||||
<div class="pj-view" data-view="board">
|
||||
<div class="pj-board-toolbar">
|
||||
<button type="button" class="pj-add-stage" id="pj-board-addstage" hidden>+ 세션 추가</button>
|
||||
</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 class="pj-view" data-view="list" hidden>
|
||||
@@ -379,5 +383,5 @@
|
||||
</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 %}
|
||||
|
||||
+33
-14
@@ -126,18 +126,29 @@
|
||||
.pj-mytask-title { font-size: 13.5px; font-weight: 600; }
|
||||
.pj-mytask-meta { margin-left: auto; font-size: 11px; color: #9aa1a9; }
|
||||
|
||||
/* ── 프로젝트 페이지 레이아웃 (좌: 프로젝트 트리 / 가운데: 뷰 / 우: 내 업무) ──
|
||||
가운데 칸에 min-width:0 이 없으면 보드에 세션이 많이 늘어날 때 grid 트랙
|
||||
자체가 넓어져 페이지 전체가 가로 스크롤된다(overflow-x:auto 는 .pj-board
|
||||
안에서만 작동해야 한다) — 반드시 필요한 규칙. */
|
||||
.pj-layout { display: grid; grid-template-columns: 230px 1fr 300px; gap: 22px; align-items: start; }
|
||||
.pj-main { min-width: 0; }
|
||||
.pj-project-mytasks { min-width: 0; width: 300px; }
|
||||
/* ── 프로젝트 페이지 레이아웃 (좌: 프로젝트 트리 / 가운데: 세션 등 뷰 / 우:
|
||||
내 업무) — 화면(=.erp-page) 자체는 스크롤하지 않고 3칸이 각자 안에서만
|
||||
스크롤한다(하나가 길어져도 다른 칸이 같이 스크롤되지 않게). 화면 높이를
|
||||
그대로 쓰기 위해 휴가/쿠팡 모듈과 같은 패턴으로 .erp-page 의 flex 자식을
|
||||
늘려 남은 높이를 다 차지하게 하고, 가운데 폭 제한(max-width:1550px)도
|
||||
이 화면에서만 풀어서 좌/우 칸이 진짜 화면 양 끝에 붙게 한다. */
|
||||
.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) {
|
||||
.pj-layout { grid-template-columns: 230px 1fr; }
|
||||
.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-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-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-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); }
|
||||
@@ -198,12 +215,14 @@
|
||||
세션이 바로 위 세션 아래에 붙는다). 세션이 많아지면 이 영역 안에서만
|
||||
세로 스크롤되고, "+ 세션 추가" 버튼은 이 영역 밖(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 {
|
||||
position: relative; max-height: 74vh; overflow-y: auto; overflow-x: hidden;
|
||||
padding-right: 4px; padding-bottom: 8px;
|
||||
}
|
||||
/* pj-board = 스크롤 뷰포트(남은 세로 공간을 그대로 씀, 부모가 이미 flex로
|
||||
높이를 정해준다). pj-board-canvas = 매이슨리 절대배치 캔버스 — 스크롤
|
||||
컨테이너 자체 높이와 콘텐츠(캔버스) 높이 계산을 분리해야 스크롤이
|
||||
올바르게 생긴다(캔버스만 커지고 뷰포트는 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 ≈
|
||||
흰 배경) 뚜렷한 회색 톤 + 테두리로 아사나처럼 컬럼 경계를 분명히 준다.
|
||||
position/width/left/top 은 layoutMasonry 가 매번 인라인으로 계산해 준다. */
|
||||
|
||||
+38
-8
@@ -1102,13 +1102,16 @@
|
||||
}
|
||||
|
||||
function renderBoard() {
|
||||
const board = el("pj-board");
|
||||
board.innerHTML = "";
|
||||
// pj-board(바깥)는 overflow-y:auto 스크롤 뷰포트, pj-board-canvas(안쪽)가
|
||||
// 실제 매이슨리 절대배치 캔버스 — 스크롤 컨테이너와 콘텐츠 높이 계산을
|
||||
// 분리해야 pj-board 의 높이가 부모(flex) 크기를 그대로 따라간다.
|
||||
const canvas = el("pj-board-canvas");
|
||||
canvas.innerHTML = "";
|
||||
const vis = visibleTasks();
|
||||
if (boardAddStageBtn) boardAddStageBtn.hidden = !(viewScope === "project" && canManage);
|
||||
if (viewScope === "project") renderBoardByStage(board, vis);
|
||||
else renderBoardByName(board, vis);
|
||||
layoutMasonry(board);
|
||||
if (viewScope === "project") renderBoardByStage(canvas, vis);
|
||||
else renderBoardByName(canvas, vis);
|
||||
layoutMasonry(canvas);
|
||||
}
|
||||
|
||||
// 세션(보드 컬럼) 매이슨리 배치 — 열은 왼쪽부터 순서대로 고정 배정하고,
|
||||
@@ -1137,7 +1140,7 @@
|
||||
window.addEventListener("resize", function () {
|
||||
if (currentView !== "board") return;
|
||||
clearTimeout(boardResizeTimer);
|
||||
boardResizeTimer = setTimeout(function () { layoutMasonry(el("pj-board")); }, 120);
|
||||
boardResizeTimer = setTimeout(function () { layoutMasonry(el("pj-board-canvas")); }, 120);
|
||||
});
|
||||
|
||||
function renderBoardByName(board, vis) {
|
||||
@@ -2050,6 +2053,29 @@
|
||||
positionImgTools();
|
||||
});
|
||||
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) {
|
||||
taskId = id;
|
||||
@@ -2063,10 +2089,13 @@
|
||||
|
||||
function insertHtmlAtCursor(html) {
|
||||
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;
|
||||
try { inserted = document.execCommand("insertHTML", false, html); } catch (_) { inserted = false; }
|
||||
if (!inserted) {
|
||||
const sel = window.getSelection();
|
||||
if (sel && sel.rangeCount && box.contains(sel.anchorNode)) {
|
||||
const range = sel.getRangeAt(0);
|
||||
range.deleteContents();
|
||||
@@ -2077,6 +2106,7 @@
|
||||
box.innerHTML += html;
|
||||
}
|
||||
}
|
||||
saveSelection();
|
||||
}
|
||||
|
||||
// 삽입 전 원본 크기를 확인해, 설명란보다 넓으면 90%로 줄여서 넣는다
|
||||
@@ -2133,7 +2163,7 @@
|
||||
|
||||
return {
|
||||
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; },
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user