[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 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<Branding | null>(null)
const [branding, setBranding] = useState<Branding | null>(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

View File

@ -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

View File

@ -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):

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.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_(

View File

@ -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):

View File

@ -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)))

View File

@ -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

View File

@ -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()],

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;
email?: string;
contact_number?: string;
role?: number;
}

View File

@ -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;

View File

@ -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,
},

View File

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

View File

@ -26,6 +26,7 @@ import {
Sun,
Moon,
Building,
Palette,
ChevronRight,
Menu,
X,
@ -41,9 +42,9 @@ interface LayoutProps {
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[] }[] = [
{
@ -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<PageType, string> = {
DASHBOARD: '대시보드',
STATISTICS: '통계',
@ -81,6 +95,7 @@ const pageLabelMap: Record<PageType, string> = {
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 */}
<nav className="mt-1 px-3 overflow-y-auto">
{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;
return (
<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 { Sheet } from '@/components/ui/sheet';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { UserRole } from '@/api/generated/model';
import { USER_ROLE_LABEL } from '@/lib/enumLabels';
import {
UserStatus,
USER_STATUS_LABEL,
@ -27,6 +29,7 @@ const schema = z.object({
email: z.string().trim().email('이메일 형식을 확인해 주십시오.').or(z.literal('')),
contactNumber: z.string().trim(),
status: z.number(),
role: z.number(),
});
type FormValues = z.infer<typeof schema>;
@ -51,9 +54,10 @@ function buildDefaults(mode: 'create' | 'edit', member: Member | null): FormValu
email: member.email ?? '',
contactNumber: normalizePhone(member.contact_number ?? ''),
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';
@ -101,6 +105,7 @@ export function MemberFormSheet({
name: v.name.trim() || undefined,
email: v.email.trim() || undefined,
contact_number: v.contactNumber.trim() || undefined,
role: v.role,
});
showToast('신규 계정이 생성되었습니다.', 'success');
onClose();
@ -174,6 +179,32 @@ export function MemberFormSheet({
<Typography as="label" variant="label"></Typography>
<Input id="form-member-name" type="text" {...register('name')} className={inputClass} placeholder="김직원" />
</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 전용) */}
{mode === 'edit' && (
<div className="space-y-1">

View File

@ -27,6 +27,7 @@ export interface ReqCreateCompanyUser {
name?: string;
email?: string;
contact_number?: string;
role?: number; // 1=일반 2=관리자. 최고관리자(3)는 앱에서 부여 불가
}
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> = {
[UserRole.USER]: '일반',
[UserRole.OWNER]: '최고관리자',
[UserRole.DEVELOPER]: '개발자',
};
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';
export type UserRole = '최고관리자' | '일반';
export type UserRole = '개발자' | '최고관리자' | '일반';
export interface AuthUser {
userId: string;

View File

@ -37,4 +37,4 @@ export interface NegotiationCard {
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';