From 8ed1263bef7d7b99684265fb72f5da4a59ff5cbc Mon Sep 17 00:00:00 2001 From: king Date: Thu, 25 Jun 2026 12:08:57 +0900 Subject: [PATCH] =?UTF-8?q?feat(project):=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=A0=91=EA=B7=BC=20=ED=86=A0?= =?UTF-8?q?=EA=B8=80=20+=20=EB=A9=A4=EB=B2=84=20=EB=B0=B0=EC=A0=95=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=EB=AA=A9=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - project 를 MODULE_KEYS 에 추가 → 관리자 페이지에 '프로젝트 관리' 토글 자동 생성. 직원별 접근 권한 부여(admin 자동, 일반은 기본 OFF). - 모듈 진입을 has_module('project') 로 게이트(기존 전원허용 철회). - 멤버 배정 모달: GET /project/api/assignable-users 로 project 권한 보유 등록 사용자를 드롭다운 자동 목록화(타이핑 제거). 이미 배정된 사람 제외. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 2 +- app/main.py | 3 +- app/modules/project/router.py | 26 ++++++++++++++++ .../project/templates/project/project.html | 28 +++++++++++++++-- app/static/project.css | 1 + app/static/project.js | 30 +++++++++++++++++-- app/store.py | 1 + docs/PROJECT_MODULE.md | 3 +- 8 files changed, 85 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3a14565..9d433a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. diff --git a/app/main.py b/app/main.py index 0f0d441..b26499d 100644 --- a/app/main.py +++ b/app/main.py @@ -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 diff --git a/app/modules/project/router.py b/app/modules/project/router.py index 79672c5..d910890 100644 --- a/app/modules/project/router.py +++ b/app/modules/project/router.py @@ -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) diff --git a/app/modules/project/templates/project/project.html b/app/modules/project/templates/project/project.html index 07b8169..440a7ff 100644 --- a/app/modules/project/templates/project/project.html +++ b/app/modules/project/templates/project/project.html @@ -1,7 +1,7 @@ {% extends "erp_base.html" %} {% block head_extra %} - + @@ -190,6 +190,30 @@ +{% if is_admin %} + + +{% endif %} + - + {% endblock %} diff --git a/app/static/project.css b/app/static/project.css index 27effad..60aed15 100644 --- a/app/static/project.css +++ b/app/static/project.css @@ -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; diff --git a/app/static/project.js b/app/static/project.js index a43d037..c1fd7eb 100644 --- a/app/static/project.js +++ b/app/static/project.js @@ -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 = ''; + 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 '"; + }); + sel.innerHTML = opts.length + ? opts.join("") + : ''; + } catch (e) { + sel.innerHTML = '"; + } + }); + 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); } diff --git a/app/store.py b/app/store.py index 1a9e125..db9363e 100644 --- a/app/store.py +++ b/app/store.py @@ -30,6 +30,7 @@ MODULE_KEYS: tuple[str, ...] = ( "cupang", "malaysia", "dispatch", + "project", "expense_approver", "vacation_approver", ) diff --git a/docs/PROJECT_MODULE.md b/docs/PROJECT_MODULE.md index 4aec8b8..3b4f0a4 100644 --- a/docs/PROJECT_MODULE.md +++ b/docs/PROJECT_MODULE.md @@ -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). 업무 배정/완료 시 관리자에게 발송 |