feat(project): 프로젝트 멤버 아이콘으로 업무 팝업, 편집후 목록유지, 사이드바 스타일
- 좌측 "프로젝트 멤버" 아이콘 클릭 시 이 프로젝트 안에서 그 사람이 담당하는 업무 팝업(전체 멤버 팝업과 같은 UI/정렬/더블클릭 편집). 제목은 "[프로젝트 이름]의 [사용자]님의 업무" + 아이콘. GET /api/users/tasks 에 project_id 옵션 추가(멀티호밍 포함 단일 프로젝트 조회 재사용) - 멤버 업무 팝업에서 더블클릭으로 업무 편집 후 닫으면, 그 자리에 같은 목록을 다시 열어 유지(MutationObserver로 taskModal 닫힘 감지 후 재오픈) - 멤버 업무 팝업 제목에 아바타 아이콘 추가, "닫기" 버튼 accent 색 적용 - 좌측 "프로젝트"/"프로젝트 멤버" 헤더 더 굵게, 프로젝트 멤버 아이콘 2배 확대 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -805,12 +805,20 @@ async def api_all_users(request: Request) -> JSONResponse:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/users/tasks")
|
@router.get("/api/users/tasks")
|
||||||
async def api_user_tasks(request: Request, email: str) -> JSONResponse:
|
async def api_user_tasks(
|
||||||
"""'전체 멤버' 아이콘 클릭 팝업 — 지정한 사용자가 담당자인 업무 목록
|
request: Request, email: str, project_id: int | None = None
|
||||||
(프로젝트/세션/마감일). /api/my-tasks 와 같은 조회를 이메일만 바꿔 재사용."""
|
) -> JSONResponse:
|
||||||
|
"""'전체 멤버'/사이드바 '프로젝트 멤버' 아이콘 클릭 팝업 — 지정한 사용자가
|
||||||
|
담당자인 업무 목록(프로젝트/세션/마감일). project_id 를 주면 그 프로젝트
|
||||||
|
안에서 담당하는 업무만(멀티호밍으로 연결된 업무 포함, list_tasks 의
|
||||||
|
project_id 단일 조회 경로 재사용). 안 주면 전체 프로젝트 대상."""
|
||||||
_require_user(request)
|
_require_user(request)
|
||||||
st = _db_or_503(request)
|
st = _db_or_503(request)
|
||||||
return JSONResponse({"tasks": st.list_tasks(assignee_email=email, limit=500)})
|
if project_id is not None:
|
||||||
|
tasks = st.list_tasks(project_id=project_id, assignee_email=email, limit=500)
|
||||||
|
else:
|
||||||
|
tasks = st.list_tasks(assignee_email=email, limit=500)
|
||||||
|
return JSONResponse({"tasks": tasks})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/projects/{project_id}/members")
|
@router.get("/api/projects/{project_id}/members")
|
||||||
|
|||||||
@@ -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=20260917o" />
|
<link rel="stylesheet" href="/static/project.css?v=20260917p" />
|
||||||
<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=20260917s" defer></script>
|
<script src="/static/project.js?v=20260917t" 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=20260917o" />
|
<link rel="stylesheet" href="/static/project.css?v=20260917p" />
|
||||||
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
|
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -398,5 +398,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=20260917s" defer></script>
|
<script src="/static/project.js?v=20260917t" 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=20260917o" />
|
<link rel="stylesheet" href="/static/project.css?v=20260917p" />
|
||||||
<!-- 구글 머티리얼 심볼(담당자 아이콘 등) — 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 -->
|
||||||
@@ -511,7 +511,7 @@
|
|||||||
<ul class="pj-mt-list" id="pj-mt-list"></ul>
|
<ul class="pj-mt-list" id="pj-mt-list"></ul>
|
||||||
<div class="pj-modal-actions">
|
<div class="pj-modal-actions">
|
||||||
<span style="flex:1"></span>
|
<span style="flex:1"></span>
|
||||||
<button type="button" class="pj-btn" data-close>닫기</button>
|
<button type="button" class="pj-btn pj-btn-primary" data-close>닫기</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -538,5 +538,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=20260917s" defer></script>
|
<script src="/static/project.js?v=20260917t" defer></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -203,11 +203,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.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: 800; color: #6b7280; text-transform: uppercase; letter-spacing: .04em; margin-bottom: 8px; }
|
||||||
.pj-member-list { list-style: none; margin: 0; padding: 0; }
|
.pj-member-list { list-style: none; margin: 0; padding: 0; }
|
||||||
.pj-member-list li { display: flex; align-items: center; gap: 8px; padding: 5px 4px; border-radius: 7px; }
|
.pj-member-list li { display: flex; align-items: center; gap: 8px; padding: 5px 4px; border-radius: 7px; }
|
||||||
.pj-member-list li:hover { background: #f6f7f8; }
|
.pj-member-list li:hover { background: #f6f7f8; }
|
||||||
.pj-member-name { font-size: 13px; flex: 1; }
|
.pj-member-name { font-size: 13px; flex: 1; }
|
||||||
|
/* 프로젝트 멤버 아이콘 — 기존 대비 2배(사진 18→36px, 기본 아이콘 24→48px).
|
||||||
|
클릭하면 이 프로젝트 안에서 그 사람이 담당하는 업무 팝업. */
|
||||||
|
.pj-avatar.pj-member-avatar-lg { width: 36px; height: 36px; }
|
||||||
|
.pj-gicon.pj-member-avatar-lg { font-size: 48px; }
|
||||||
|
|
||||||
/* ── 아사나식 프로젝트 트리(사이드바) — 프로젝트 이름만 나열, 업무 목록 없음 ── */
|
/* ── 아사나식 프로젝트 트리(사이드바) — 프로젝트 이름만 나열, 업무 목록 없음 ── */
|
||||||
.pj-tree { list-style: none; margin: 0; padding: 0; }
|
.pj-tree { list-style: none; margin: 0; padding: 0; }
|
||||||
@@ -625,6 +629,8 @@
|
|||||||
|
|
||||||
/* ── 전체 멤버 클릭 팝업 — 담당 업무 목록(프로젝트/세션/제목/마감일) ── */
|
/* ── 전체 멤버 클릭 팝업 — 담당 업무 목록(프로젝트/세션/제목/마감일) ── */
|
||||||
.pj-mt-card { width: min(600px, 94vw); }
|
.pj-mt-card { width: min(600px, 94vw); }
|
||||||
|
.pj-mt-card h3 { display: flex; align-items: center; gap: 6px; }
|
||||||
|
.pj-mt-title-avatar { width: 22px; height: 22px; border-radius: 50%; flex: 0 0 auto; vertical-align: middle; }
|
||||||
.pj-mt-head {
|
.pj-mt-head {
|
||||||
display: grid; grid-template-columns: 100px 90px 1fr 80px; gap: 8px;
|
display: grid; grid-template-columns: 100px 90px 1fr 80px; gap: 8px;
|
||||||
padding: 6px; border-bottom: 2px solid #eef0f2; margin-bottom: 2px;
|
padding: 6px; border-bottom: 2px solid #eef0f2; margin-bottom: 2px;
|
||||||
|
|||||||
+51
-5
@@ -3398,8 +3398,8 @@
|
|||||||
const url = avatars[String(m.user_email).toLowerCase()];
|
const url = avatars[String(m.user_email).toLowerCase()];
|
||||||
return "<li data-email=\"" + escapeHtml(m.user_email) + "\">" +
|
return "<li data-email=\"" + escapeHtml(m.user_email) + "\">" +
|
||||||
(url
|
(url
|
||||||
? '<img class="pj-avatar pj-gicon-sm" src="' + escapeHtml(url) + '" alt="" referrerpolicy="no-referrer" />'
|
? '<img class="pj-avatar pj-member-avatar-lg" src="' + escapeHtml(url) + '" alt="" title="' + escapeHtml(idOf(m.user_email)) + '님의 업무 보기" referrerpolicy="no-referrer" />'
|
||||||
: '<span class="material-symbols-outlined pj-gicon">account_circle</span>') +
|
: '<span class="material-symbols-outlined pj-gicon pj-member-avatar-lg" title="' + escapeHtml(idOf(m.user_email)) + '님의 업무 보기">account_circle</span>') +
|
||||||
'<span class="pj-member-name">' + escapeHtml(idOf(m.user_email)) + "</span>" +
|
'<span class="pj-member-name">' + escapeHtml(idOf(m.user_email)) + "</span>" +
|
||||||
(m.role === "manager" ? '<span class="pj-chip pj-chip-sm">관리</span>' : "") +
|
(m.role === "manager" ? '<span class="pj-chip pj-chip-sm">관리</span>' : "") +
|
||||||
(isAdmin ? '<button type="button" class="pj-icon-btn pj-del-member" data-email="' +
|
(isAdmin ? '<button type="button" class="pj-icon-btn pj-del-member" data-email="' +
|
||||||
@@ -3421,6 +3421,17 @@
|
|||||||
ul.querySelectorAll(".pj-del-member").forEach(function (b) {
|
ul.querySelectorAll(".pj-del-member").forEach(function (b) {
|
||||||
b.addEventListener("click", function () { removeMember(b.dataset.email); });
|
b.addEventListener("click", function () { removeMember(b.dataset.email); });
|
||||||
});
|
});
|
||||||
|
// 아바타 클릭 = 이 프로젝트 안에서 그 사람이 담당하는 업무 팝업.
|
||||||
|
ul.querySelectorAll("li[data-email]").forEach(function (li) {
|
||||||
|
const avatarEl = li.querySelector(".pj-member-avatar-lg");
|
||||||
|
if (!avatarEl) return;
|
||||||
|
avatarEl.style.cursor = "pointer";
|
||||||
|
avatarEl.addEventListener("click", function () {
|
||||||
|
openMemberTasksModal(li.dataset.email, idOf(li.dataset.email), {
|
||||||
|
projectId: projectId, projectName: currentProject.name,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
if (isAdmin) ul.querySelectorAll("li[data-email]").forEach(function (li) {
|
if (isAdmin) ul.querySelectorAll("li[data-email]").forEach(function (li) {
|
||||||
li.addEventListener("contextmenu", function (e) {
|
li.addEventListener("contextmenu", function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -3445,6 +3456,12 @@
|
|||||||
if (memberTasksModal) wireModalClose(memberTasksModal);
|
if (memberTasksModal) wireModalClose(memberTasksModal);
|
||||||
let mtItems = [];
|
let mtItems = [];
|
||||||
let mtSort = { key: null, dir: 1 };
|
let mtSort = { key: null, dir: 1 };
|
||||||
|
// 더블클릭으로 업무 편집 팝업을 열 때 이 팝업을 완전히 닫지 않고 잠깐
|
||||||
|
// 숨겨두는 용도의 컨텍스트 — 업무 편집을 마치고 닫으면(taskModal 의
|
||||||
|
// hidden 이 다시 true 로 바뀌면) 같은 조건으로 다시 열어 목록을 최신
|
||||||
|
// 상태로 되살린다("기존 멤버 업무리스트는 그대로 유지" 요구사항).
|
||||||
|
let mtCurrentCtx = null;
|
||||||
|
let mtReopenCtx = null;
|
||||||
function mtSortValue(t, key) {
|
function mtSortValue(t, key) {
|
||||||
if (key === "project") return (t.project_name || "").toLowerCase();
|
if (key === "project") return (t.project_name || "").toLowerCase();
|
||||||
if (key === "stage") return (t.stage_name || "").toLowerCase();
|
if (key === "stage") return (t.stage_name || "").toLowerCase();
|
||||||
@@ -3484,10 +3501,13 @@
|
|||||||
list.innerHTML = sorted.map(mtRowHtml).join("");
|
list.innerHTML = sorted.map(mtRowHtml).join("");
|
||||||
// 더블클릭 = 업무 편집 팝업(현재 프로젝트가 아니어도 openTaskModal 은
|
// 더블클릭 = 업무 편집 팝업(현재 프로젝트가 아니어도 openTaskModal 은
|
||||||
// task.project_id 기준으로 담당자/세션 옵션을 다시 채우므로 그대로 동작).
|
// task.project_id 기준으로 담당자/세션 옵션을 다시 채우므로 그대로 동작).
|
||||||
|
// 이 팝업은 닫는 게 아니라 잠깐 숨겨두고, 편집 팝업이 닫히면 같은
|
||||||
|
// 대상으로 다시 열어 목록을 새로고침한다.
|
||||||
list.querySelectorAll("li[data-task-id]").forEach(function (li) {
|
list.querySelectorAll("li[data-task-id]").forEach(function (li) {
|
||||||
li.addEventListener("dblclick", function () {
|
li.addEventListener("dblclick", function () {
|
||||||
const t = mtItems.find(function (x) { return String(x.id) === li.dataset.taskId; });
|
const t = mtItems.find(function (x) { return String(x.id) === li.dataset.taskId; });
|
||||||
if (!t) return;
|
if (!t) return;
|
||||||
|
mtReopenCtx = mtCurrentCtx;
|
||||||
closeModal(memberTasksModal);
|
closeModal(memberTasksModal);
|
||||||
openTaskModal(t);
|
openTaskModal(t);
|
||||||
});
|
});
|
||||||
@@ -3501,9 +3521,22 @@
|
|||||||
updateMtSortIcons();
|
updateMtSortIcons();
|
||||||
renderMtList();
|
renderMtList();
|
||||||
});
|
});
|
||||||
async function openMemberTasksModal(email, name) {
|
// 제목 앞에 붙는 아이콘 — 아바타 있으면 사진, 없으면 기본 사람 아이콘.
|
||||||
|
function mtTitleAvatarHtml(email) {
|
||||||
|
const url = avatars[String(email || "").toLowerCase()];
|
||||||
|
return url
|
||||||
|
? '<img class="pj-avatar pj-mt-title-avatar" src="' + escapeHtml(url) + '" alt="" referrerpolicy="no-referrer" />'
|
||||||
|
: '<span class="material-symbols-outlined pj-gicon pj-mt-title-avatar" style="font-size:22px;">account_circle</span>';
|
||||||
|
}
|
||||||
|
// opts.projectId 를 주면 "이 프로젝트 안에서" 담당하는 업무만, 제목도
|
||||||
|
// "[프로젝트 이름]의 [사용자]님의 업무" 형식으로. 안 주면(전체 멤버)
|
||||||
|
// 전체 프로젝트 대상 + "[사용자]님의 업무".
|
||||||
|
async function openMemberTasksModal(email, name, opts) {
|
||||||
if (!memberTasksModal) return;
|
if (!memberTasksModal) return;
|
||||||
el("pj-mt-title").textContent = (name || email) + "님의 업무";
|
opts = opts || {};
|
||||||
|
mtCurrentCtx = { email: email, name: name, opts: opts };
|
||||||
|
const prefix = opts.projectName ? escapeHtml(opts.projectName) + "의 " : "";
|
||||||
|
el("pj-mt-title").innerHTML = prefix + mtTitleAvatarHtml(email) + escapeHtml(name || email) + "님의 업무";
|
||||||
mtItems = [];
|
mtItems = [];
|
||||||
mtSort = { key: null, dir: 1 };
|
mtSort = { key: null, dir: 1 };
|
||||||
updateMtSortIcons();
|
updateMtSortIcons();
|
||||||
@@ -3511,13 +3544,26 @@
|
|||||||
list.innerHTML = "<li class='pj-empty-sm'>불러오는 중…</li>";
|
list.innerHTML = "<li class='pj-empty-sm'>불러오는 중…</li>";
|
||||||
openModal(memberTasksModal);
|
openModal(memberTasksModal);
|
||||||
try {
|
try {
|
||||||
const res = await api("GET", "/project/api/users/tasks?email=" + encodeURIComponent(email));
|
const url = "/project/api/users/tasks?email=" + encodeURIComponent(email) +
|
||||||
|
(opts.projectId ? "&project_id=" + opts.projectId : "");
|
||||||
|
const res = await api("GET", url);
|
||||||
mtItems = res.tasks || [];
|
mtItems = res.tasks || [];
|
||||||
renderMtList();
|
renderMtList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
list.innerHTML = "<li class='pj-empty-sm'>불러오기 실패: " + escapeHtml(e.message) + "</li>";
|
list.innerHTML = "<li class='pj-empty-sm'>불러오기 실패: " + escapeHtml(e.message) + "</li>";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 업무 편집 팝업이 닫히면(hidden 이 다시 true) 대기 중인 컨텍스트가
|
||||||
|
// 있을 때만 멤버 업무 팝업을 그 자리에 다시 연다.
|
||||||
|
if (taskModal) {
|
||||||
|
new MutationObserver(function () {
|
||||||
|
if (taskModal.hidden && mtReopenCtx) {
|
||||||
|
const ctx = mtReopenCtx;
|
||||||
|
mtReopenCtx = null;
|
||||||
|
openMemberTasksModal(ctx.email, ctx.name, ctx.opts);
|
||||||
|
}
|
||||||
|
}).observe(taskModal, { attributes: true, attributeFilter: ["hidden"] });
|
||||||
|
}
|
||||||
const allMembersGrid = el("pj-allmembers-grid");
|
const allMembersGrid = el("pj-allmembers-grid");
|
||||||
if (allMembersGrid) {
|
if (allMembersGrid) {
|
||||||
api("GET", "/project/api/all-users").then(function (res) {
|
api("GET", "/project/api/all-users").then(function (res) {
|
||||||
|
|||||||
Reference in New Issue
Block a user