feat(project): 관리자 페이지 접근 토글 + 멤버 배정 등록사용자 자동목록
- project 를 MODULE_KEYS 에 추가 → 관리자 페이지에 '프로젝트 관리' 토글
자동 생성. 직원별 접근 권한 부여(admin 자동, 일반은 기본 OFF).
- 모듈 진입을 has_module('project') 로 게이트(기존 전원허용 철회).
- 멤버 배정 모달: GET /project/api/assignable-users 로 project 권한 보유
등록 사용자를 드롭다운 자동 목록화(타이핑 제거). 이미 배정된 사람 제외.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+1
-2
@@ -353,8 +353,7 @@ def _menu_items_for(user_rec: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
]
|
||||
allowed = allowed_modules(user_rec)
|
||||
for item in items:
|
||||
# project 는 권한키 없이 로그인한 회사 직원 전원 진입(전사 협업).
|
||||
item["allowed"] = item["key"] == "project" or item["key"] in allowed
|
||||
item["allowed"] = item["key"] in allowed
|
||||
return items
|
||||
|
||||
|
||||
|
||||
@@ -40,10 +40,13 @@ def _store(request: Request) -> Any:
|
||||
|
||||
def _require_user(request: Request) -> dict[str, Any]:
|
||||
from app.main import get_current_user_record # noqa: WPS433
|
||||
from app.store import has_module # noqa: WPS433
|
||||
|
||||
user = get_current_user_record(request)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail="로그인이 필요합니다.")
|
||||
if not has_module(user, "project"):
|
||||
raise HTTPException(status_code=403, detail="프로젝트 관리 모듈 권한이 없습니다.")
|
||||
return user
|
||||
|
||||
|
||||
@@ -455,6 +458,29 @@ async def api_delete_project(request: Request, project_id: int) -> JSONResponse:
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# JSON API — 멤버 (관리자가 사용자 배정)
|
||||
# ────────────────────────────────────────────────────────────
|
||||
@router.get("/api/assignable-users")
|
||||
async def api_assignable_users(request: Request) -> JSONResponse:
|
||||
"""배정 후보 = 등록된 사용자 중 프로젝트 권한(project 모듈) 보유자. 관리자 전용.
|
||||
|
||||
관리자 페이지의 '프로젝트 관리' 토글을 켠 직원이 자동으로 후보가 된다.
|
||||
"""
|
||||
_require_admin(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],
|
||||
}
|
||||
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/projects/{project_id}/members")
|
||||
async def api_list_members(request: Request, project_id: int) -> JSONResponse:
|
||||
_require_user(request)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}
|
||||
<link rel="stylesheet" href="/static/project.css?v=20260625c" />
|
||||
<link rel="stylesheet" href="/static/project.css?v=20260625d" />
|
||||
<!-- 구글 머티리얼 심볼(담당자 아이콘 등) -->
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0" />
|
||||
<!-- 달력은 휴가 모듈과 동일한 서버 렌더 그리드(project.css). 타임라인만 vis(CDN, 추후 self-host) -->
|
||||
@@ -190,6 +190,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if is_admin %}
|
||||
<!-- 멤버 배정 모달 — 등록된 사용자(프로젝트 권한 보유)를 자동 목록화 -->
|
||||
<div class="pj-modal" id="pj-modal-member" hidden>
|
||||
<div class="pj-modal-card">
|
||||
<h3>멤버 배정</h3>
|
||||
<p class="pj-modal-hint">관리자 페이지에서 <b>프로젝트 관리</b> 권한을 켠 직원이 후보로 나옵니다.</p>
|
||||
<label>직원
|
||||
<select id="pj-m-user"><option value="">불러오는 중…</option></select>
|
||||
</label>
|
||||
<label>역할
|
||||
<select id="pj-m-role">
|
||||
<option value="member" selected>멤버</option>
|
||||
<option value="manager">관리(서브·업무 관리)</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="pj-modal-actions">
|
||||
<span style="flex:1"></span>
|
||||
<button type="button" class="pj-btn" data-close>취소</button>
|
||||
<button type="button" class="pj-btn pj-btn-primary" id="pj-save-member">배정</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- 데이터 -->
|
||||
<script id="pj-data" type="application/json">
|
||||
{
|
||||
@@ -202,5 +226,5 @@
|
||||
</script>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.3/standalone/umd/vis-timeline-graph2d.min.js"></script>
|
||||
<script src="/static/project.js?v=20260625c" defer></script>
|
||||
<script src="/static/project.js?v=20260625d" defer></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
.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); }
|
||||
.pj-modal-card h3 { margin: 0 0 16px; font-size: 17px; }
|
||||
.pj-modal-hint { margin: -6px 0 14px; font-size: 12px; color: #6b7280; }
|
||||
.pj-modal-card label { display: block; font-size: 12.5px; font-weight: 600; color: #525860; margin-bottom: 12px; }
|
||||
.pj-modal-card input[type=text], .pj-modal-card input[type=date], .pj-modal-card textarea, .pj-modal-card select {
|
||||
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;
|
||||
|
||||
+27
-3
@@ -345,13 +345,37 @@
|
||||
});
|
||||
|
||||
// ── 멤버 배정/제외 (관리자) ──
|
||||
const memberModal = el("pj-modal-member");
|
||||
wireModalClose(memberModal);
|
||||
const addMemberBtn = el("pj-add-member");
|
||||
if (addMemberBtn) addMemberBtn.addEventListener("click", async function () {
|
||||
const email = prompt("배정할 직원 이메일 (@dbxcorp.co.kr)");
|
||||
if (!email || !email.trim()) return;
|
||||
const sel = el("pj-m-user");
|
||||
openModal(memberModal);
|
||||
sel.innerHTML = '<option value="">불러오는 중…</option>';
|
||||
try {
|
||||
const res = await api("GET", "/project/api/assignable-users");
|
||||
const existing = new Set(members.map(function (m) { return m.user_email; }));
|
||||
const opts = res.users
|
||||
.filter(function (u) { return !existing.has(u.email); })
|
||||
.map(function (u) {
|
||||
return '<option value="' + u.email + '" data-name="' + escapeHtml(u.name) + '">' +
|
||||
escapeHtml(u.id) + " (" + escapeHtml(u.email) + ")</option>";
|
||||
});
|
||||
sel.innerHTML = opts.length
|
||||
? opts.join("")
|
||||
: '<option value="">배정 가능한 직원이 없습니다(권한 미부여 또는 전원 배정됨)</option>';
|
||||
} catch (e) {
|
||||
sel.innerHTML = '<option value="">불러오기 실패: ' + escapeHtml(e.message) + "</option>";
|
||||
}
|
||||
});
|
||||
const saveMemberBtn = el("pj-save-member");
|
||||
if (saveMemberBtn) saveMemberBtn.addEventListener("click", async function () {
|
||||
const sel = el("pj-m-user");
|
||||
if (!sel.value) { alert("직원을 선택하세요."); return; }
|
||||
const name = sel.selectedOptions[0] ? (sel.selectedOptions[0].dataset.name || "") : "";
|
||||
try {
|
||||
await api("POST", "/project/api/projects/" + projectId + "/members", {
|
||||
user_email: email.trim(), role: "member",
|
||||
user_email: sel.value, user_name: name, role: el("pj-m-role").value,
|
||||
});
|
||||
location.reload();
|
||||
} catch (e) { alert("배정 실패: " + e.message); }
|
||||
|
||||
@@ -30,6 +30,7 @@ MODULE_KEYS: tuple[str, ...] = (
|
||||
"cupang",
|
||||
"malaysia",
|
||||
"dispatch",
|
||||
"project",
|
||||
"expense_approver",
|
||||
"vacation_approver",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user