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:
2026-06-25 12:08:57 +09:00
parent d60ae9a210
commit 8ed1263bef
8 changed files with 85 additions and 9 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ Claude Code는 이 저장소에서 작업을 시작하기 전에 **반드시 아
- 휴가 관리 (`app/modules/vacation/`, `vacation_db`) — 월간 달력(구글식 bar)/연차·반차 신청/승인 워크플로/공휴일·연차 설정. 권한키 `vacation`·`vacation_approver`
- 말레이시아 창고 재고관리 (`app/modules/malaysia/`, `malaysia_stock_db`) — 낱개(MT/MX/MZ) 입출고·조정, 세트(MY) BOM, 일일 재고조사(세트→낱개 자동 분해), 현재고 현황. 뚜껑(MD-)은 재고 집계 제외 — 단, 창고 랙에는 위치 확인용으로 배치 가능(`store.LID_ITEMS`). 상품은 `itemcode_db` 읽기 전용. 권한키 `malaysia`
- 말레이시아 배송 (`app/modules/dispatch/`, `dispatch_db`) — TikTok·Shopee 출고관리. 플랫폼별 데이터 엑셀 업로드(TikTok=03_TikTok_Order_Export.xlsx, Shopee=Packing List.Doorstep Delivery.xlsx) → 1박스=1카드 출고 작업 리스트·SKU 피킹 요약·Kagayaku 전달표 자동 생성. 1박스 묶음 기준 Package ID > Tracking ID > Order ID, 같은 박스 같은 SKU 합산. 작업 상태 토글(`dispatch_logs` 기록). 받는 사람 이름/전화/주소는 박스 단위로 저장(작업 카드 표시 + 출고 엑셀 생성용 — 개인정보). 배치 다운로드 zip 에 업로드 원본 + 취합 출고 엑셀(`YYYY.MM.DD(Ddd)_tictoc|shopee.xlsx`) 포함. 엑셀은 openpyxl 파싱/생성. 권한키 `dispatch`. 상세는 `docs/DISPATCH_MODULE.md`
- 프로젝트 관리 (`app/modules/project/`, `project_db`) — 아사나식. 프로젝트/서브프로젝트(self-FK `parent_id`, CASCADE)·업무(`tasks`: 담당자·우선순위·시작/마감)·진행단계(`project_stages` 칸반, 생성시 기본 4단계 seed)·멤버 배정(`project_members`)·활동이력(`project_activity`). 메인 뷰 달력(FullCalendar)/타임라인(vis-timeline) 버튼 토글 + 보드(드래그로 단계 이동)/리스트. **진입은 로그인한 회사 직원 전원**(권한키 없음 — MODULE_KEYS 미포함, 메뉴 `allowed` 강제 True). 프로젝트 생성/삭제·사용자 배정은 `is_admin` 만, 배정 멤버(또는 owner)는 서브프로젝트/업무/단계 CRUD. 업무 배정·완료 시 관리자에게 메일(`app/mail.py` stdlib smtplib, `SMTP_*`+`PROJECT_NOTIFY_EMAIL` env, 미설정 시 조용히 skip, `BackgroundTasks` 비동기). 상세는 `docs/PROJECT_MODULE.md`
- 프로젝트 관리 (`app/modules/project/`, `project_db`) — 아사나식. 프로젝트/서브프로젝트(self-FK `parent_id`, CASCADE)·업무(`tasks`: 담당자·우선순위·시작/마감)·진행단계(`project_stages` 칸반, 생성시 기본 4단계 seed)·멤버 배정(`project_members`)·활동이력(`project_activity`). 메인 뷰 달력(FullCalendar)/타임라인(vis-timeline) 버튼 토글 + 보드(드래그로 단계 이동)/리스트. 진입 권한키 `project`(관리자 페이지 토글로 직원별 부여, admin 자동). 프로젝트 생성/삭제·사용자 배정은 `is_admin` 만, 배정 멤버(또는 owner)는 서브프로젝트/업무/단계 CRUD. 멤버 배정 후보는 `project` 권한 보유 등록 사용자에서 자동 목록(`GET /project/api/assignable-users`). 업무 배정·완료 시 관리자에게 메일(`app/mail.py` stdlib smtplib, `SMTP_*`+`PROJECT_NOTIFY_EMAIL` env, 미설정 시 조용히 skip, `BackgroundTasks` 비동기). 상세는 `docs/PROJECT_MODULE.md`
상세는 `docs/PROJECT_OVERVIEW.md`.
+1 -2
View File
@@ -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
+26
View File
@@ -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 %}
+1
View File
@@ -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
View File
@@ -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); }
+1
View File
@@ -30,6 +30,7 @@ MODULE_KEYS: tuple[str, ...] = (
"cupang",
"malaysia",
"dispatch",
"project",
"expense_approver",
"vacation_approver",
)
+2 -1
View File
@@ -10,7 +10,8 @@
| URL prefix | `/project` |
| DB | `project_db` (PostgreSQL, 역할 `project_app` CRUD only) |
| 연결 env | `PROJECT_DB_URL` (미설정 시 "설정 필요" 안내, 앱은 죽지 않음) |
| 진입 권한 | 로그인한 회사 직원 전원 (별도 권한키 없음) |
| 진입 권한 | 권한키 `project` (관리자 페이지 토글로 직원별 부여, admin 자동) |
| 멤버 배정 후보 | `project` 권한 보유 등록 사용자 자동 목록 (`GET /project/api/assignable-users`) |
| 관리 권한 | 프로젝트 생성/삭제·사용자 배정 = ERP 관리자(`is_admin`) / 서브프로젝트·업무·단계 = 배정 멤버 또는 owner |
| 메일 알림 | `app/mail.py` (stdlib smtplib). 업무 배정/완료 시 관리자에게 발송 |