[feat] negodata: 권한 3단계(개발자 신설)·개발자 메뉴 그룹·디자인 시스템 페이지

- UserRole 1=일반 2=최고관리자 3=개발자(내부 운영). 개발자 계정은 회원 목록·총계에서 제외해 고객사에 노출하지 않음
- 계정 생성 시 권한 선택(일반/최고관리자) 추가, 개발자는 앱에서 부여 불가(DB 시드 전용)
- 사이드바 '개발자' 그룹 신설 — 회사 설정·디자인 시스템을 개발자에게만 노출
- /dev/design 디자인 시스템 페이지: 색 토큰·타이포·버튼·배지·입력·반경, 목록 화면 구조, 반응형 기준, URL 상태 규칙, 회사 커스터마이징 훅
- 공급사 재협상 요청(#15) 플로우 문서 — sessions.custom 기반(DDL 0)
This commit is contained in:
Mina Choi 2026-07-23 15:41:36 +09:00
parent db07a60cfa
commit cfee6e89d0
20 changed files with 469 additions and 43 deletions

View File

@ -3,12 +3,33 @@ import { useSearchParams } from 'react-router'
import { authApi } from '@/apis' import { authApi } from '@/apis'
import type { Branding } from '@/apis/auth/auth.type' 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 로 인증 없이 브랜딩만 조회한다. // 협력사는 초청 메일의 /chat?session_id=... 로 들어오므로, 그 session_id 로 인증 없이 브랜딩만 조회한다.
// session_id 가 없으면 회사를 특정할 수 없으므로 기본 브랜드(Negosium)로 둔다. // session_id 가 없으면 마지막으로 확인된 회사 브랜딩을 쓰고, 그것도 없으면 기본 브랜드(Negosium).
export function usePreLoginBranding(): Branding | null { export function usePreLoginBranding(): Branding | null {
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const [branding, setBranding] = useState<Branding | null>(null) const [branding, setBranding] = useState<Branding | null>(readCached)
const sessionId = searchParams.get('session_id') ?? '' const sessionId = searchParams.get('session_id') ?? ''
@ -26,9 +47,10 @@ export function usePreLoginBranding(): Branding | null {
} }
if (!next.service_name && !next.logo_url) return if (!next.service_name && !next.logo_url) return
setBranding(next) setBranding(next)
writeCached(next) // 다음 진입에 session_id 가 없어도 이 회사로 보이게 한다
}) })
.catch(() => { .catch(() => {
// 조회 실패 시 기본 브랜드로 그대로 둔다 // 조회 실패 시 캐시(또는 기본 브랜드)를 그대로 둔다
}) })
return () => { return () => {
alive = false alive = false

View File

@ -2,8 +2,8 @@ from common.enums import UserRole
def is_owner_or_admin(resource_user_id, user_id, role) -> bool: def is_owner_or_admin(resource_user_id, user_id, role) -> bool:
"""변경 액션 공용 소유권 판정 — 리소스 소유자(user_id 일치) 또는 최고관리자(OWNER)면 True. """변경 액션 공용 소유권 판정 — 리소스 소유자(user_id 일치) 또는 최고관리자 이상(OWNER/DEVELOPER)이면 True.
프론트의 버튼 게이팅과 같은 규칙을 백엔드에서 강제하는 단일 출처. 프론트의 버튼 게이팅과 같은 규칙을 백엔드에서 강제하는 단일 출처.
소유자 없는 공용 리소스(예: user_id NULL 공용카드)는 이 판정 대상이 아니다(도메인별 별도 처리).""" 소유자 없는 공용 리소스(예: 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

View File

@ -122,12 +122,13 @@ class UserStatus(CodeEnum):
class UserRole(CodeEnum): class UserRole(CodeEnum):
"""users.role 코드값. negodata 유저는 전부 회사 직원(관리자측) — """users.role 코드값.
의미 있는 구분은 '직원 계정 관리 권한 유무' 하나뿐이라 2단계로 둔다. 1=일반, 2=최고관리자(고객사 최상위), 3=개발자(우리 내부 운영 계정).
1=일반, 2=최고관리자(직원 계정 생성·관리).""" 개발자 계정은 고객사에 존재를 노출하지 않는다 — 회원 목록에서 빼고 총계에도 넣지 않는다."""
USER = 1 USER = 1
OWNER = 2 # 최고관리자: 자기 회사 유저(직원 계정)를 생성·관리 OWNER = 2 # 최고관리자: 자기 회사 계정 관리 + 회사 설정
DEVELOPER = 3 # 개발자(내부 운영): 최고관리자 권한 전부 + 고객사에 보이지 않음
class CompanyStatus(CodeEnum): class CompanyStatus(CodeEnum):

View File

@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import users, companies 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.logger import LOG
from common.utils.gtime import GTime from common.utils.gtime import GTime
@ -40,7 +40,7 @@ class IUserCRUD(ABC):
pass pass
@abstractmethod @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 pass
@abstractmethod @abstractmethod
@ -120,10 +120,13 @@ class UserCRUD(IUserCRUD):
return ErrorType.DB_RUN_FAILED return ErrorType.DB_RUN_FAILED
async def list_by_company( 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]: ) -> Tuple[ErrorType, list, int]:
try: try:
conditions = [users.deleted == False, users.company_id == company_id] # noqa: E712 conditions = [users.deleted == False, users.company_id == company_id] # noqa: E712
# 개발자(내부 운영) 계정은 고객사에 존재 자체가 보이면 안 된다 — 목록에서 빼고 총계에도 넣지 않는다.
if hide_dev:
conditions.append(users.role != UserRole.DEVELOPER.value)
if search: if search:
conditions.append( conditions.append(
or_( or_(

View File

@ -14,12 +14,13 @@ class CompanyUserProtocol(WebPacketProtocol):
class Req_CreateCompanyUser(CompanyUserProtocol): class Req_CreateCompanyUser(CompanyUserProtocol):
# role 은 받지 않는다 — 최고관리자가 만드는 계정은 항상 일반(USER) 로 서버에서 고정. # role 은 일반(1)·최고관리자(2)만 허용 — 개발자(3)는 앱에서 만들 수 없다(DB 시드로만).
id: str = "" id: str = ""
password: str = "" password: str = ""
name: str = "" name: str = ""
email: str = "" email: str = ""
contact_number: str = "" contact_number: str = ""
role: int = 1
class Req_UpdateCompanyUser(CompanyUserProtocol): class Req_UpdateCompanyUser(CompanyUserProtocol):

View File

@ -2,6 +2,7 @@ from uuid import UUID
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from common.enums import UserRole
from common.models.gmodel import PageParams, UserInfo from common.models.gmodel import PageParams, UserInfo
from router.v1.validator.dependencies import RemoveNoneResponse, RequireOwner from router.v1.validator.dependencies import RemoveNoneResponse, RequireOwner
from services.company_user_service import CompanyUserService from services.company_user_service import CompanyUserService
@ -13,39 +14,40 @@ from .protocol import (
Res_DeleteCompanyUser, Res_DeleteCompanyUser,
) )
# 최고관리자(OWNER) 전용. 모든 엔드포인트가 RequireOwner 로 게이트되며 company_id 는 토큰값으로 스코프된다. # 최고관리자 이상 전용. company_id 는 토큰값으로 스코프되고,
# 개발자(내부 운영) 계정은 고객사 최고관리자에게 목록에서 감춰진다(존재 자체를 노출하지 않는다).
router = APIRouter(prefix="/v1/company/user", tags=["CompanyUser"], responses={404: {"description": "Not found"}}) router = APIRouter(prefix="/v1/company/user", tags=["CompanyUser"], responses={404: {"description": "Not found"}})
@router.get(path="/list", response_model=Res_CompanyUserList, summary="회사 유저 목록(최고관리자)") @router.get(path="/list", response_model=Res_CompanyUserList, summary="회사 유저 목록(최고관리자)")
async def list_users( async def list_users(
service: CompanyUserService = Depends(), service: CompanyUserService = Depends(),
owner: UserInfo = Depends(RequireOwner), actor: UserInfo = Depends(RequireOwner),
search: str | None = Query(None, description="로그인ID/이름/이메일 검색"), search: str | None = Query(None, description="로그인ID/이름/이메일 검색"),
pg: PageParams = Depends(), 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="회사 유저 생성(일반 권한 고정)") @router.post(path="/create", response_model=Res_CompanyUser, summary="회사 유저 생성(일반 권한 고정)")
async def create_user( 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="회사 유저 조회") @router.get(path="/{user_id}", response_model=Res_CompanyUser, summary="회사 유저 조회")
async def get_user(user_id: UUID, service: CompanyUserService = Depends(), owner: UserInfo = Depends(RequireOwner)): async def get_user(user_id: UUID, service: CompanyUserService = Depends(), actor: UserInfo = Depends(RequireOwner)):
return RemoveNoneResponse(await service.get_user(owner.company_id, str(user_id))) return RemoveNoneResponse(await service.get_user(actor.company_id, str(user_id)))
@router.patch(path="/update/{user_id}", response_model=Res_CompanyUser, summary="회사 유저 수정") @router.patch(path="/update/{user_id}", response_model=Res_CompanyUser, summary="회사 유저 수정")
async def update_user( 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="회사 유저 삭제") @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)): async def delete_user(user_id: UUID, service: CompanyUserService = Depends(), actor: UserInfo = Depends(RequireOwner)):
return RemoveNoneResponse(await service.delete_user(owner.company_id, str(user_id))) return RemoveNoneResponse(await service.delete_user(actor.company_id, str(user_id)))

View File

@ -100,9 +100,9 @@ async def IsValidRefreshToken(credentials: HTTPAuthorizationCredentials = Depend
return DecodeRefreshToken(credentials.credentials) return DecodeRefreshToken(credentials.credentials)
# 최고관리자 전용 엔드포인트 게이트. 액세스 토큰 검증 + role==OWNER 가 아니면 403. # 최고관리자 이상(OWNER/DEVELOPER) 게이트. 회원관리·회사설정에 건다.
async def RequireOwner(user_info: UserInfo = Depends(IsValidAccessToken)) -> UserInfo: 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 raise EXCEPTION_FORBIDDEN
return user_info return user_info

View File

@ -44,14 +44,14 @@ class CompanyUserService:
return ErrorType.ACCOUNT_FORBIDDEN, None return ErrorType.ACCOUNT_FORBIDDEN, None
return ErrorType.SUCCESS, user 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) res = Res_CompanyUserList(page=pg.page, size=pg.size)
company_uuid = uuid.UUID(company_id) company_uuid = uuid.UUID(company_id)
err_type, rows, total = await DB_SESSION_MNG.execute_lambda( err_type, rows, total = await DB_SESSION_MNG.execute_lambda(
users.DBType(), users.DBType(),
DBWRType.DB_READ.value, 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: if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type) res.result.SetResult(err_type)
@ -86,7 +86,7 @@ class CompanyUserService:
res.result.SetResult(err_type) res.result.SetResult(err_type)
return res return res
# 2) 생성 — 회사는 토큰값, 권한은 항상 USER 로 고정. # 2) 생성 — 회사는 토큰값. 권한은 일반·관리자만 허용(최고관리자 승격은 앱에서 불가).
user = users( user = users(
company_id=company_uuid, company_id=company_uuid,
id=req.id, id=req.id,
@ -94,7 +94,7 @@ class CompanyUserService:
name=req.name or None, name=req.name or None,
email=req.email or None, email=req.email or None,
contact_number=req.contact_number 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( err_type = await DB_SESSION_MNG.execute_lambda_run(
[users.DBType()], [users.DBType()],

View File

@ -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": "<user_id>", // 심사 후 채움
"decided_at": "2026-07-23T11:00:00Z",
"memo": "목표가 대비 격차가 커 반려",
"next_quotation_id": "<qt_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건만)

View File

@ -11,4 +11,5 @@ export interface ReqCreateCompanyUser {
name?: string; name?: string;
email?: string; email?: string;
contact_number?: string; contact_number?: string;
role?: number;
} }

View File

@ -6,9 +6,9 @@
*/ */
/** /**
* users.role 코드값. negodata 유저는 전부 회사 직원(관리자측) — * users.role 코드값.
의미 있는 구분은 '직원 계정 관리 권한 유무' 하나뿐이라 2단계로 둔다. 1=일반, 2=최고관리자(고객사 최상위), 3=개발자(우리 내부 운영 계정).
1=일반, 2=최고관리자(직원 계정 생성·관리). 개발자 계정은 고객사에 존재를 노출하지 않는다 — 회원 목록에서 빼고 총계에도 넣지 않는다.
*/ */
export type UserRole = typeof UserRole[keyof typeof UserRole]; export type UserRole = typeof UserRole[keyof typeof UserRole];
@ -17,4 +17,5 @@ export type UserRole = typeof UserRole[keyof typeof UserRole];
export const UserRole = { export const UserRole = {
USER: 1, USER: 1,
OWNER: 2, OWNER: 2,
DEVELOPER: 3,
} as const; } as const;

View File

@ -7,6 +7,7 @@ import LoginPage from '../pages/login';
import DashboardPage from '../pages/dashboard'; import DashboardPage from '../pages/dashboard';
import StatisticsPage from '../pages/statistics'; import StatisticsPage from '../pages/statistics';
import ForbiddenPage from '../pages/forbidden'; import ForbiddenPage from '../pages/forbidden';
import DevDesignPage from '../pages/dev-design';
import NotFoundPage from '../pages/not-found'; import NotFoundPage from '../pages/not-found';
import ProductsPage from '../pages/products'; import ProductsPage from '../pages/products';
import PartnersPage from '../pages/partners'; import PartnersPage from '../pages/partners';
@ -88,16 +89,25 @@ export const router = createBrowserRouter([
path: 'members', path: 'members',
loader: async () => { loader: async () => {
await initAuth(); await initAuth();
return hasRole('최고관리자') ? null : redirect('/forbidden'); return hasRole('최고관리자', '개발자') ? null : redirect('/forbidden');
}, },
Component: MembersPage, Component: MembersPage,
}, },
{
// 개발자 전용. 디자인 토큰·컴포넌트 기준 화면.
path: 'dev/design',
loader: async () => {
await initAuth();
return hasRole('개발자') ? null : redirect('/forbidden');
},
Component: DevDesignPage,
},
{ {
// 최고관리자 전용. 회사 브랜딩/용어/커스텀필드 설정. (자식 loader 는 부모와 병렬 → initAuth 대기 필수) // 최고관리자 전용. 회사 브랜딩/용어/커스텀필드 설정. (자식 loader 는 부모와 병렬 → initAuth 대기 필수)
path: 'settings', path: 'settings',
loader: async () => { loader: async () => {
await initAuth(); await initAuth();
return hasRole('최고관리자') ? null : redirect('/forbidden'); return hasRole('최고관리자', '개발자') ? null : redirect('/forbidden');
}, },
Component: SettingsPage, Component: SettingsPage,
}, },

View File

@ -13,6 +13,7 @@ const PAGE_TO_PATH: Record<PageType, string> = {
CARDS: '/cards', CARDS: '/cards',
MEMBERS: '/members', MEMBERS: '/members',
SETTINGS: '/settings', SETTINGS: '/settings',
DESIGN: '/dev/design',
NOTIFICATIONS: '/notifications', NOTIFICATIONS: '/notifications',
}; };

View File

@ -26,6 +26,7 @@ import {
Sun, Sun,
Moon, Moon,
Building, Building,
Palette,
ChevronRight, ChevronRight,
Menu, Menu,
X, X,
@ -41,9 +42,9 @@ interface LayoutProps {
type SidebarUser = ReturnType<typeof useAuth>['user']; type SidebarUser = ReturnType<typeof useAuth>['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[] }[] = [ const menuGroups: { label?: string; items: MenuItem[] }[] = [
{ {
@ -65,13 +66,26 @@ const menuGroups: { label?: string; items: MenuItem[] }[] = [
label: '관리', label: '관리',
items: [ items: [
{ type: 'MEMBERS', label: '회원관리', icon: UserCog, id: 'sidebar-members', ownerOnly: true }, { 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); 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<PageType, string> = { const pageLabelMap: Record<PageType, string> = {
DASHBOARD: '대시보드', DASHBOARD: '대시보드',
STATISTICS: '통계', STATISTICS: '통계',
@ -81,6 +95,7 @@ const pageLabelMap: Record<PageType, string> = {
CARDS: '협상카드관리', CARDS: '협상카드관리',
MEMBERS: '회원관리', MEMBERS: '회원관리',
SETTINGS: '회사 설정', SETTINGS: '회사 설정',
DESIGN: '디자인 시스템',
NOTIFICATIONS: '알림', NOTIFICATIONS: '알림',
}; };
@ -100,7 +115,7 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
// 사이드바 펼침 여부(라벨/프로필 노출 기준). 모바일 드로어는 항상 펼친 상태로 본다. // 사이드바 펼침 여부(라벨/프로필 노출 기준). 모바일 드로어는 항상 펼친 상태로 본다.
const expanded = isMobileOpen || isSidebarOpen; 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 로 빠른 이동 열기 // ⌘K / Ctrl+K 로 빠른 이동 열기
useEffect(() => { useEffect(() => {
@ -196,7 +211,7 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
{/* Nav Items */} {/* Nav Items */}
<nav className="mt-1 px-3 overflow-y-auto"> <nav className="mt-1 px-3 overflow-y-auto">
{menuGroups.map((group, gi) => { {menuGroups.map((group, gi) => {
const items = group.items.filter((item) => !item.ownerOnly || user?.role === '최고관리자'); const items = group.items.filter((item) => canSee(item, user?.role));
if (items.length === 0) return null; if (items.length === 0) return null;
return ( return (
<div key={group.label ?? gi} className="space-y-0.5"> <div key={group.label ?? gi} className="space-y-0.5">

View File

@ -10,6 +10,8 @@ import { Input } from '@/components/ui/input';
import { PhoneInput } from '@/components/ui/phone-input'; import { PhoneInput } from '@/components/ui/phone-input';
import { Sheet } from '@/components/ui/sheet'; import { Sheet } from '@/components/ui/sheet';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { UserRole } from '@/api/generated/model';
import { USER_ROLE_LABEL } from '@/lib/enumLabels';
import { import {
UserStatus, UserStatus,
USER_STATUS_LABEL, USER_STATUS_LABEL,
@ -27,6 +29,7 @@ const schema = z.object({
email: z.string().trim().email('이메일 형식을 확인해 주십시오.').or(z.literal('')), email: z.string().trim().email('이메일 형식을 확인해 주십시오.').or(z.literal('')),
contactNumber: z.string().trim(), contactNumber: z.string().trim(),
status: z.number(), status: z.number(),
role: z.number(),
}); });
type FormValues = z.infer<typeof schema>; type FormValues = z.infer<typeof schema>;
@ -51,9 +54,10 @@ function buildDefaults(mode: 'create' | 'edit', member: Member | null): FormValu
email: member.email ?? '', email: member.email ?? '',
contactNumber: normalizePhone(member.contact_number ?? ''), contactNumber: normalizePhone(member.contact_number ?? ''),
status: member.status, status: member.status,
role: member.role,
}; };
} }
return { id: '', password: '', passwordConfirm: '', name: '', email: '', contactNumber: '', status: UserStatus.ACTIVE }; return { id: '', password: '', passwordConfirm: '', name: '', email: '', contactNumber: '', status: UserStatus.ACTIVE, role: UserRole.USER };
} }
const inputClass = 'text-foreground text-xs'; const inputClass = 'text-foreground text-xs';
@ -101,6 +105,7 @@ export function MemberFormSheet({
name: v.name.trim() || undefined, name: v.name.trim() || undefined,
email: v.email.trim() || undefined, email: v.email.trim() || undefined,
contact_number: v.contactNumber.trim() || undefined, contact_number: v.contactNumber.trim() || undefined,
role: v.role,
}); });
showToast('신규 계정이 생성되었습니다.', 'success'); showToast('신규 계정이 생성되었습니다.', 'success');
onClose(); onClose();
@ -174,6 +179,32 @@ export function MemberFormSheet({
<Typography as="label" variant="label">이름</Typography> <Typography as="label" variant="label">이름</Typography>
<Input id="form-member-name" type="text" {...register('name')} className={inputClass} placeholder="김직원" /> <Input id="form-member-name" type="text" {...register('name')} className={inputClass} placeholder="김직원" />
</div> </div>
{/* 권한 (create 전용) — 최고관리자는 앱에서 부여 불가 */}
{mode === 'create' && (
<div className="space-y-1">
<Typography as="label" variant="label">권한</Typography>
<Controller
control={control}
name="role"
render={({ field }) => (
<Select value={String(field.value)} onValueChange={(v) => field.onChange(Number(v))}>
<SelectTrigger id="form-member-role" className="w-full">
<SelectValue>
{(value) => USER_ROLE_LABEL[Number(value) as UserRole] ?? ''}
</SelectValue>
</SelectTrigger>
<SelectContent>
{[UserRole.USER, UserRole.OWNER].map((r) => (
<SelectItem key={r} value={String(r)}>
{USER_ROLE_LABEL[r]}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
)}
{/* 상태 (edit 전용) */} {/* 상태 (edit 전용) */}
{mode === 'edit' && ( {mode === 'edit' && (
<div className="space-y-1"> <div className="space-y-1">

View File

@ -27,6 +27,7 @@ export interface ReqCreateCompanyUser {
name?: string; name?: string;
email?: string; email?: string;
contact_number?: string; contact_number?: string;
role?: number; // 1=일반 2=관리자. 최고관리자(3)는 앱에서 부여 불가
} }
export interface ReqUpdateCompanyUser { export interface ReqUpdateCompanyUser {

View File

@ -23,6 +23,7 @@ export const DELIVERY_TYPE_OPTIONS = Object.values(DeliveryType).map((value) =>
export const USER_ROLE_LABEL: Record<UserRole, string> = { export const USER_ROLE_LABEL: Record<UserRole, string> = {
[UserRole.USER]: '일반', [UserRole.USER]: '일반',
[UserRole.OWNER]: '최고관리자', [UserRole.OWNER]: '최고관리자',
[UserRole.DEVELOPER]: '개발자',
}; };
export const SUPPLIER_TYPE_LABEL: Record<SupplierType, string> = { export const SUPPLIER_TYPE_LABEL: Record<SupplierType, string> = {

View File

@ -0,0 +1,193 @@
import { PageContainer } from '@/components/layout/PageContainer';
import { Typography, typographyVariants } from '@/components/ui/typography';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { StatusPill } from '@/features/quotations/components/QuotationDetailSheet/StatusPill';
import { cn } from '@/lib/utils';
// 개발자 전용 디자인 시스템 페이지(/dev/design).
// 토큰·타이포·컴포넌트 변형을 한 화면에서 대조해 새 화면을 만들 때 기준을 맞춘다.
// 여기 없는 스타일을 새로 만들지 말고, 필요하면 토큰/변형을 먼저 늘린다.
const COLOR_TOKENS: { name: string; className: string; note: string }[] = [
{ name: 'primary', className: 'bg-primary', note: '주요 액션·선택 상태' },
{ name: 'secondary', className: 'bg-secondary', note: '보조 버튼' },
{ name: 'muted', className: 'bg-muted', note: '표 헤더·비활성 배경' },
{ name: 'card', className: 'bg-card border border-border', note: '카드 면' },
{ name: 'destructive', className: 'bg-destructive', note: '삭제·경고' },
{ name: 'success', className: 'bg-success', note: '낙찰·성공' },
{ name: 'warning', className: 'bg-warning', note: '주의' },
{ name: 'info', className: 'bg-info', note: '정보' },
];
const TYPO_VARIANTS = ['h1', 'h2', 'h3', 'h4', 'body', 'small', 'muted', 'label', 'mono', 'caption', 'link'] as const;
const BUTTON_VARIANTS = ['default', 'outline', 'secondary', 'ghost', 'destructive', 'link'] as const;
const BUTTON_SIZES = ['sm', 'default', 'lg'] as const;
const RADII: { name: string; className: string; use: string }[] = [
{ name: 'rounded-md', className: 'rounded-md', use: '입력·작은 버튼' },
{ name: 'rounded-lg', className: 'rounded-lg', use: '카드·테이블 컨테이너' },
{ name: 'rounded-full', className: 'rounded-full', use: '배지·아바타' },
];
export default function DevDesignPage() {
return (
<PageContainer>
<Section title="색 토큰" desc="직접 hex 를 쓰지 말고 토큰 클래스를 쓴다. 다크모드가 토큰에서 갈린다.">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{COLOR_TOKENS.map((c) => (
<div key={c.name} className="space-y-1.5">
<div className={cn('h-14 w-full rounded-lg', c.className)} />
<Typography as="span" variant="label" className="block">{c.name}</Typography>
<Typography as="span" variant="caption" className="block">{c.note}</Typography>
</div>
))}
</div>
</Section>
<Section title="타이포그래피" desc="모든 텍스트는 Typography 컴포넌트로 쓴다. 여기 없는 크기를 직접 박지 않는다.">
<div className="space-y-3">
{TYPO_VARIANTS.map((v) => (
<div key={v} className="flex flex-wrap items-baseline gap-3 border-b border-border/50 pb-2">
<code className="w-24 shrink-0 font-mono text-[11px] text-muted-foreground">{v}</code>
<Typography variant={v}>다람쥐 헌 쳇바퀴에 타고파 · Negotium 0123</Typography>
</div>
))}
</div>
</Section>
<Section title="버튼" desc="variant × size 조합. 아이콘은 lucide-react 를 쓰고 크기는 13~16px.">
<div className="space-y-3">
{BUTTON_VARIANTS.map((v) => (
<div key={v} className="flex flex-wrap items-center gap-2">
<code className="w-24 shrink-0 font-mono text-[11px] text-muted-foreground">{v}</code>
{BUTTON_SIZES.map((size) => (
<Button key={size} variant={v} size={size}>{size}</Button>
))}
<Button variant={v} disabled>disabled</Button>
</div>
))}
</div>
</Section>
<Section title="배지 · 상태" desc="배지는 정적 라벨, StatusPill 은 진행 상태(색이 의미를 가진다).">
<div className="flex flex-wrap items-center gap-2">
<Badge>default</Badge>
<Badge variant="secondary">secondary</Badge>
<Badge variant="outline">outline</Badge>
<Badge variant="destructive">destructive</Badge>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<StatusPill tone="zinc">대기</StatusPill>
<StatusPill tone="blue">진행중</StatusPill>
<StatusPill tone="emerald">완료</StatusPill>
<StatusPill tone="amber">주의</StatusPill>
<StatusPill tone="rose">거절</StatusPill>
</div>
</Section>
<Section title="입력" desc="폼은 react-hook-form + zod. 라벨은 Typography variant='label'.">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<Typography as="label" variant="label">기본</Typography>
<Input placeholder="입력하세요" />
</div>
<div className="space-y-1">
<Typography as="label" variant="label">비활성</Typography>
<Input placeholder="비활성" disabled />
</div>
</div>
</Section>
<Section title="반경" desc="위계별로 갈린다 — 섞어 쓰면 화면이 흐트러진다.">
<div className="flex flex-wrap gap-4">
{RADII.map((r) => (
<div key={r.name} className="space-y-1.5">
<div className={cn('h-14 w-24 border border-border bg-muted', r.className)} />
<Typography as="span" variant="label" className="block">{r.name}</Typography>
<Typography as="span" variant="caption" className="block">{r.use}</Typography>
</div>
))}
</div>
</Section>
<Section title="목록 화면 구조" desc="툴바와 테이블을 한 카드로 붙인다(포털형). 페이지마다 새로 짜지 말 것.">
<pre className="overflow-x-auto rounded-lg border border-border bg-muted/40 p-3 font-mono text-[11px] leading-relaxed text-foreground">{`<PageContainer>
<div className="overflow-hidden rounded-lg border border-border bg-card">
<PageToolbar className="rounded-none border-0 border-b border-border" actions={...}>
<SearchInput ... /> {/* 필터 Select 도 여기 */}
</PageToolbar>
<XxxTable className="rounded-none border-0" ... />
</div>
</PageContainer>`}</pre>
<Typography variant="caption" className="mt-2 block">
상품·협력사·견적·협상카드·회원 5개 목록이 이 구조를 공유한다.
</Typography>
</Section>
<Section title="반응형 기준" desc="목록 화면은 lg(1024px)에서 표 ↔ 카드로 갈린다.">
<ul className="space-y-1.5">
{[
'DataTable: lg 이상 표(가로 넘치면 overflow-x-auto), 미만 카드 리스트',
'PageToolbar: lg 이상 한 줄, 미만 검색/필터·액션 2줄(각각 flex-wrap)',
'견적 상세 협상현황: lg 이상 13컬럼 표, 미만 세션별 카드',
'컨테이너 쿼리(@container)는 쓰지 않는다 — 감싸는 래퍼가 생기면 전환 지점이 흔들린다',
].map((t) => (
<li key={t} className="flex gap-2">
<span className="text-muted-foreground">·</span>
<Typography as="span" variant="small">{t}</Typography>
</li>
))}
</ul>
</Section>
<Section title="링크" desc="react-router Link 에는 typographyVariants({ variant: 'link' }) 를 합성한다.">
<a href="#" className={cn(typographyVariants({ variant: 'link' }), 'text-sm font-semibold')}>
링크 텍스트 예시
</a>
</Section>
<Section title="화면 상태 · URL" desc="시트/모달/탭 상태는 URL 쿼리로 남긴다 — 새로고침·링크 공유·빠른이동(⌘K)에서 살아난다.">
<ul className="space-y-1.5">
{[
'상세 시트: ?detail=<id> 신규: ?new 모달: ?modal=<key>',
'탭: ?tab=<key> (회사 설정), ?guide=<key> (이용안내)',
'useOverlay().open(key, id) 또는 <Link to="?detail=..."> 둘 다 쓰인다 — 키 이름 바꿀 땐 양쪽 grep',
].map((t) => (
<li key={t} className="flex gap-2">
<span className="text-muted-foreground">·</span>
<Typography as="span" variant="small" className="font-mono text-[11px]">{t}</Typography>
</li>
))}
</ul>
</Section>
<Section title="회사 커스터마이징" desc="라벨·숨김은 코드가 아니라 회사 설정(DB)에서 온다. 하드코딩 금지.">
<ul className="space-y-1.5">
{[
"용어: const label = useLabels(); label('item.price') — 카탈로그(catalog.ts)에 키를 먼저 추가",
"숨김: const isHidden = useHiddenFields(); isHidden('selling_price') — 목록·폼·엑셀 3곳 모두 반영",
'브랜딩: useBranding() — 서비스명·로고·색상',
].map((t) => (
<li key={t} className="flex gap-2">
<span className="text-muted-foreground">·</span>
<Typography as="span" variant="small" className="font-mono text-[11px]">{t}</Typography>
</li>
))}
</ul>
</Section>
</PageContainer>
);
}
function Section({ title, desc, children }: { title: string; desc: string; children: React.ReactNode }) {
return (
<section className="rounded-lg border border-border bg-card p-4">
<Typography variant="h4">{title}</Typography>
<Typography variant="caption" className="mt-0.5 mb-4 block">{desc}</Typography>
{children}
</section>
);
}

View File

@ -1,6 +1,6 @@
import {create} from 'zustand'; import {create} from 'zustand';
export type UserRole = '최고관리자' | '일반'; export type UserRole = '개발자' | '최고관리자' | '일반';
export interface AuthUser { export interface AuthUser {
userId: string; userId: string;

View File

@ -37,4 +37,4 @@ export interface NegotiationCard {
usedCount: number; // 카드 사용 세션 수(표본) usedCount: number; // 카드 사용 세션 수(표본)
} }
export type PageType = 'DASHBOARD' | 'STATISTICS' | 'PRODUCTS' | 'PARTNERS' | 'QUOTATION' | 'CARDS' | 'MEMBERS' | 'SETTINGS' | 'NOTIFICATIONS'; export type PageType = 'DASHBOARD' | 'STATISTICS' | 'PRODUCTS' | 'PARTNERS' | 'QUOTATION' | 'CARDS' | 'MEMBERS' | 'SETTINGS' | 'DESIGN' | 'NOTIFICATIONS';