[feat] negodata: 권한 3단계(개발자 신설)·개발자 메뉴 그룹·디자인 시스템 페이지
- UserRole 1=일반 2=최고관리자 3=개발자(내부 운영). 개발자 계정은 회원 목록·총계에서 제외해 고객사에 노출하지 않음 - 계정 생성 시 권한 선택(일반/최고관리자) 추가, 개발자는 앱에서 부여 불가(DB 시드 전용) - 사이드바 '개발자' 그룹 신설 — 회사 설정·디자인 시스템을 개발자에게만 노출 - /dev/design 디자인 시스템 페이지: 색 토큰·타이포·버튼·배지·입력·반경, 목록 화면 구조, 반응형 기준, URL 상태 규칙, 회사 커스터마이징 훅 - 공급사 재협상 요청(#15) 플로우 문서 — sessions.custom 기반(DDL 0)
This commit is contained in:
parent
db07a60cfa
commit
cfee6e89d0
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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_(
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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)))
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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()],
|
||||
|
||||
143
negodata/docs/renegotiation-flow.md
Normal file
143
negodata/docs/renegotiation-flow.md
Normal 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건만)
|
||||
@ -11,4 +11,5 @@ export interface ReqCreateCompanyUser {
|
||||
name?: string;
|
||||
email?: string;
|
||||
contact_number?: string;
|
||||
role?: number;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -13,6 +13,7 @@ const PAGE_TO_PATH: Record<PageType, string> = {
|
||||
CARDS: '/cards',
|
||||
MEMBERS: '/members',
|
||||
SETTINGS: '/settings',
|
||||
DESIGN: '/dev/design',
|
||||
NOTIFICATIONS: '/notifications',
|
||||
};
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -27,6 +27,7 @@ export interface ReqCreateCompanyUser {
|
||||
name?: string;
|
||||
email?: string;
|
||||
contact_number?: string;
|
||||
role?: number; // 1=일반 2=관리자. 최고관리자(3)는 앱에서 부여 불가
|
||||
}
|
||||
|
||||
export interface ReqUpdateCompanyUser {
|
||||
|
||||
@ -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> = {
|
||||
|
||||
193
negodata/front/src/pages/dev-design.tsx
Normal file
193
negodata/front/src/pages/dev-design.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import {create} from 'zustand';
|
||||
|
||||
export type UserRole = '최고관리자' | '일반';
|
||||
export type UserRole = '개발자' | '최고관리자' | '일반';
|
||||
|
||||
export interface AuthUser {
|
||||
userId: string;
|
||||
|
||||
@ -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';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user