feat(project): 카드 드래그 순서변경/전체멤버/탭 슬라이드/내업무 실시간반영
- 홈 프로젝트 카드 드래그 재정렬(관리자, projects.sort_order 공유) - 프로젝트 제목 색상 pill 스타일로 강조 - 보드 세션 우클릭에 "업무 추가" 메뉴 추가 - 좌측 "멤버"→"프로젝트 멤버" + "전체 멤버" 그리드(클릭 시 담당 업무 팝업) - 뷰 탭/스코프 토글에 슬라이드 하이라이트 애니메이션 - "내 업무" 패널이 8초 실시간 폴링에서 빠져 있던 문제 수정(홈/프로젝트 화면 모두 /api/my-tasks 로 patch) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -801,6 +801,18 @@ class ProjectStore:
|
|||||||
raise KeyError(stage_id)
|
raise KeyError(stage_id)
|
||||||
return self._serialize(row)
|
return self._serialize(row)
|
||||||
|
|
||||||
|
def reorder_projects(self, *, ordered_ids: list[int]) -> None:
|
||||||
|
"""홈 화면 프로젝트 카드 드래그 순서 변경. 최상위 프로젝트(parent_id
|
||||||
|
IS NULL)만 대상 — 서브프로젝트 정렬(부모 안에서의 순서)은 건드리지 않는다."""
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
with conn.transaction():
|
||||||
|
for i, pid in enumerate(ordered_ids):
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE projects SET sort_order = %s "
|
||||||
|
"WHERE id = %s AND parent_id IS NULL",
|
||||||
|
(i, int(pid)),
|
||||||
|
)
|
||||||
|
|
||||||
def reorder_stages(self, *, project_id: int, ordered_ids: list[int]) -> None:
|
def reorder_stages(self, *, project_id: int, ordered_ids: list[int]) -> None:
|
||||||
"""주어진 순서대로 sort_order 재배정. 해당 프로젝트 단계만 갱신."""
|
"""주어진 순서대로 sort_order 재배정. 해당 프로젝트 단계만 갱신."""
|
||||||
with self._pool.connection() as conn:
|
with self._pool.connection() as conn:
|
||||||
|
|||||||
@@ -732,6 +732,22 @@ async def api_delete_project(request: Request, project_id: int) -> JSONResponse:
|
|||||||
return JSONResponse({"ok": True})
|
return JSONResponse({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/projects/order")
|
||||||
|
async def api_reorder_projects(
|
||||||
|
request: Request, payload: dict[str, Any] = Body(...)
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""홈 화면 프로젝트 카드 드래그 순서 변경. 최상위 프로젝트만 대상이며,
|
||||||
|
순서는 이 화면을 보는 모든 사용자에게 공유되므로(세션 재정렬과 동일 원칙)
|
||||||
|
관리자 전용으로 제한한다."""
|
||||||
|
_require_admin(request)
|
||||||
|
st = _db_or_503(request)
|
||||||
|
ids = payload.get("ordered_ids") or []
|
||||||
|
if not isinstance(ids, list) or not ids:
|
||||||
|
raise HTTPException(status_code=400, detail="ordered_ids 가 필요합니다.")
|
||||||
|
st.reorder_projects(ordered_ids=[int(i) for i in ids])
|
||||||
|
return JSONResponse({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────
|
||||||
# JSON API — 멤버 (관리자가 사용자 배정)
|
# JSON API — 멤버 (관리자가 사용자 배정)
|
||||||
# ────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────
|
||||||
@@ -759,6 +775,38 @@ async def api_assignable_users(request: Request) -> JSONResponse:
|
|||||||
return JSONResponse({"users": out})
|
return JSONResponse({"users": out})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/all-users")
|
||||||
|
async def api_all_users(request: Request) -> JSONResponse:
|
||||||
|
"""프로젝트 화면 좌측 "전체 멤버" 표시용 — project 모듈 권한 보유 등록
|
||||||
|
사용자 전원. assignable-users 와 후보군은 같지만, 이건 멤버 배정(쓰기)이
|
||||||
|
아니라 단순 열람용이라 관리자가 아니어도 볼 수 있다."""
|
||||||
|
_require_user(request)
|
||||||
|
from app.main import user_store # noqa: WPS433
|
||||||
|
from app.store import has_module # noqa: WPS433
|
||||||
|
|
||||||
|
out = [
|
||||||
|
{
|
||||||
|
"email": u["email"],
|
||||||
|
"name": u.get("name") or u["email"].split("@")[0],
|
||||||
|
"id": u["email"].split("@")[0],
|
||||||
|
"avatar": u.get("picture") or "",
|
||||||
|
}
|
||||||
|
for u in user_store.list_all()
|
||||||
|
if has_module(u, "project")
|
||||||
|
]
|
||||||
|
out.sort(key=lambda x: x["id"])
|
||||||
|
return JSONResponse({"users": out})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/users/tasks")
|
||||||
|
async def api_user_tasks(request: Request, email: str) -> JSONResponse:
|
||||||
|
"""'전체 멤버' 아이콘 클릭 팝업 — 지정한 사용자가 담당자인 업무 목록
|
||||||
|
(프로젝트/세션/마감일). /api/my-tasks 와 같은 조회를 이메일만 바꿔 재사용."""
|
||||||
|
_require_user(request)
|
||||||
|
st = _db_or_503(request)
|
||||||
|
return JSONResponse({"tasks": st.list_tasks(assignee_email=email, limit=500)})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/projects/{project_id}/members")
|
@router.get("/api/projects/{project_id}/members")
|
||||||
async def api_list_members(request: Request, project_id: int) -> JSONResponse:
|
async def api_list_members(request: Request, project_id: int) -> JSONResponse:
|
||||||
_require_user(request)
|
_require_user(request)
|
||||||
|
|||||||
@@ -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=20260917i" />
|
<link rel="stylesheet" href="/static/project.css?v=20260917j" />
|
||||||
<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=20260917n" defer></script>
|
<script src="/static/project.js?v=20260917o" 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=20260917i" />
|
<link rel="stylesheet" href="/static/project.css?v=20260917j" />
|
||||||
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
|
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -393,5 +393,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=20260917n" defer></script>
|
<script src="/static/project.js?v=20260917o" 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=20260917i" />
|
<link rel="stylesheet" href="/static/project.css?v=20260917j" />
|
||||||
<!-- 구글 머티리얼 심볼(담당자 아이콘 등) — 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 -->
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
|
|
||||||
<div class="pj-side-block">
|
<div class="pj-side-block">
|
||||||
<div class="pj-side-head">
|
<div class="pj-side-head">
|
||||||
<span>멤버</span>
|
<span>프로젝트 멤버</span>
|
||||||
{% if is_admin %}
|
{% if is_admin %}
|
||||||
<button type="button" class="pj-icon-btn" id="pj-add-member" title="멤버 배정">+</button>
|
<button type="button" class="pj-icon-btn" id="pj-add-member" title="멤버 배정">+</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -90,6 +90,13 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 전체 멤버 — 등록된 전체 직원(프로젝트 권한 보유자). 아이콘 클릭 시
|
||||||
|
그 사람이 담당자인 업무를 팝업으로 보여준다(프로젝트/세션/마감일). -->
|
||||||
|
<div class="pj-side-block">
|
||||||
|
<div class="pj-side-head"><span>전체 멤버</span></div>
|
||||||
|
<div class="pj-allmembers-grid" id="pj-allmembers-grid">불러오는 중…</div>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- ── 우측: 뷰 ── -->
|
<!-- ── 우측: 뷰 ── -->
|
||||||
@@ -102,7 +109,9 @@
|
|||||||
<button type="button" class="pj-tab" data-view="timeline">타임라인</button>
|
<button type="button" class="pj-tab" data-view="timeline">타임라인</button>
|
||||||
<button type="button" class="pj-tab" data-view="dashboard">대시보드</button>
|
<button type="button" class="pj-tab" data-view="dashboard">대시보드</button>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="pj-project-title">{{ project.name }}</h1>
|
<h1 class="pj-project-title">
|
||||||
|
<span class="pj-project-title-pill" style="--pj-color: {{ project.color or '#4573d2' }}">{{ project.name }}</span>
|
||||||
|
</h1>
|
||||||
<!-- 아사나처럼 기본은 "이 프로젝트"만 보여준다. 전체 프로젝트 보기는
|
<!-- 아사나처럼 기본은 "이 프로젝트"만 보여준다. 전체 프로젝트 보기는
|
||||||
예전처럼 접근 가능한 모든 프로젝트를 합쳐서 보여주는 모드. -->
|
예전처럼 접근 가능한 모든 프로젝트를 합쳐서 보여주는 모드. -->
|
||||||
<div class="pj-view-tabs" id="pj-scope-toggle">
|
<div class="pj-view-tabs" id="pj-scope-toggle">
|
||||||
@@ -451,6 +460,18 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- 전체 멤버 아이콘 클릭 팝업 — 그 사람이 담당자인 업무를 프로젝트/세션/마감일과 함께 나열 -->
|
||||||
|
<div class="pj-modal" id="pj-modal-member-tasks" hidden>
|
||||||
|
<div class="pj-modal-card pj-mt-card">
|
||||||
|
<h3 id="pj-mt-title">업무</h3>
|
||||||
|
<ul class="pj-mt-list" id="pj-mt-list"></ul>
|
||||||
|
<div class="pj-modal-actions">
|
||||||
|
<span style="flex:1"></span>
|
||||||
|
<button type="button" class="pj-btn" data-close>닫기</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>window.PJ_COLORS = {{ color_palette | tojson }};</script>
|
<script>window.PJ_COLORS = {{ color_palette | tojson }};</script>
|
||||||
<!-- 데이터 -->
|
<!-- 데이터 -->
|
||||||
<script id="pj-data" type="application/json">
|
<script id="pj-data" type="application/json">
|
||||||
@@ -470,5 +491,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=20260917n" defer></script>
|
<script src="/static/project.js?v=20260917o" defer></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+44
-4
@@ -66,6 +66,8 @@
|
|||||||
background: #fff; box-shadow: var(--pj-card-shadow); transition: box-shadow .14s, transform .1s;
|
background: #fff; box-shadow: var(--pj-card-shadow); transition: box-shadow .14s, transform .1s;
|
||||||
}
|
}
|
||||||
.pj-project-card:hover { box-shadow: var(--pj-card-shadow-hover); transform: translateY(-1px); }
|
.pj-project-card:hover { box-shadow: var(--pj-card-shadow-hover); transform: translateY(-1px); }
|
||||||
|
.pj-project-card[draggable="true"] { cursor: grab; }
|
||||||
|
.pj-project-card.is-dragging { opacity: .45; }
|
||||||
.pj-project-cardlink { display: flex; gap: 12px; padding: 16px; text-decoration: none; color: inherit; }
|
.pj-project-cardlink { display: flex; gap: 12px; padding: 16px; text-decoration: none; color: inherit; }
|
||||||
/* 완료 프로젝트 — 비활성(흐리게) */
|
/* 완료 프로젝트 — 비활성(흐리게) */
|
||||||
.pj-project-card.is-done { background: #f6f7f8; opacity: .62; }
|
.pj-project-card.is-done { background: #f6f7f8; opacity: .62; }
|
||||||
@@ -239,7 +241,11 @@
|
|||||||
.pj-toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; flex: none; }
|
.pj-toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; flex: none; }
|
||||||
.pj-project-title {
|
.pj-project-title {
|
||||||
flex: 1 1 auto; min-width: 0; margin: 0; padding: 0 16px; text-align: center;
|
flex: 1 1 auto; min-width: 0; margin: 0; padding: 0 16px; text-align: center;
|
||||||
font-size: 20px; font-weight: 800; color: var(--pj-ink);
|
}
|
||||||
|
.pj-project-title-pill {
|
||||||
|
display: inline-block; max-width: 100%; vertical-align: middle;
|
||||||
|
font-size: 22px; font-weight: 800; color: #fff;
|
||||||
|
background: var(--pj-color, var(--pj-accent)); border-radius: 14px; padding: 8px 24px;
|
||||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
}
|
}
|
||||||
/* 현재 활성 뷰(보드/리스트/달력/타임라인)가 pj-main 의 남은 세로 공간을
|
/* 현재 활성 뷰(보드/리스트/달력/타임라인)가 pj-main 의 남은 세로 공간을
|
||||||
@@ -248,9 +254,17 @@
|
|||||||
.pj-view { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
|
.pj-view { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
|
||||||
.pj-view[hidden] { display: none; }
|
.pj-view[hidden] { display: none; }
|
||||||
.pj-view[data-view="board"]:not([hidden]) { display: flex; flex-direction: column; overflow: hidden; }
|
.pj-view[data-view="board"]:not([hidden]) { display: flex; flex-direction: column; overflow: hidden; }
|
||||||
.pj-view-tabs { display: inline-flex; flex: 0 0 auto; background: #eef0f2; border-radius: 10px; padding: 3px; gap: 2px; }
|
.pj-view-tabs { display: inline-flex; position: relative; flex: 0 0 auto; 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; white-space: nowrap; }
|
.pj-tab { position: relative; z-index: 1; border: none; background: transparent; padding: 7px 16px; border-radius: 8px; font-size: 13px; font-weight: 600; color: #525860; cursor: pointer; white-space: nowrap; }
|
||||||
.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: transparent; color: #fff; }
|
||||||
|
/* 뷰 탭이 바뀔 때 배경이 새 탭 위치로 미끄러지는 하이라이트 — is-active 의
|
||||||
|
단순 배경색 대신 이 절대배치 요소 하나가 실제로 움직인다. */
|
||||||
|
.pj-tab-highlight {
|
||||||
|
position: absolute; top: 3px; left: 0; z-index: 0;
|
||||||
|
background: var(--pj-accent); border-radius: 8px; box-shadow: 0 1px 3px rgba(123,104,238,.35);
|
||||||
|
transition: transform .22s cubic-bezier(.4,0,.2,1), width .22s cubic-bezier(.4,0,.2,1);
|
||||||
|
}
|
||||||
|
.pj-tab-highlight.pj-no-anim { transition: none; }
|
||||||
|
|
||||||
/* 알림 벨 흔들림(미읽음 있을 때) */
|
/* 알림 벨 흔들림(미읽음 있을 때) */
|
||||||
@keyframes pj-bell-ring {
|
@keyframes pj-bell-ring {
|
||||||
@@ -577,6 +591,32 @@
|
|||||||
.pj-member-pick-avatar img.pj-member-pick-icon { width: 36px; height: 36px; }
|
.pj-member-pick-avatar img.pj-member-pick-icon { width: 36px; height: 36px; }
|
||||||
.pj-member-pick-stacked .pj-member-pick-name { font-size: 12.5px; }
|
.pj-member-pick-stacked .pj-member-pick-name { font-size: 12.5px; }
|
||||||
|
|
||||||
|
/* ── 좌측 사이드바 "전체 멤버" — 4열 그리드, 아이콘 아래 이름(새 프로젝트
|
||||||
|
모달의 pj-member-pick-stacked 와 같은 아이콘 크기 재사용). ── */
|
||||||
|
.pj-allmembers-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px 4px; }
|
||||||
|
.pj-allmember-item {
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 4px;
|
||||||
|
padding: 6px 2px; border: none; background: none; border-radius: 8px; cursor: pointer;
|
||||||
|
font: inherit; width: 100%;
|
||||||
|
}
|
||||||
|
.pj-allmember-item:hover { background: #f5f6f8; }
|
||||||
|
.pj-allmember-item .pj-member-pick-icon.material-symbols-outlined { font-size: 40px; }
|
||||||
|
.pj-allmember-item img.pj-member-pick-icon { width: 36px; height: 36px; }
|
||||||
|
.pj-allmember-name { font-size: 11.5px; color: #333; text-align: center; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* ── 전체 멤버 클릭 팝업 — 담당 업무 목록(프로젝트/세션/제목/마감일) ── */
|
||||||
|
.pj-mt-card { width: min(600px, 94vw); }
|
||||||
|
.pj-mt-list { list-style: none; margin: 0 0 10px; padding: 0; max-height: 50vh; overflow-y: auto; }
|
||||||
|
.pj-mt-row {
|
||||||
|
display: grid; grid-template-columns: 100px 90px 1fr 80px; gap: 8px; align-items: center;
|
||||||
|
padding: 8px 6px; border-bottom: 1px solid #f1f2f4; font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.pj-mt-row.is-done { color: #9aa1a9; text-decoration: line-through; }
|
||||||
|
.pj-mt-proj { font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.pj-mt-stage { color: #6b7280; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.pj-mt-title2 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.pj-mt-due { text-align: right; color: var(--pj-ink-soft); font-weight: 600; white-space: nowrap; }
|
||||||
|
|
||||||
/* ── 댓글만 보는 채팅형 팝업(카톡 대화창 느낌) ── */
|
/* ── 댓글만 보는 채팅형 팝업(카톡 대화창 느낌) ── */
|
||||||
/* 크기 고정(500x850) — 내용이 많아도 팝업 크기는 그대로, 목록만 스크롤. */
|
/* 크기 고정(500x850) — 내용이 많아도 팝업 크기는 그대로, 목록만 스크롤. */
|
||||||
.pj-modal-chat {
|
.pj-modal-chat {
|
||||||
|
|||||||
+198
-6
@@ -114,6 +114,37 @@
|
|||||||
return mm + "/" + dd + "(" + wd + ")";
|
return mm + "/" + dd + "(" + wd + ")";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 탭 그룹(뷰 탭/스코프 토글, 둘 다 .pj-view-tabs 마크업 공유)에 슬라이드
|
||||||
|
// 하이라이트를 붙인다 — is-active 를 단순 배경색 대신, 실제 위치가 움직이는
|
||||||
|
// 절대배치 요소 하나로 표현한다. 탭 클릭·초기 렌더·리사이즈마다 위치 재계산.
|
||||||
|
function initTabSlider(container) {
|
||||||
|
if (!container || container.dataset.pjSlider) return null;
|
||||||
|
container.dataset.pjSlider = "1";
|
||||||
|
const hl = document.createElement("div");
|
||||||
|
hl.className = "pj-tab-highlight";
|
||||||
|
container.insertBefore(hl, container.firstChild);
|
||||||
|
function move(animate) {
|
||||||
|
const active = container.querySelector(".pj-tab.is-active");
|
||||||
|
if (!active) { hl.style.opacity = "0"; return; }
|
||||||
|
hl.style.opacity = "1";
|
||||||
|
if (!animate) hl.classList.add("pj-no-anim");
|
||||||
|
hl.style.width = active.offsetWidth + "px";
|
||||||
|
hl.style.height = active.offsetHeight + "px";
|
||||||
|
hl.style.transform = "translateX(" + active.offsetLeft + "px)";
|
||||||
|
if (!animate) {
|
||||||
|
// 강제 리플로우로 즉시 반영시킨 뒤 트랜지션을 다시 켠다(다음 클릭부터 애니메이션).
|
||||||
|
void hl.offsetWidth;
|
||||||
|
hl.classList.remove("pj-no-anim");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
move(false);
|
||||||
|
container.addEventListener("click", function (e) {
|
||||||
|
if (e.target.closest(".pj-tab")) requestAnimationFrame(function () { move(true); });
|
||||||
|
});
|
||||||
|
window.addEventListener("resize", function () { move(false); });
|
||||||
|
return move;
|
||||||
|
}
|
||||||
|
|
||||||
// 멤버 선택 후보 카드(아이콘 위 / 이름 가운데 / 체크박스 아래) — 새 프로젝트
|
// 멤버 선택 후보 카드(아이콘 위 / 이름 가운데 / 체크박스 아래) — 새 프로젝트
|
||||||
// 모달(홈·프로젝트 페이지)과 멤버 배정 모달 3곳이 전부 이 함수 하나를 쓴다.
|
// 모달(홈·프로젝트 페이지)과 멤버 배정 모달 3곳이 전부 이 함수 하나를 쓴다.
|
||||||
// /api/assignable-users 응답의 avatar 필드를 그대로 쓰므로 페이지별
|
// /api/assignable-users 응답의 avatar 필드를 그대로 쓰므로 페이지별
|
||||||
@@ -286,6 +317,13 @@
|
|||||||
// 카드 하나에 수정/업무추가/우클릭 메뉴를 건다 — 최초 로드된 카드·새로
|
// 카드 하나에 수정/업무추가/우클릭 메뉴를 건다 — 최초 로드된 카드·새로
|
||||||
// 만든 카드 모두 이 함수 하나로 통일(initHome 맨 끝에서 전체 카드에 호출).
|
// 만든 카드 모두 이 함수 하나로 통일(initHome 맨 끝에서 전체 카드에 호출).
|
||||||
function wireProjectCard(card) {
|
function wireProjectCard(card) {
|
||||||
|
if (isAdmin) {
|
||||||
|
card.draggable = true;
|
||||||
|
// <a> 는 브라우저 기본값이 draggable="true" — 그대로 두면 카드 대신
|
||||||
|
// 링크(URL) 드래그가 시작돼 우리 dragstart 핸들러가 무력화된다.
|
||||||
|
const link = card.querySelector(".pj-project-cardlink");
|
||||||
|
if (link) link.draggable = false;
|
||||||
|
}
|
||||||
const editBtn = card.querySelector(".pj-card-edit");
|
const editBtn = card.querySelector(".pj-card-edit");
|
||||||
if (editBtn) editBtn.addEventListener("click", function (e) {
|
if (editBtn) editBtn.addEventListener("click", function (e) {
|
||||||
e.preventDefault(); e.stopPropagation();
|
e.preventDefault(); e.stopPropagation();
|
||||||
@@ -1039,6 +1077,74 @@
|
|||||||
|
|
||||||
// 최초 로드된 카드 + 이후 새로 만든 카드 모두 여기서 한 번에 배선.
|
// 최초 로드된 카드 + 이후 새로 만든 카드 모두 여기서 한 번에 배선.
|
||||||
document.querySelectorAll(".pj-project-card").forEach(wireProjectCard);
|
document.querySelectorAll(".pj-project-card").forEach(wireProjectCard);
|
||||||
|
|
||||||
|
// 프로젝트 카드 드래그로 순서 변경 — 정렬은 화면을 보는 모두에게 공유되므로
|
||||||
|
// (세션 재정렬과 같은 원칙) 관리자만 드래그할 수 있다(wireProjectCard 가
|
||||||
|
// isAdmin 일 때만 draggable 을 켠다).
|
||||||
|
if (isAdmin) wireProjectGridDrag();
|
||||||
|
|
||||||
|
// ── 실시간 동기화: "내 업무" 패널만 8초 안에 반영(카드 미리보기 목록은
|
||||||
|
// 기존처럼 다음 새로고침까지 유지 — initLiveRefresh 주석 참고). 다른
|
||||||
|
// 사용자가 내 업무를 수정/완료/재배정해도 여기서 바로 patch 된다.
|
||||||
|
softSyncProjectRef = async function () {
|
||||||
|
try {
|
||||||
|
const res = await api("GET", "/project/api/my-tasks");
|
||||||
|
const fresh = res.tasks || [];
|
||||||
|
const freshIds = new Set(fresh.map(function (t) { return t.id; }));
|
||||||
|
document.querySelectorAll(".pj-mytree-group .pj-mytree-tasks .pj-task-popup-link[data-task-id]").forEach(function (a) {
|
||||||
|
const id = Number(a.dataset.taskId);
|
||||||
|
if (freshIds.has(id)) return;
|
||||||
|
const group = a.closest(".pj-mytree-group");
|
||||||
|
const pid = group ? Number(group.dataset.groupId) : null;
|
||||||
|
syncMyTaskPanel({ id: id, project_id: pid }, true);
|
||||||
|
});
|
||||||
|
fresh.forEach(function (t) { homeTasks[t.id] = t; syncMyTaskPanel(t); });
|
||||||
|
} catch (_) {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 프로젝트 카드 드래그 순서 변경 — 진행중/완료 그리드 각각 안에서만
|
||||||
|
// 재정렬(그리드 사이 드래그로 옮기면 상태가 바뀐 것처럼 보이니 막는다).
|
||||||
|
function wireProjectGridDrag() {
|
||||||
|
const grids = [el("pj-project-grid"), el("pj-project-grid-done")].filter(Boolean);
|
||||||
|
if (!grids.length) return;
|
||||||
|
let dragCard = null, sourceGrid = null;
|
||||||
|
grids.forEach(function (grid) {
|
||||||
|
grid.addEventListener("dragstart", function (e) {
|
||||||
|
const card = e.target.closest(".pj-project-card");
|
||||||
|
if (!card || card.draggable !== true) return;
|
||||||
|
dragCard = card;
|
||||||
|
sourceGrid = grid;
|
||||||
|
e.dataTransfer.setData("application/x-pj-project", String(card.dataset.id));
|
||||||
|
e.dataTransfer.effectAllowed = "move";
|
||||||
|
card.classList.add("is-dragging");
|
||||||
|
});
|
||||||
|
grid.addEventListener("dragend", function () {
|
||||||
|
if (dragCard) dragCard.classList.remove("is-dragging");
|
||||||
|
dragCard = null; sourceGrid = null;
|
||||||
|
});
|
||||||
|
grid.addEventListener("dragover", function (e) {
|
||||||
|
if (!dragCard || grid !== sourceGrid) return;
|
||||||
|
const target = e.target.closest(".pj-project-card");
|
||||||
|
if (!target || target === dragCard) { e.preventDefault(); return; }
|
||||||
|
e.preventDefault();
|
||||||
|
const r = target.getBoundingClientRect();
|
||||||
|
const before = (e.clientX - r.left) < r.width / 2;
|
||||||
|
grid.insertBefore(dragCard, before ? target : target.nextSibling);
|
||||||
|
});
|
||||||
|
grid.addEventListener("drop", async function (e) {
|
||||||
|
if (!dragCard || grid !== sourceGrid) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const ids = Array.prototype.slice.call(grid.querySelectorAll(".pj-project-card"))
|
||||||
|
.map(function (c) { return parseInt(c.dataset.id, 10); });
|
||||||
|
try {
|
||||||
|
await api("PUT", "/project/api/projects/order", { ordered_ids: ids });
|
||||||
|
} catch (err) {
|
||||||
|
showToast("순서 변경 실패: " + err.message, "error");
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════
|
||||||
@@ -1209,6 +1315,7 @@
|
|||||||
|
|
||||||
// ── 뷰 토글 ──
|
// ── 뷰 토글 ──
|
||||||
const tabs = el("pj-view-tabs");
|
const tabs = el("pj-view-tabs");
|
||||||
|
const moveViewHighlight = initTabSlider(tabs);
|
||||||
tabs.addEventListener("click", function (e) {
|
tabs.addEventListener("click", function (e) {
|
||||||
const btn = e.target.closest(".pj-tab");
|
const btn = e.target.closest(".pj-tab");
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
@@ -1231,10 +1338,12 @@
|
|||||||
document.querySelectorAll(".pj-view").forEach(function (v) {
|
document.querySelectorAll(".pj-view").forEach(function (v) {
|
||||||
v.hidden = v.dataset.view !== currentView;
|
v.hidden = v.dataset.view !== currentView;
|
||||||
});
|
});
|
||||||
|
if (moveViewHighlight) moveViewHighlight(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 프로젝트 스코프 토글 ──
|
// ── 프로젝트 스코프 토글 ──
|
||||||
const scopeToggle = el("pj-scope-toggle");
|
const scopeToggle = el("pj-scope-toggle");
|
||||||
|
const moveScopeHighlight = initTabSlider(scopeToggle);
|
||||||
if (scopeToggle) {
|
if (scopeToggle) {
|
||||||
scopeToggle.addEventListener("click", function (e) {
|
scopeToggle.addEventListener("click", function (e) {
|
||||||
const btn = e.target.closest("[data-scope]");
|
const btn = e.target.closest("[data-scope]");
|
||||||
@@ -1246,6 +1355,7 @@
|
|||||||
renderView(currentView);
|
renderView(currentView);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (moveScopeHighlight) moveScopeHighlight(false);
|
||||||
|
|
||||||
function renderView(view) {
|
function renderView(view) {
|
||||||
if (view === "calendar") renderCalendar();
|
if (view === "calendar") renderCalendar();
|
||||||
@@ -1867,6 +1977,7 @@
|
|||||||
enableDropByStageId(body, s.id);
|
enableDropByStageId(body, s.id);
|
||||||
wireColumnControls(col, s);
|
wireColumnControls(col, s);
|
||||||
wireColumnDrag(col, s);
|
wireColumnDrag(col, s);
|
||||||
|
wireStageAddTask(body, s);
|
||||||
}
|
}
|
||||||
board.appendChild(col);
|
board.appendChild(col);
|
||||||
});
|
});
|
||||||
@@ -1931,6 +2042,7 @@
|
|||||||
if (head) head.addEventListener("contextmenu", function (e) {
|
if (head) head.addEventListener("contextmenu", function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
showContextMenu(e.clientX, e.clientY, [
|
showContextMenu(e.clientX, e.clientY, [
|
||||||
|
{ label: "업무 추가", onClick: function () { openTaskModal(null, s.id); } },
|
||||||
{ label: "이름 변경", onClick: renameStage },
|
{ label: "이름 변경", onClick: renameStage },
|
||||||
{ label: s.is_done_stage ? "완료 세션 해제" : "완료 세션으로 지정", onClick: toggleDoneStage },
|
{ label: s.is_done_stage ? "완료 세션 해제" : "완료 세션으로 지정", onClick: toggleDoneStage },
|
||||||
null,
|
null,
|
||||||
@@ -1939,6 +2051,18 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 세션 본문(카드 사이 빈 공간) 우클릭 — "업무 추가"만. 카드 위에서 우클릭한
|
||||||
|
// 경우는 taskCard() 쪽 컨텍스트 메뉴(수정/완료/삭제 등)가 대신 뜨도록 건너뛴다.
|
||||||
|
function wireStageAddTask(body, s) {
|
||||||
|
body.addEventListener("contextmenu", function (e) {
|
||||||
|
if (e.target.closest(".pj-card")) return;
|
||||||
|
e.preventDefault();
|
||||||
|
showContextMenu(e.clientX, e.clientY, [
|
||||||
|
{ label: "업무 추가", onClick: function () { openTaskModal(null, s.id); } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 세션(컬럼) 드래그 재정렬 — 카드 드래그와 같은 네이티브 HTML5 DnD 방식이되,
|
// 세션(컬럼) 드래그 재정렬 — 카드 드래그와 같은 네이티브 HTML5 DnD 방식이되,
|
||||||
// dataTransfer 타입을 달리 써서(application/x-pj-stage) 카드 드롭과 안 섞인다.
|
// dataTransfer 타입을 달리 써서(application/x-pj-stage) 카드 드롭과 안 섞인다.
|
||||||
function wireColumnDrag(col, s) {
|
function wireColumnDrag(col, s) {
|
||||||
@@ -2604,7 +2728,7 @@
|
|||||||
sel.value = currentEmail || "";
|
sel.value = currentEmail || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function openTaskModal(task) {
|
function openTaskModal(task, presetStageId) {
|
||||||
const projName = task ? (task.project_name || currentProject.name || "") : (currentProject.name || "");
|
const projName = task ? (task.project_name || currentProject.name || "") : (currentProject.name || "");
|
||||||
el("pj-task-modal-title").textContent = "[" + projName + "]의 " + (task ? "업무 편집" : "새 업무");
|
el("pj-task-modal-title").textContent = "[" + projName + "]의 " + (task ? "업무 편집" : "새 업무");
|
||||||
el("pj-t-id").value = task ? task.id : "";
|
el("pj-t-id").value = task ? task.id : "";
|
||||||
@@ -2612,7 +2736,8 @@
|
|||||||
if (descEditor) { descEditor.setTaskId(task ? task.id : null); descEditor.setHtml(task ? (task.description || "") : ""); }
|
if (descEditor) { descEditor.setTaskId(task ? task.id : null); descEditor.setHtml(task ? (task.description || "") : ""); }
|
||||||
fillAssigneeSelect(task ? task.project_id : projectId, task ? (task.assignee_email || "") : "");
|
fillAssigneeSelect(task ? task.project_id : projectId, task ? (task.assignee_email || "") : "");
|
||||||
fillStageSelect(task ? task.project_id : projectId);
|
fillStageSelect(task ? task.project_id : projectId);
|
||||||
el("pj-t-stage").value = task && task.stage_id ? task.stage_id : (stages[0] ? stages[0].id : "");
|
el("pj-t-stage").value = task && task.stage_id ? task.stage_id
|
||||||
|
: (presetStageId != null ? presetStageId : (stages[0] ? stages[0].id : ""));
|
||||||
el("pj-t-priority").value = task ? (task.priority || "normal") : "normal";
|
el("pj-t-priority").value = task ? (task.priority || "normal") : "normal";
|
||||||
el("pj-t-start").value = task ? (task.start_date || "") : "";
|
el("pj-t-start").value = task ? (task.start_date || "") : "";
|
||||||
el("pj-t-due").value = task ? (task.due_date || "") : "";
|
el("pj-t-due").value = task ? (task.due_date || "") : "";
|
||||||
@@ -3066,6 +3191,56 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
renderMemberList();
|
renderMemberList();
|
||||||
|
|
||||||
|
// ── 전체 멤버(프로젝트 권한 보유 등록 사용자 전원) — 아이콘 클릭 시
|
||||||
|
// 그 사람이 담당자인 업무를 팝업으로 보여준다(프로젝트/세션/마감일).
|
||||||
|
function allMemberItemHtml(u) {
|
||||||
|
const icon = u.avatar
|
||||||
|
? '<img class="pj-avatar pj-member-pick-icon" src="' + escapeHtml(u.avatar) + '" alt="" referrerpolicy="no-referrer" />'
|
||||||
|
: '<span class="material-symbols-outlined pj-gicon pj-member-pick-icon">account_circle</span>';
|
||||||
|
return '<button type="button" class="pj-allmember-item" data-email="' + escapeHtml(u.email) + '" data-name="' + escapeHtml(u.id) + '">' +
|
||||||
|
icon + '<span class="pj-allmember-name">' + escapeHtml(u.id) + "</span></button>";
|
||||||
|
}
|
||||||
|
const memberTasksModal = el("pj-modal-member-tasks");
|
||||||
|
if (memberTasksModal) wireModalClose(memberTasksModal);
|
||||||
|
async function openMemberTasksModal(email, name) {
|
||||||
|
if (!memberTasksModal) return;
|
||||||
|
el("pj-mt-title").textContent = (name || email) + "님의 업무";
|
||||||
|
const list = el("pj-mt-list");
|
||||||
|
list.innerHTML = "<li class='pj-empty-sm'>불러오는 중…</li>";
|
||||||
|
openModal(memberTasksModal);
|
||||||
|
try {
|
||||||
|
const res = await api("GET", "/project/api/users/tasks?email=" + encodeURIComponent(email));
|
||||||
|
const items = res.tasks || [];
|
||||||
|
list.innerHTML = items.length
|
||||||
|
? items.map(function (t) {
|
||||||
|
return '<li class="pj-mt-row' + (t.completed_at ? " is-done" : "") + '">' +
|
||||||
|
'<span class="pj-mt-proj" style="color:' + (t.project_color || "#4573d2") + '">' + escapeHtml(t.project_name || "-") + "</span>" +
|
||||||
|
'<span class="pj-mt-stage">' + escapeHtml(t.stage_name || "미지정") + "</span>" +
|
||||||
|
'<span class="pj-mt-title2">' + escapeHtml(t.title || "") + "</span>" +
|
||||||
|
'<span class="pj-mt-due">' + (t.due_date ? fmtDateKr(t.due_date) : "-") + "</span>" +
|
||||||
|
"</li>";
|
||||||
|
}).join("")
|
||||||
|
: "<li class='pj-empty-sm'>배정된 업무가 없습니다.</li>";
|
||||||
|
} catch (e) {
|
||||||
|
list.innerHTML = "<li class='pj-empty-sm'>불러오기 실패: " + escapeHtml(e.message) + "</li>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const allMembersGrid = el("pj-allmembers-grid");
|
||||||
|
if (allMembersGrid) {
|
||||||
|
api("GET", "/project/api/all-users").then(function (res) {
|
||||||
|
const users = res.users || [];
|
||||||
|
allMembersGrid.innerHTML = users.length
|
||||||
|
? users.map(allMemberItemHtml).join("")
|
||||||
|
: "<span class='pj-empty-sm'>없음</span>";
|
||||||
|
allMembersGrid.querySelectorAll(".pj-allmember-item").forEach(function (btn) {
|
||||||
|
btn.addEventListener("click", function () { openMemberTasksModal(btn.dataset.email, btn.dataset.name); });
|
||||||
|
});
|
||||||
|
}).catch(function (e) {
|
||||||
|
allMembersGrid.innerHTML = "<span class='pj-empty-sm'>불러오기 실패: " + escapeHtml(e.message) + "</span>";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const addMemberBtn = el("pj-add-member");
|
const addMemberBtn = el("pj-add-member");
|
||||||
if (addMemberBtn) addMemberBtn.addEventListener("click", async function () {
|
if (addMemberBtn) addMemberBtn.addEventListener("click", async function () {
|
||||||
const box = el("pj-m-users");
|
const box = el("pj-m-users");
|
||||||
@@ -3126,10 +3301,11 @@
|
|||||||
// live-version 자체가 감지 못하는 기존 사각지대 — 다음 새로고침까지 반영 안 됨.
|
// live-version 자체가 감지 못하는 기존 사각지대 — 다음 새로고침까지 반영 안 됨.
|
||||||
softSyncProjectRef = async function () {
|
softSyncProjectRef = async function () {
|
||||||
try {
|
try {
|
||||||
const [tRes, sRes, mRes] = await Promise.all([
|
const [tRes, sRes, mRes, myRes] = await Promise.all([
|
||||||
api("GET", "/project/api/projects/" + projectId + "/tasks"),
|
api("GET", "/project/api/projects/" + projectId + "/tasks"),
|
||||||
api("GET", "/project/api/projects/" + projectId + "/stages"),
|
api("GET", "/project/api/projects/" + projectId + "/stages"),
|
||||||
api("GET", "/project/api/projects/" + projectId + "/members"),
|
api("GET", "/project/api/projects/" + projectId + "/members"),
|
||||||
|
api("GET", "/project/api/my-tasks"),
|
||||||
]);
|
]);
|
||||||
// 서버 페이지 렌더 시엔 router.py 가 각 업무에 context_project_id(=지금
|
// 서버 페이지 렌더 시엔 router.py 가 각 업무에 context_project_id(=지금
|
||||||
// 보는 프로젝트 id)를 붙여준다(멀티호밍 스코프 필터의 기준) — 이 API
|
// 보는 프로젝트 id)를 붙여준다(멀티호밍 스코프 필터의 기준) — 이 API
|
||||||
@@ -3144,6 +3320,21 @@
|
|||||||
members = mRes.members || [];
|
members = mRes.members || [];
|
||||||
renderMemberList();
|
renderMemberList();
|
||||||
refreshActiveView();
|
refreshActiveView();
|
||||||
|
// 우측 "내 업무" 패널 — 다른 사람이 내 업무를 수정/완료/재배정해도
|
||||||
|
// 다음 전체 새로고침까지 기다리지 않고 8초 안에 반영되게 patch.
|
||||||
|
const freshMy = myRes.tasks || [];
|
||||||
|
const freshIds = new Set(freshMy.map(function (t) { return t.id; }));
|
||||||
|
Object.keys(myTasksById).forEach(function (idStr) {
|
||||||
|
const id = Number(idStr);
|
||||||
|
if (freshIds.has(id)) return;
|
||||||
|
const old = myTasksById[id];
|
||||||
|
delete myTasksById[id];
|
||||||
|
syncProjectMyTaskPanel(old, true);
|
||||||
|
});
|
||||||
|
freshMy.forEach(function (t) {
|
||||||
|
myTasksById[t.id] = t;
|
||||||
|
syncProjectMyTaskPanel(t);
|
||||||
|
});
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -3672,9 +3863,10 @@
|
|||||||
// ── 실시간 새로고침 ──
|
// ── 실시간 새로고침 ──
|
||||||
// 다른 사용자가 프로젝트/업무/댓글/멤버/단계를 추가·수정하면, 같은 화면을 열어둔
|
// 다른 사용자가 프로젝트/업무/댓글/멤버/단계를 추가·수정하면, 같은 화면을 열어둔
|
||||||
// 모든 사용자에게 8초 주기로 반영한다. 전체 새로고침 대신 배경 목록만 조용히
|
// 모든 사용자에게 8초 주기로 반영한다. 전체 새로고침 대신 배경 목록만 조용히
|
||||||
// 다시 받아 patch 한다(입력 중인 모달 폼은 건드리지 않음) — initProject() 가
|
// 다시 받아 patch 한다(입력 중인 모달 폼은 건드리지 않음) — initHome()/
|
||||||
// 끝나면서 softSyncProjectRef 를 채워준다(홈 페이지는 채우지 않아 벨 배지만
|
// initProject() 가 끝나면서 각자 softSyncProjectRef 를 채워준다(홈은 "내
|
||||||
// 갱신). initLiveRefresh 는 어느 페이지든(프로젝트/홈) 항상 이 하나로 처리.
|
// 업무" 패널 + 벨 배지, 프로젝트 화면은 보드/리스트/멤버 + "내 업무" 패널).
|
||||||
|
// initLiveRefresh 는 어느 페이지든(프로젝트/홈) 항상 이 하나로 처리.
|
||||||
let softSyncProjectRef = null;
|
let softSyncProjectRef = null;
|
||||||
function initLiveRefresh() {
|
function initLiveRefresh() {
|
||||||
if (!document.querySelector(".pj-wrap, .pj-project-view")) return;
|
if (!document.querySelector(".pj-wrap, .pj-project-view")) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user