diff --git a/frontend/src/features/auth/hooks/usePreLoginBranding.ts b/frontend/src/features/auth/hooks/usePreLoginBranding.ts index 1c0051a..01b80d6 100644 --- a/frontend/src/features/auth/hooks/usePreLoginBranding.ts +++ b/frontend/src/features/auth/hooks/usePreLoginBranding.ts @@ -3,12 +3,33 @@ import { useSearchParams } from 'react-router' import { authApi } from '@/apis' import type { Branding } from '@/apis/auth/auth.type' +// 로그인 전 화면의 회사 브랜딩을 담아 두는 곳. 로그아웃/토큰 만료와 무관하게 남겨 +// 뒤로가기·재진입으로 session_id 가 없어져도 같은 회사 화면을 유지한다(회사 식별 정보만 담는다). +const BRANDING_KEY = 'negosium.preLoginBranding' + +function readCached(): Branding | null { + try { + const raw = localStorage.getItem(BRANDING_KEY) + return raw ? (JSON.parse(raw) as Branding) : null + } catch { + return null // 손상된 값은 없는 것으로 본다 + } +} + +function writeCached(branding: Branding): void { + try { + localStorage.setItem(BRANDING_KEY, JSON.stringify(branding)) + } catch { + // 저장 불가(프라이빗 모드 등)면 이번 방문에만 적용된다 + } +} + // 로그인 전 화면(로그인 페이지)의 회사 브랜딩. // 협력사는 초청 메일의 /chat?session_id=... 로 들어오므로, 그 session_id 로 인증 없이 브랜딩만 조회한다. -// session_id 가 없으면 회사를 특정할 수 없으므로 기본 브랜드(Negosium)로 둔다. +// session_id 가 없으면 마지막으로 확인된 회사 브랜딩을 쓰고, 그것도 없으면 기본 브랜드(Negosium). export function usePreLoginBranding(): Branding | null { const [searchParams] = useSearchParams() - const [branding, setBranding] = useState(null) + const [branding, setBranding] = useState(readCached) const sessionId = searchParams.get('session_id') ?? '' @@ -26,9 +47,10 @@ export function usePreLoginBranding(): Branding | null { } if (!next.service_name && !next.logo_url) return setBranding(next) + writeCached(next) // 다음 진입에 session_id 가 없어도 이 회사로 보이게 한다 }) .catch(() => { - // 조회 실패 시 기본 브랜드로 그대로 둔다 + // 조회 실패 시 캐시(또는 기본 브랜드)를 그대로 둔다 }) return () => { alive = false diff --git a/negodata/backend/common/authz.py b/negodata/backend/common/authz.py index 50b0ee3..6e7b1a3 100644 --- a/negodata/backend/common/authz.py +++ b/negodata/backend/common/authz.py @@ -2,8 +2,8 @@ from common.enums import UserRole def is_owner_or_admin(resource_user_id, user_id, role) -> bool: - """변경 액션 공용 소유권 판정 — 리소스 소유자(user_id 일치) 또는 최고관리자(OWNER)면 True. + """변경 액션 공용 소유권 판정 — 리소스 소유자(user_id 일치) 또는 최고관리자 이상(OWNER/DEVELOPER)이면 True. 프론트의 버튼 게이팅과 같은 규칙을 백엔드에서 강제하는 단일 출처. 소유자 없는 공용 리소스(예: user_id NULL 공용카드)는 이 판정 대상이 아니다(도메인별 별도 처리).""" - return str(resource_user_id) == str(user_id) or role == UserRole.OWNER.value + return str(resource_user_id) == str(user_id) or (role or 0) >= UserRole.OWNER.value diff --git a/negodata/backend/common/enums.py b/negodata/backend/common/enums.py index b859c41..2a8befb 100644 --- a/negodata/backend/common/enums.py +++ b/negodata/backend/common/enums.py @@ -122,12 +122,13 @@ class UserStatus(CodeEnum): class UserRole(CodeEnum): - """users.role 코드값. negodata 유저는 전부 회사 직원(관리자측) — - 의미 있는 구분은 '직원 계정 관리 권한 유무' 하나뿐이라 2단계로 둔다. - 1=일반, 2=최고관리자(직원 계정 생성·관리).""" + """users.role 코드값. + 1=일반, 2=최고관리자(고객사 최상위), 3=개발자(우리 내부 운영 계정). + 개발자 계정은 고객사에 존재를 노출하지 않는다 — 회원 목록에서 빼고 총계에도 넣지 않는다.""" USER = 1 - OWNER = 2 # 최고관리자: 자기 회사 유저(직원 계정)를 생성·관리 + OWNER = 2 # 최고관리자: 자기 회사 계정 관리 + 회사 설정 + DEVELOPER = 3 # 개발자(내부 운영): 최고관리자 권한 전부 + 고객사에 보이지 않음 class CompanyStatus(CodeEnum): diff --git a/negodata/backend/crud/user_crud.py b/negodata/backend/crud/user_crud.py index 5d8d414..fb9e08e 100644 --- a/negodata/backend/crud/user_crud.py +++ b/negodata/backend/crud/user_crud.py @@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from common.database.db_session_manager import DB_SESSION_MNG from common.database.model.models import users, companies -from common.enums import ErrorType +from common.enums import ErrorType, UserRole from common.logger import LOG from common.utils.gtime import GTime @@ -40,7 +40,7 @@ class IUserCRUD(ABC): pass @abstractmethod - async def list_by_company(self, cdb: AsyncSession, company_id, search, skip, limit) -> Tuple[ErrorType, list, int]: + async def list_by_company(self, cdb: AsyncSession, company_id, search, skip, limit, hide_dev: bool = False) -> Tuple[ErrorType, list, int]: pass @abstractmethod @@ -120,10 +120,13 @@ class UserCRUD(IUserCRUD): return ErrorType.DB_RUN_FAILED async def list_by_company( - self, cdb: AsyncSession, company_id, search: Optional[str], skip: int, limit: int + self, cdb: AsyncSession, company_id, search: Optional[str], skip: int, limit: int, hide_dev: bool = False ) -> Tuple[ErrorType, list, int]: try: conditions = [users.deleted == False, users.company_id == company_id] # noqa: E712 + # 개발자(내부 운영) 계정은 고객사에 존재 자체가 보이면 안 된다 — 목록에서 빼고 총계에도 넣지 않는다. + if hide_dev: + conditions.append(users.role != UserRole.DEVELOPER.value) if search: conditions.append( or_( diff --git a/negodata/backend/router/v1/company/protocol.py b/negodata/backend/router/v1/company/protocol.py index b11b69a..d6294a4 100644 --- a/negodata/backend/router/v1/company/protocol.py +++ b/negodata/backend/router/v1/company/protocol.py @@ -14,12 +14,13 @@ class CompanyUserProtocol(WebPacketProtocol): class Req_CreateCompanyUser(CompanyUserProtocol): - # role 은 받지 않는다 — 최고관리자가 만드는 계정은 항상 일반(USER) 로 서버에서 고정. + # role 은 일반(1)·최고관리자(2)만 허용 — 개발자(3)는 앱에서 만들 수 없다(DB 시드로만). id: str = "" password: str = "" name: str = "" email: str = "" contact_number: str = "" + role: int = 1 class Req_UpdateCompanyUser(CompanyUserProtocol): diff --git a/negodata/backend/router/v1/company/user.py b/negodata/backend/router/v1/company/user.py index 76df668..1f2869b 100644 --- a/negodata/backend/router/v1/company/user.py +++ b/negodata/backend/router/v1/company/user.py @@ -2,6 +2,7 @@ from uuid import UUID from fastapi import APIRouter, Depends, Query +from common.enums import UserRole from common.models.gmodel import PageParams, UserInfo from router.v1.validator.dependencies import RemoveNoneResponse, RequireOwner from services.company_user_service import CompanyUserService @@ -13,39 +14,40 @@ from .protocol import ( Res_DeleteCompanyUser, ) -# 최고관리자(OWNER) 전용. 모든 엔드포인트가 RequireOwner 로 게이트되며 company_id 는 토큰값으로 스코프된다. +# 최고관리자 이상 전용. company_id 는 토큰값으로 스코프되고, +# 개발자(내부 운영) 계정은 고객사 최고관리자에게 목록에서 감춰진다(존재 자체를 노출하지 않는다). router = APIRouter(prefix="/v1/company/user", tags=["CompanyUser"], responses={404: {"description": "Not found"}}) @router.get(path="/list", response_model=Res_CompanyUserList, summary="회사 유저 목록(최고관리자)") async def list_users( service: CompanyUserService = Depends(), - owner: UserInfo = Depends(RequireOwner), + actor: UserInfo = Depends(RequireOwner), search: str | None = Query(None, description="로그인ID/이름/이메일 검색"), pg: PageParams = Depends(), ): - return RemoveNoneResponse(await service.list_users(owner.company_id, search, pg)) + return RemoveNoneResponse(await service.list_users(actor.company_id, search, pg, hide_dev=actor.role != UserRole.DEVELOPER.value)) @router.post(path="/create", response_model=Res_CompanyUser, summary="회사 유저 생성(일반 권한 고정)") async def create_user( - req: Req_CreateCompanyUser, service: CompanyUserService = Depends(), owner: UserInfo = Depends(RequireOwner) + req: Req_CreateCompanyUser, service: CompanyUserService = Depends(), actor: UserInfo = Depends(RequireOwner) ): - return RemoveNoneResponse(await service.create_user(owner.company_id, req)) + return RemoveNoneResponse(await service.create_user(actor.company_id, req)) @router.get(path="/{user_id}", response_model=Res_CompanyUser, summary="회사 유저 조회") -async def get_user(user_id: UUID, service: CompanyUserService = Depends(), owner: UserInfo = Depends(RequireOwner)): - return RemoveNoneResponse(await service.get_user(owner.company_id, str(user_id))) +async def get_user(user_id: UUID, service: CompanyUserService = Depends(), actor: UserInfo = Depends(RequireOwner)): + return RemoveNoneResponse(await service.get_user(actor.company_id, str(user_id))) @router.patch(path="/update/{user_id}", response_model=Res_CompanyUser, summary="회사 유저 수정") async def update_user( - user_id: UUID, req: Req_UpdateCompanyUser, service: CompanyUserService = Depends(), owner: UserInfo = Depends(RequireOwner) + user_id: UUID, req: Req_UpdateCompanyUser, service: CompanyUserService = Depends(), actor: UserInfo = Depends(RequireOwner) ): - return RemoveNoneResponse(await service.update_user(owner.company_id, str(user_id), req)) + return RemoveNoneResponse(await service.update_user(actor.company_id, str(user_id), req)) @router.delete(path="/delete/{user_id}", response_model=Res_DeleteCompanyUser, summary="회사 유저 삭제") -async def delete_user(user_id: UUID, service: CompanyUserService = Depends(), owner: UserInfo = Depends(RequireOwner)): - return RemoveNoneResponse(await service.delete_user(owner.company_id, str(user_id))) +async def delete_user(user_id: UUID, service: CompanyUserService = Depends(), actor: UserInfo = Depends(RequireOwner)): + return RemoveNoneResponse(await service.delete_user(actor.company_id, str(user_id))) diff --git a/negodata/backend/router/v1/validator/dependencies.py b/negodata/backend/router/v1/validator/dependencies.py index 0bb784f..a98b2d8 100644 --- a/negodata/backend/router/v1/validator/dependencies.py +++ b/negodata/backend/router/v1/validator/dependencies.py @@ -100,9 +100,9 @@ async def IsValidRefreshToken(credentials: HTTPAuthorizationCredentials = Depend return DecodeRefreshToken(credentials.credentials) -# 최고관리자 전용 엔드포인트 게이트. 액세스 토큰 검증 + role==OWNER 가 아니면 403. +# 최고관리자 이상(OWNER/DEVELOPER) 게이트. 회원관리·회사설정에 건다. async def RequireOwner(user_info: UserInfo = Depends(IsValidAccessToken)) -> UserInfo: - if user_info.role != UserRole.OWNER.value: + if (user_info.role or 0) < UserRole.OWNER.value: raise EXCEPTION_FORBIDDEN return user_info diff --git a/negodata/backend/services/company_user_service.py b/negodata/backend/services/company_user_service.py index a45c98f..a475cf4 100644 --- a/negodata/backend/services/company_user_service.py +++ b/negodata/backend/services/company_user_service.py @@ -44,14 +44,14 @@ class CompanyUserService: return ErrorType.ACCOUNT_FORBIDDEN, None return ErrorType.SUCCESS, user - async def list_users(self, company_id: str, search, pg) -> Res_CompanyUserList: + async def list_users(self, company_id: str, search, pg, hide_dev: bool = False) -> Res_CompanyUserList: res = Res_CompanyUserList(page=pg.page, size=pg.size) company_uuid = uuid.UUID(company_id) err_type, rows, total = await DB_SESSION_MNG.execute_lambda( users.DBType(), DBWRType.DB_READ.value, - lambda s: self.user_crud.list_by_company(s, company_uuid, search, pg.skip, pg.size), + lambda s: self.user_crud.list_by_company(s, company_uuid, search, pg.skip, pg.size, hide_dev), ) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) @@ -86,7 +86,7 @@ class CompanyUserService: res.result.SetResult(err_type) return res - # 2) 생성 — 회사는 토큰값, 권한은 항상 USER 로 고정. + # 2) 생성 — 회사는 토큰값. 권한은 일반·관리자만 허용(최고관리자 승격은 앱에서 불가). user = users( company_id=company_uuid, id=req.id, @@ -94,7 +94,7 @@ class CompanyUserService: name=req.name or None, email=req.email or None, contact_number=req.contact_number or None, - role=UserRole.USER.value, + role=req.role if req.role in (UserRole.USER.value, UserRole.OWNER.value) else UserRole.USER.value, ) err_type = await DB_SESSION_MNG.execute_lambda_run( [users.DBType()], diff --git a/negodata/docs/renegotiation-flow.md b/negodata/docs/renegotiation-flow.md new file mode 100644 index 0000000..471928c --- /dev/null +++ b/negodata/docs/renegotiation-flow.md @@ -0,0 +1,143 @@ +# 공급사 재협상 요청 (IMK #15) + +> 요구 원문: "협상결렬 건에 한하여 해당 공급사측 재협상 기능 추가 및 재협상 현황 확인·승인 화면 추가 요청" + +기존 재생성은 **담당자가 먼저** 마감 견적에서 공급사를 골라 다음 라운드를 만드는 방향이다(`regenerate_quotation`). +#15는 방향이 반대다 — **공급사가 먼저 요청하고 담당자가 승인**하면 그 결과로 다음 라운드가 생성된다. +따라서 재생성 로직 자체는 재사용하고, 그 앞에 "요청 → 심사" 단계를 붙인다. + +## 1. 용어와 범위 + +| 용어 | 정의 | +|---|---| +| 결렬 건 | 낙찰되지 못한 채 마감된 건. `quotations.status=CLOSED` 且 `close_reason ∈ {OPEN_PRICE(5), OPEN_EQUAL(6), OPEN_NOSHOW(7), OPEN_REJECT(8)}` | +| 요청 자격 | 그 견적의 **마지막 라운드**에 세션이 있는 공급사 본인 | +| 승인 결과 | 원 견적의 다음 라운드 생성(`regenerate_quotation` 과 동일 경로), 요청 공급사 포함 | + +### 요청 가능 조건 (전부 만족) +1. 견적이 마감(`CLOSED`)이고 `close_reason` 이 `OPEN_*` — **낙찰(AWARDED) 건은 불가** +2. 요청자가 그 견적 **마지막 라운드**의 세션 보유자 +3. 같은 세션에 **대기(PENDING) 상태 요청이 없음** (중복 방지) +4. 해당 견적 체인에 **더 뒤 라운드가 아직 없음** (이미 재생성됐으면 요청 의미 없음) + +거부(`REJECTED`)·미참여(`NOT_PARTICIPATED`) 세션도 요청은 허용한다 — 단종·품절로 거부했다가 조건이 풀리는 경우가 실제로 있다. + +## 2. 상태 흐름 + +``` +[공급사] 결렬 건 확인 → 재협상 요청(사유·희망가) → PENDING + │ +[담당자] 요청 현황 화면에서 심사 │ + ├─ 승인 → APPROVED → 다음 라운드 생성 → 초청메일 + └─ 반려 → REJECTED (사유 기록, 공급사에 노출) +``` + +요청 상태(`sessions.custom.renegotiation.status`) + +| 코드 | 상태 | 설명 | +|---|---|---| +| 1 | PENDING | 접수, 담당자 심사 대기 | +| 2 | APPROVED | 승인 — 다음 라운드 생성 완료 | +| 3 | REJECTED | 반려 — 사유 기록 | +| 4 | CANCELED | 공급사가 스스로 철회(PENDING 일 때만) | + +## 3. 저장 위치 — `sessions.custom` (DDL 0) + +**신규 테이블을 만들지 않는다.** 재협상 요청은 세션당 1건이라 기존 `negotiation.sessions.custom` JSONB 에 +`renegotiation` 키로 얹는다. 같은 컬럼의 협상완료 부가정보와 키가 갈리므로 서로 덮어쓰지 않는다. + +```json +{ + "std_lead_time": 30, // 기존: 협상완료 부가정보(session_fields 정의대로) + "renegotiation": { + "status": 1, // 1=PENDING 2=APPROVED 3=REJECTED 4=CANCELED + "reason": "가격 조건 재검토", + "desired_price": 15000000, // 선택 + "requested_at": "2026-07-23T10:00:00Z", + "decided_by": "", // 심사 후 채움 + "decided_at": "2026-07-23T11:00:00Z", + "memo": "목표가 대비 격차가 커 반려", + "next_quotation_id": "" + } +} +``` + +조회는 `sessions` 를 견적·공급사와 조인하면서 `custom -> 'renegotiation' ->> 'status'` 로 거른다. +세션 규모(수십~수백)에서 전용 인덱스 없이 충분하다. 느려지면 다음 순으로 올린다. + +1. `custom` 에 GIN 인덱스 +2. 그래도 부족하면 전용 테이블(`renegotiation_requests`)로 승격 — `custom` 값을 그대로 옮기면 되므로 되돌리기 부담이 작다 + +### 이 방식의 제약(수용 범위) +- **요청 이력은 최신 1건만 남는다.** 반려 후 재요청하면 이전 기록을 덮어쓴다. + 이력이 필요해지는 시점 = 테이블 승격 시점. +- 세션당 대기 요청 1건 제약은 DB 유니크가 아니라 **API 검증**으로 건다(기존 값이 PENDING 이면 거부). +- 쓰기는 반드시 `execute_lambda_run` 으로 — `execute_lambda` 는 커밋하지 않아 값이 조용히 사라진다. + +## 4. API + +### 공급사 포털 (backend) +| 메서드 | 경로 | 설명 | +|---|---|---| +| `GET` | `/v1/negotiation/renegotiation/eligible` | 요청 가능한 결렬 건 목록(본인 공급사) | +| `POST` | `/v1/negotiation/session/{session_id}/renegotiation` | 요청 생성 `{reason, desired_price?}` | +| `GET` | `/v1/negotiation/renegotiation` | 내 요청 목록(상태·심사결과 포함) | +| `DELETE` | `/v1/negotiation/session/{session_id}/renegotiation` | 철회(PENDING 일 때만) | + +### 관리자 (negodata backend) +| 메서드 | 경로 | 설명 | +|---|---|---| +| `GET` | `/v1/renegotiation/list` | 요청 현황(상태·견적·공급사 필터, 페이지네이션) | +| `POST` | `/v1/renegotiation/{session_id}/approve` | 승인 `{supplier_ids?}` — 미지정 시 요청자만 | +| `POST` | `/v1/renegotiation/{session_id}/reject` | 반려 `{memo}` | + +식별자는 별도 request_id 가 아니라 **`session_id`** 다 — 세션당 요청 1건이므로 그것으로 충분하다. +권한: 승인·반려는 **해당 견적 소유자 ∪ 최고관리자**(기존 `is_owner_or_admin` 그대로). + +## 5. 승인 처리 로직 + +1. 요청 상태가 `PENDING` 인지 확인(아니면 `INVALID_REQUEST_DATA`) +2. 대상 견적이 여전히 마지막 라운드인지 재확인 — 그 사이 담당자가 수동 재생성했을 수 있다 +3. `regenerate_quotation(qt_id, company_id, supplier_ids, ...)` 호출 + - `supplier_ids` 기본값 = 요청 공급사 1곳 + - 담당자가 화면에서 다른 공급사를 추가로 체크하면 함께 포함(같은 라운드에 묶음) +4. 성공 시 요청을 `APPROVED` 로, `next_quotation_id`·`decided_by`·`decided_at` 기록 +5. 생성된 라운드는 **기존과 동일하게 초청메일 수동 발송** — 자동 발송하지 않는다(현행 정책 유지) + +같은 견적에 대기 요청이 여러 건이면, 하나를 승인할 때 나머지도 함께 처리할지 담당자가 선택한다(기본: 함께 승인하여 한 라운드에 묶음). + +## 6. 화면 + +### 공급사 포털 +- **협상 목록**: 결렬 건 행에 `재협상 요청` 버튼. 이미 요청했으면 상태 배지(`심사 중` / `승인됨` / `반려됨`)로 대체 +- **요청 모달**: 사유 선택(프리셋: 가격 조건 재검토 / 재고·납기 확보 / 단가 정정 / 직접 입력) + 희망가(선택) + 안내 문구 +- 반려된 경우 배지에 담당자 메모를 툴팁으로 노출 + +### 관리자 +- **신규 메뉴 `재협상 요청`**(사이드바 업무 그룹). 목록 컬럼: 요청일시 · 견적번호/차수 · 상품 · 공급사 · 사유 · 희망가 · 상태 · 심사자 +- 상태 탭: `대기` / `승인` / `반려` — 기본 `대기` +- 행 클릭 → 심사 패널: 원 견적 요약(목표가·최저 투찰가·마감사유), 요청 사유·희망가, `승인` / `반려(사유 입력)` +- 승인 시 함께 포함할 공급사 체크박스(기본: 요청자만) +- **대시보드**: 대기 건수 카드 추가 — 방치 방지 + +## 7. 알림 + +- 요청 접수 → 견적 담당자에게 알림(기존 notifications 테이블 재사용) +- 승인/반려 → 공급사 포털 목록에서 상태로 확인. 메일은 승인 시 생성되는 **초청메일로 대체**(중복 발송 방지) + +## 8. 구현 순서 + +1. (DDL 없음) 요청 payload 형태를 양쪽 백엔드에서 공유하는 상수/헬퍼로 정리 +2. 공급사 포털 API(요청 생성·조회·철회) + 자격 판정 +3. 관리자 API(목록·승인·반려) + 승인 시 `regenerate_quotation` 연결 +4. 관리자 화면(메뉴·목록·심사 패널·대시보드 카드) +5. 공급사 포털 화면(버튼·모달·상태 배지) +6. 알림 연결 + +## 9. 미결 (IMK 확인 필요) + +- **요청 횟수 제한**: 같은 견적 체인에서 공급사가 몇 번까지 요청 가능한가? (제안: 체인당 1회) +- **요청 기한**: 마감 후 며칠까지 허용? (제안: 7일) +- **자동 승인 옵션**: 담당자 심사 없이 자동 재생성하는 회사 설정이 필요한가? (제안: 초기엔 없음 — 승인 필수) +- **희망가 노출 범위**: 담당자에게만인지, 협상 봇의 앵커링에 반영할지 (제안: 담당자 판단 근거로만, 봇 미반영) +- **요청 이력 보존 필요 여부**: 반려 후 재요청 이력을 남겨야 하면 `sessions.custom` 대신 전용 테이블이 필요하다 (제안: 초기엔 최신 1건만) diff --git a/negodata/front/src/api/generated/model/reqCreateCompanyUser.ts b/negodata/front/src/api/generated/model/reqCreateCompanyUser.ts index 795c416..05709e2 100644 --- a/negodata/front/src/api/generated/model/reqCreateCompanyUser.ts +++ b/negodata/front/src/api/generated/model/reqCreateCompanyUser.ts @@ -11,4 +11,5 @@ export interface ReqCreateCompanyUser { name?: string; email?: string; contact_number?: string; + role?: number; } diff --git a/negodata/front/src/api/generated/model/userRole.ts b/negodata/front/src/api/generated/model/userRole.ts index 860ab73..da61b29 100644 --- a/negodata/front/src/api/generated/model/userRole.ts +++ b/negodata/front/src/api/generated/model/userRole.ts @@ -6,9 +6,9 @@ */ /** - * users.role 코드값. negodata 유저는 전부 회사 직원(관리자측) — -의미 있는 구분은 '직원 계정 관리 권한 유무' 하나뿐이라 2단계로 둔다. -1=일반, 2=최고관리자(직원 계정 생성·관리). + * users.role 코드값. +1=일반, 2=최고관리자(고객사 최상위), 3=개발자(우리 내부 운영 계정). +개발자 계정은 고객사에 존재를 노출하지 않는다 — 회원 목록에서 빼고 총계에도 넣지 않는다. */ export type UserRole = typeof UserRole[keyof typeof UserRole]; @@ -17,4 +17,5 @@ export type UserRole = typeof UserRole[keyof typeof UserRole]; export const UserRole = { USER: 1, OWNER: 2, + DEVELOPER: 3, } as const; diff --git a/negodata/front/src/app/router.tsx b/negodata/front/src/app/router.tsx index 9a73de6..825521c 100644 --- a/negodata/front/src/app/router.tsx +++ b/negodata/front/src/app/router.tsx @@ -7,6 +7,7 @@ import LoginPage from '../pages/login'; import DashboardPage from '../pages/dashboard'; import StatisticsPage from '../pages/statistics'; import ForbiddenPage from '../pages/forbidden'; +import DevDesignPage from '../pages/dev-design'; import NotFoundPage from '../pages/not-found'; import ProductsPage from '../pages/products'; import PartnersPage from '../pages/partners'; @@ -88,16 +89,25 @@ export const router = createBrowserRouter([ path: 'members', loader: async () => { await initAuth(); - return hasRole('최고관리자') ? null : redirect('/forbidden'); + return hasRole('최고관리자', '개발자') ? null : redirect('/forbidden'); }, Component: MembersPage, }, + { + // 개발자 전용. 디자인 토큰·컴포넌트 기준 화면. + path: 'dev/design', + loader: async () => { + await initAuth(); + return hasRole('개발자') ? null : redirect('/forbidden'); + }, + Component: DevDesignPage, + }, { // 최고관리자 전용. 회사 브랜딩/용어/커스텀필드 설정. (자식 loader 는 부모와 병렬 → initAuth 대기 필수) path: 'settings', loader: async () => { await initAuth(); - return hasRole('최고관리자') ? null : redirect('/forbidden'); + return hasRole('최고관리자', '개발자') ? null : redirect('/forbidden'); }, Component: SettingsPage, }, diff --git a/negodata/front/src/components/layout/AuthenticatedLayout.tsx b/negodata/front/src/components/layout/AuthenticatedLayout.tsx index 720cac1..8047a66 100644 --- a/negodata/front/src/components/layout/AuthenticatedLayout.tsx +++ b/negodata/front/src/components/layout/AuthenticatedLayout.tsx @@ -13,6 +13,7 @@ const PAGE_TO_PATH: Record = { CARDS: '/cards', MEMBERS: '/members', SETTINGS: '/settings', + DESIGN: '/dev/design', NOTIFICATIONS: '/notifications', }; diff --git a/negodata/front/src/components/layout/Layout.tsx b/negodata/front/src/components/layout/Layout.tsx index 82075f3..db26391 100644 --- a/negodata/front/src/components/layout/Layout.tsx +++ b/negodata/front/src/components/layout/Layout.tsx @@ -26,6 +26,7 @@ import { Sun, Moon, Building, + Palette, ChevronRight, Menu, X, @@ -41,9 +42,9 @@ interface LayoutProps { type SidebarUser = ReturnType['user']; -type MenuItem = { type: PageType; label: string; icon: ElementType; id: string; ownerOnly?: boolean }; +type MenuItem = { type: PageType; label: string; icon: ElementType; id: string; ownerOnly?: boolean; devOnly?: boolean }; -// ownerOnly 항목은 최고관리자에게만 노출된다(렌더 시 user.role 로 필터). +// ownerOnly = 최고관리자 이상, devOnly = 개발자(내부 운영)만. 렌더 시 user.role 로 필터한다. // 그룹 라벨은 사이드바 섹션 헤더로 노출(접힘 상태에선 숨김). const menuGroups: { label?: string; items: MenuItem[] }[] = [ { @@ -65,13 +66,26 @@ const menuGroups: { label?: string; items: MenuItem[] }[] = [ label: '관리', items: [ { type: 'MEMBERS', label: '회원관리', icon: UserCog, id: 'sidebar-members', ownerOnly: true }, - { type: 'SETTINGS', label: '회사 설정', icon: Building, id: 'sidebar-settings', ownerOnly: true }, + ], + }, + { + label: '개발자', + items: [ + { type: 'SETTINGS', label: '회사 설정', icon: Building, id: 'sidebar-settings', devOnly: true }, + { type: 'DESIGN', label: '디자인 시스템', icon: Palette, id: 'sidebar-design', devOnly: true }, ], }, ]; const menuItems: MenuItem[] = menuGroups.flatMap((g) => g.items); +// 메뉴 노출 판정 — devOnly 는 개발자만, ownerOnly 는 최고관리자 이상. +const canSee = (item: MenuItem, role?: string): boolean => { + if (item.devOnly) return role === '개발자'; + if (item.ownerOnly) return role === '최고관리자' || role === '개발자'; + return true; +}; + const pageLabelMap: Record = { DASHBOARD: '대시보드', STATISTICS: '통계', @@ -81,6 +95,7 @@ const pageLabelMap: Record = { CARDS: '협상카드관리', MEMBERS: '회원관리', SETTINGS: '회사 설정', + DESIGN: '디자인 시스템', NOTIFICATIONS: '알림', }; @@ -100,7 +115,7 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay // 사이드바 펼침 여부(라벨/프로필 노출 기준). 모바일 드로어는 항상 펼친 상태로 본다. const expanded = isMobileOpen || isSidebarOpen; - const visibleItems = menuItems.filter((item) => !item.ownerOnly || user?.role === '최고관리자'); + const visibleItems = menuItems.filter((item) => canSee(item, user?.role)); // ⌘K / Ctrl+K 로 빠른 이동 열기 useEffect(() => { @@ -196,7 +211,7 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay {/* Nav Items */}