[refactor] negodata: 도메인 코드값 enum 정비 — 백엔드 정본 + 프론트 생성타입 사용

- 백엔드: 도메인 enum CodeEnum 상속(x-enum-varnames) → orval이 이름 있는 enum 생성. 응답 필드 enum 타입 지정(요청은 int 유지). ENUM_LABELS/DOMAIN_ENUMS·/v1/enums 제거
- 프론트: 생성 enum + 라벨맵으로 교체(매직넘버·발명 어휘 제거), 견적·세션 상태 코드화, role/delivery 라벨 프론트 소유
- 동반 정리: unwrap 캐스트·목업(buildSessions·데모)·死코드 제거, mapItem toMinPrice dedupe

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Mina Choi 2026-06-22 17:06:26 +09:00
parent 6fa024137f
commit eb6612af1c
47 changed files with 501 additions and 581 deletions

View File

@ -3,6 +3,17 @@ from enum import Enum, auto
from fastapi import HTTPException
class CodeEnum(Enum):
"""OpenAPI 스키마에 x-enum-varnames(멤버 이름)을 실어 orval 이 이름 있는 enum 을 생성하게 하는 베이스."""
@classmethod
def __get_pydantic_json_schema__(cls, core_schema, handler):
json_schema = handler(core_schema)
json_schema = handler.resolve_ref_schema(json_schema)
json_schema["x-enum-varnames"] = [m.name for m in cls]
return json_schema
class ErrorType(Enum):
"""서버 전역 결과 코드. Res_WebPacketProtocol.result 에 담겨 클라이언트로 전달된다.
HTTP status 와 겹치지 않도록 구간을 분리해서 관리한다.
@ -84,35 +95,35 @@ class DBWRType(Enum):
# 도메인 코드값
class UserStatus(Enum):
class UserStatus(CodeEnum):
"""users.status 코드값."""
ACTIVE = 1
INACTIVE = 2
class UserRole(Enum):
class UserRole(CodeEnum):
"""users.role 코드값."""
USER = 1
MANAGER = 2
class CompanyStatus(Enum):
class CompanyStatus(CodeEnum):
"""companies.status 코드값."""
ACTIVE = 1
INACTIVE = 2
class QuotationType(Enum):
class QuotationType(CodeEnum):
"""quotations.type 코드값. 1=renego(재협상 1:1), 2=requote(재견적 1:N)."""
RENEGO = 1
REQUOTE = 2
class QuotationStatus(Enum):
class QuotationStatus(CodeEnum):
"""quotations.status 코드값(SMALLINT). 프론트 견적상태 뱃지와 매핑된다."""
CREATED = 1
@ -121,7 +132,7 @@ class QuotationStatus(Enum):
ON_HOLD = 4
class SessionStatus(Enum):
class SessionStatus(CodeEnum):
"""negotiation.sessions.status 코드값. 협력사별 협상 세션 진행 상태."""
CREATED = 1
@ -131,14 +142,14 @@ class SessionStatus(Enum):
REJECTED = 5
class ChatSender(Enum):
class ChatSender(CodeEnum):
"""negotiation.chats.sender 코드값. 채팅 발신 주체."""
BOT = 1
USER = 2
class DeliveryType(Enum):
class DeliveryType(CodeEnum):
"""items.delivery_type 코드값. 협상 채팅의 배송형태 선택지와 동일 집합."""
PARTNER = 1 # 협력사배송
@ -146,50 +157,15 @@ class DeliveryType(Enum):
PICKUP = 3 # 픽업배송
class CardStatus(Enum):
class CardStatus(CodeEnum):
"""nego_cards.status 코드값. 와일드카드의 협상 적용 여부(수동 승인). 일반 협상카드는 상시 ACTIVE."""
ACTIVE = 1
INACTIVE = 2
# 도메인 enum 한글 라벨. 프론트 드롭다운 표시는 이 라벨을 쓴다(값=코드).
ENUM_LABELS = {
UserStatus.ACTIVE: "활성",
UserStatus.INACTIVE: "비활성",
UserRole.USER: "일반",
UserRole.MANAGER: "관리자",
CompanyStatus.ACTIVE: "활성",
CompanyStatus.INACTIVE: "비활성",
QuotationType.RENEGO: "재협상",
QuotationType.REQUOTE: "재견적",
QuotationStatus.CREATED: "견적생성",
QuotationStatus.ACTIVE: "견적진행중",
QuotationStatus.CLOSED: "견적마감",
QuotationStatus.ON_HOLD: "협상보류",
SessionStatus.CREATED: "협상생성",
SessionStatus.IN_PROGRESS: "협상중",
SessionStatus.DONE: "협상완료",
SessionStatus.NOT_PARTICIPATED: "미참여",
SessionStatus.REJECTED: "협상거부",
ChatSender.BOT: "봇",
ChatSender.USER: "협력사",
DeliveryType.PARTNER: "협력사배송",
DeliveryType.COURIER: "지정택배배송",
DeliveryType.PICKUP: "픽업배송",
CardStatus.ACTIVE: "적용",
CardStatus.INACTIVE: "대기",
}
class CardType(CodeEnum):
"""negotiation.chats.card_type / quotation_cards.type 코드값. 1=nego_card, 2=wild_card."""
# 프론트로 내려주는 도메인 코드 enum 모음. 새 코드 enum 추가 시 여기에 등록한다.
DOMAIN_ENUMS = {
"user_status": UserStatus,
"user_role": UserRole,
"company_status": CompanyStatus,
"quotation_type": QuotationType,
"quotation_status": QuotationStatus,
"session_status": SessionStatus,
"chat_sender": ChatSender,
"delivery_type": DeliveryType,
"card_status": CardStatus,
}
NEGO = 1
WILD = 2

View File

@ -9,22 +9,24 @@ from common.database.db_session_manager import DB_SESSION_MNG
from common.logger import LOG
from common.utils.gtime import GTime
from config.server_configs import web_server_config
from scheduler import shutdown_scheduler, start_scheduler
import router.v1.auth.account
import router.v1.item.item
import router.v1.supplier.supplier
import router.v1.card.card
import router.v1.quotation.quotation
import router.v1.quotation_setting.quotation_setting
import router.v1.enums.enums
API_SERVER_START_TIME = GTime.UTCStr()
@asynccontextmanager
async def lifespan(app: FastAPI):
# startup
# startup: 마감 스케줄러 기동(SCHEDULER_ENABLED=1 인 프로세스에서만)
start_scheduler()
yield
# shutdown: DB 엔진 커넥션 풀 정리
# shutdown: 스케줄러 정지 + DB 엔진 커넥션 풀 정리
shutdown_scheduler()
await DB_SESSION_MNG.dispose_all()
@ -64,4 +66,3 @@ app.include_router(router.v1.supplier.supplier.router)
app.include_router(router.v1.card.card.router)
app.include_router(router.v1.quotation.quotation.router)
app.include_router(router.v1.quotation_setting.quotation_setting.router)
app.include_router(router.v1.enums.enums.router)

View File

@ -52,6 +52,5 @@ class Res_Me(Res_WebPacketProtocol):
name: Optional[str] = None
email: Optional[str] = None
contact_number: Optional[str] = None
role: int = UserRole.USER.value
role_label: str = ""
role: UserRole = UserRole.USER
company: Optional[CompanyData] = Field(default=None)

View File

@ -44,7 +44,7 @@ class CardData(WebPacketProtocol):
number: Optional[str] = None
script: Optional[str] = None
edit_script: Optional[Any] = None
status: int = CardStatus.ACTIVE.value
status: CardStatus = CardStatus.ACTIVE
condition: Optional[str] = None
memo: Optional[str] = None
created_at: Optional[datetime] = None

View File

@ -1,21 +0,0 @@
from fastapi import APIRouter
from common.enums import DOMAIN_ENUMS, ENUM_LABELS
from router.v1.validator.dependencies import RemoveNoneResponse
from .protocol import EnumOption, Res_Enums
# 도메인 코드 enum 메타데이터(공용). 프론트가 페이지 진입 시 드롭다운을 이걸로 채운다.
router = APIRouter(prefix="/v1", tags=["Enums"], responses={404: {"description": "Not found"}})
@router.get(path="/enums", response_model=Res_Enums, summary="도메인 코드 enum 전체")
async def list_enums():
res = Res_Enums()
res.enums = {
key: [
EnumOption(value=member.value, name=member.name, label=ENUM_LABELS.get(member, member.name))
for member in enum_cls
]
for key, enum_cls in DOMAIN_ENUMS.items()
}
return RemoveNoneResponse(res)

View File

@ -1,15 +0,0 @@
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
class EnumsProtocol(WebPacketProtocol):
pass
class EnumOption(WebPacketProtocol):
value: int
name: str
label: str
class Res_Enums(Res_WebPacketProtocol):
enums: dict[str, list[EnumOption]] = {}

View File

@ -4,6 +4,7 @@ from typing import Optional
from pydantic import ConfigDict
from common.enums import DeliveryType
from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol
@ -71,7 +72,7 @@ class ItemData(WebPacketProtocol):
moq: Optional[str] = None
lead_time: Optional[int] = None
quantity_unit: Optional[str] = None
delivery_type: Optional[int] = None
delivery_type: Optional[DeliveryType] = None
vat_yn: Optional[bool] = None
delivery_fee_yn: Optional[bool] = None
created_at: Optional[datetime] = None

View File

@ -4,6 +4,7 @@ from typing import Any, Optional
from pydantic import ConfigDict
from common.enums import CardType, ChatSender, DeliveryType, QuotationStatus, QuotationType, SessionStatus
from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol
@ -39,9 +40,9 @@ class QuotationData(WebPacketProtocol):
version_id: uuid.UUID
name: str
number: str
type: int
type: QuotationType
round: int = 1
status: int
status: QuotationStatus
start_time: datetime
end_time: datetime
manager_name: Optional[str] = None
@ -82,15 +83,15 @@ class SessionData(WebPacketProtocol):
item_id: uuid.UUID
qt_number: str
qt_round: int
qt_type: int
qt_type: QuotationType
target_price: int
status: int
status: SessionStatus
bid_price: Optional[int] = None
bid_at: Optional[datetime] = None
end_time: datetime
reject_reason: Optional[str] = None
reject_price: Optional[int] = None
reject_delivery_type: Optional[int] = None
reject_delivery_type: Optional[DeliveryType] = None
url: str = "" # 세션 chat 실행 URL(공급사 협상 프론트). DB 미저장 — session_id 로 구성
@ -124,11 +125,13 @@ class ChatMessageData(WebPacketProtocol):
session_id: uuid.UUID
card_id: Optional[uuid.UUID] = None
index: int
sender: int
sender: ChatSender
target_price: int
card_used_yn: Optional[bool] = None
indicator_value: Optional[float] = None
card_type: Optional[int] = None
card_type: Optional[CardType] = None
script: Optional[str] = None
step: Optional[str] = None
class Res_SessionChat(Res_WebPacketProtocol):
@ -150,7 +153,7 @@ class QuotationCardData(WebPacketProtocol):
qt_id: Optional[uuid.UUID] = None
nego_card_id: Optional[uuid.UUID] = None
wild_card_id: Optional[uuid.UUID] = None
type: Optional[int] = None
type: Optional[CardType] = None
number: Optional[str] = None
name: Optional[str] = None
script: Optional[str] = None # 협상 멘트(평문)

View File

@ -4,7 +4,7 @@ from fastapi import Depends
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import users
from common.enums import DBWRType, ErrorType, UserStatus, UserRole, ENUM_LABELS
from common.enums import DBWRType, ErrorType, UserStatus, UserRole
from common.logger import LOG
from common.models.gmodel import UserInfo
from crud.user_crud import IUserCRUD, UserCRUD
@ -156,7 +156,6 @@ class AuthService:
res.email = user.email
res.contact_number = user.contact_number
res.role = user.role
res.role_label = ENUM_LABELS.get(UserRole(user.role), str(user.role))
res.company = company
return res

View File

@ -1,124 +0,0 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import {
useQuery
} from '@tanstack/react-query';
import type {
DataTag,
DefinedInitialDataOptions,
DefinedUseQueryResult,
QueryClient,
QueryFunction,
QueryKey,
UndefinedInitialDataOptions,
UseQueryOptions,
UseQueryResult
} from '@tanstack/react-query';
import type {
ResEnums
} from '.././model';
import { customFetch } from '../../mutator/custom-fetch';
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
/**
* @summary 도메인 코드 enum 전체
*/
export const listEnums = (
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
) => {
return customFetch<ResEnums>(
{url: `/v1/enums`, method: 'GET', signal
},
options);
}
export const getListEnumsQueryKey = () => {
return [
`/v1/enums`
] as const;
}
export const getListEnumsQueryOptions = <TData = Awaited<ReturnType<typeof listEnums>>, TError = void>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listEnums>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
) => {
const {query: queryOptions, request: requestOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListEnumsQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof listEnums>>> = ({ signal }) => listEnums(requestOptions, signal);
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listEnums>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
}
export type ListEnumsQueryResult = NonNullable<Awaited<ReturnType<typeof listEnums>>>
export type ListEnumsQueryError = void
export function useListEnums<TData = Awaited<ReturnType<typeof listEnums>>, TError = void>(
options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof listEnums>>, TError, TData>> & Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof listEnums>>,
TError,
Awaited<ReturnType<typeof listEnums>>
> , 'initialData'
>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useListEnums<TData = Awaited<ReturnType<typeof listEnums>>, TError = void>(
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listEnums>>, TError, TData>> & Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof listEnums>>,
TError,
Awaited<ReturnType<typeof listEnums>>
> , 'initialData'
>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useListEnums<TData = Awaited<ReturnType<typeof listEnums>>, TError = void>(
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listEnums>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary 도메인 코드 enum 전체
*/
export function useListEnums<TData = Awaited<ReturnType<typeof listEnums>>, TError = void>(
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listEnums>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getListEnumsQueryOptions(options)
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey ;
return query;
}

View File

@ -9,6 +9,7 @@ import type { CardDataName } from './cardDataName';
import type { CardDataNumber } from './cardDataNumber';
import type { CardDataScript } from './cardDataScript';
import type { CardDataEditScript } from './cardDataEditScript';
import type { CardStatus } from './cardStatus';
import type { CardDataCondition } from './cardDataCondition';
import type { CardDataMemo } from './cardDataMemo';
import type { CardDataCreatedAt } from './cardDataCreatedAt';
@ -22,7 +23,7 @@ export interface CardData {
number?: CardDataNumber;
script?: CardDataScript;
edit_script?: CardDataEditScript;
status?: number;
status?: CardStatus;
condition?: CardDataCondition;
memo?: CardDataMemo;
created_at?: CardDataCreatedAt;

View File

@ -0,0 +1,18 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
/**
* nego_cards.status 코드값. 와일드카드의 협상 적용 여부(수동 승인). 일반 협상카드는 상시 ACTIVE.
*/
export type CardStatus = typeof CardStatus[keyof typeof CardStatus];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const CardStatus = {
ACTIVE: 1,
INACTIVE: 2,
} as const;

View File

@ -0,0 +1,18 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
/**
* negotiation.chats.card_type / quotation_cards.type 코드값. 1=nego_card, 2=wild_card.
*/
export type CardType = typeof CardType[keyof typeof CardType];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const CardType = {
NEGO: 1,
WILD: 2,
} as const;

View File

@ -5,18 +5,23 @@
* OpenAPI spec version: 0.1.0
*/
import type { ChatMessageDataCardId } from './chatMessageDataCardId';
import type { ChatSender } from './chatSender';
import type { ChatMessageDataCardUsedYn } from './chatMessageDataCardUsedYn';
import type { ChatMessageDataIndicatorValue } from './chatMessageDataIndicatorValue';
import type { ChatMessageDataCardType } from './chatMessageDataCardType';
import type { ChatMessageDataScript } from './chatMessageDataScript';
import type { ChatMessageDataStep } from './chatMessageDataStep';
export interface ChatMessageData {
chat_id: string;
session_id: string;
card_id?: ChatMessageDataCardId;
index: number;
sender: number;
sender: ChatSender;
target_price: number;
card_used_yn?: ChatMessageDataCardUsedYn;
indicator_value?: ChatMessageDataIndicatorValue;
card_type?: ChatMessageDataCardType;
script?: ChatMessageDataScript;
step?: ChatMessageDataStep;
}

View File

@ -4,5 +4,6 @@
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { CardType } from './cardType';
export type ChatMessageDataCardType = number | null;
export type ChatMessageDataCardType = CardType | null;

View File

@ -5,8 +5,4 @@
* OpenAPI spec version: 0.1.0
*/
export interface EnumOption {
value: number;
name: string;
label: string;
}
export type ChatMessageDataScript = string | null;

View File

@ -5,4 +5,4 @@
* OpenAPI spec version: 0.1.0
*/
export type ResEnumsMsg = string | null;
export type ChatMessageDataStep = string | null;

View File

@ -0,0 +1,18 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
/**
* negotiation.chats.sender 코드값. 채팅 발신 주체.
*/
export type ChatSender = typeof ChatSender[keyof typeof ChatSender];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const ChatSender = {
BOT: 1,
USER: 2,
} as const;

View File

@ -0,0 +1,19 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
/**
* items.delivery_type 코드값. 협상 채팅의 배송형태 선택지와 동일 집합.
*/
export type DeliveryType = typeof DeliveryType[keyof typeof DeliveryType];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const DeliveryType = {
PARTNER: 1,
COURIER: 2,
PICKUP: 3,
} as const;

View File

@ -17,13 +17,18 @@ export * from './cardDataNumber';
export * from './cardDataScript';
export * from './cardDataUpdatedAt';
export * from './cardDataUserId';
export * from './cardStatus';
export * from './cardType';
export * from './chatMessageData';
export * from './chatMessageDataCardId';
export * from './chatMessageDataCardType';
export * from './chatMessageDataCardUsedYn';
export * from './chatMessageDataIndicatorValue';
export * from './chatMessageDataScript';
export * from './chatMessageDataStep';
export * from './chatSender';
export * from './companyData';
export * from './enumOption';
export * from './deliveryType';
export * from './errorInfo';
export * from './errorInfoCode';
export * from './errorInfoDesc';
@ -80,6 +85,8 @@ export * from './quotationSettingData';
export * from './quotationSettingDataCreatedAt';
export * from './quotationSettingDataUpdatedAt';
export * from './quotationSettingDataUserId';
export * from './quotationStatus';
export * from './quotationType';
export * from './reqCheckCodes';
export * from './reqCreateAccount';
export * from './reqCreateCard';
@ -179,9 +186,6 @@ export * from './resDeleteQuotationSetting';
export * from './resDeleteQuotationSettingMsg';
export * from './resDeleteSupplier';
export * from './resDeleteSupplierMsg';
export * from './resEnums';
export * from './resEnumsEnums';
export * from './resEnumsMsg';
export * from './resItem';
export * from './resItemCategories';
export * from './resItemCategoriesMsg';
@ -248,6 +252,7 @@ export * from './sessionDataBidPrice';
export * from './sessionDataRejectDeliveryType';
export * from './sessionDataRejectPrice';
export * from './sessionDataRejectReason';
export * from './sessionStatus';
export * from './supplierData';
export * from './supplierDataCode';
export * from './supplierDataCreatedAt';
@ -256,6 +261,7 @@ export * from './supplierDataManagerEmail';
export * from './supplierDataManagerName';
export * from './supplierDataPriority';
export * from './supplierDataUpdatedAt';
export * from './userRole';
export * from './validationError';
export * from './validationErrorCtx';
export * from './validationErrorLocItem';

View File

@ -4,5 +4,6 @@
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { DeliveryType } from './deliveryType';
export type ItemDataDeliveryType = number | null;
export type ItemDataDeliveryType = DeliveryType | null;

View File

@ -4,5 +4,6 @@
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { CardType } from './cardType';
export type QuotationCardDataType = number | null;
export type QuotationCardDataType = CardType | null;

View File

@ -4,6 +4,8 @@
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { QuotationType } from './quotationType';
import type { QuotationStatus } from './quotationStatus';
import type { QuotationDataManagerName } from './quotationDataManagerName';
import type { QuotationDataManagerEmail } from './quotationDataManagerEmail';
import type { QuotationDataManagerContactNumber } from './quotationDataManagerContactNumber';
@ -25,9 +27,9 @@ export interface QuotationData {
version_id: string;
name: string;
number: string;
type: number;
type: QuotationType;
round?: number;
status: number;
status: QuotationStatus;
start_time: string;
end_time: string;
manager_name?: QuotationDataManagerName;

View File

@ -0,0 +1,20 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
/**
* quotations.status 코드값(SMALLINT). 프론트 견적상태 뱃지와 매핑된다.
*/
export type QuotationStatus = typeof QuotationStatus[keyof typeof QuotationStatus];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const QuotationStatus = {
CREATED: 1,
ACTIVE: 2,
CLOSED: 3,
ON_HOLD: 4,
} as const;

View File

@ -0,0 +1,18 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
/**
* quotations.type 코드값. 1=renego(재협상 1:1), 2=requote(재견적 1:N).
*/
export type QuotationType = typeof QuotationType[keyof typeof QuotationType];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const QuotationType = {
RENEGO: 1,
REQUOTE: 2,
} as const;

View File

@ -15,8 +15,6 @@ export interface ResCardList {
page?: number;
size?: number;
cards?: CardData[];
/** 협상카드 탭 카운트(검색 필터 반영) */
total_nego?: number;
/** 와일드카드 탭 카운트(검색 필터 반영) */
total_wild?: number;
}

View File

@ -1,15 +0,0 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { ErrorInfo } from './errorInfo';
import type { ResEnumsMsg } from './resEnumsMsg';
import type { ResEnumsEnums } from './resEnumsEnums';
export interface ResEnums {
result?: ErrorInfo;
msg?: ResEnumsMsg;
enums?: ResEnumsEnums;
}

View File

@ -1,9 +0,0 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { EnumOption } from './enumOption';
export type ResEnumsEnums = {[key: string]: EnumOption[]};

View File

@ -9,6 +9,7 @@ import type { ResMeMsg } from './resMeMsg';
import type { ResMeName } from './resMeName';
import type { ResMeEmail } from './resMeEmail';
import type { ResMeContactNumber } from './resMeContactNumber';
import type { UserRole } from './userRole';
import type { ResMeCompany } from './resMeCompany';
export interface ResMe {
@ -19,7 +20,6 @@ export interface ResMe {
name?: ResMeName;
email?: ResMeEmail;
contact_number?: ResMeContactNumber;
role?: number;
role_label?: string;
role?: UserRole;
company?: ResMeCompany;
}

View File

@ -4,6 +4,8 @@
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { QuotationType } from './quotationType';
import type { SessionStatus } from './sessionStatus';
import type { SessionDataBidPrice } from './sessionDataBidPrice';
import type { SessionDataBidAt } from './sessionDataBidAt';
import type { SessionDataRejectReason } from './sessionDataRejectReason';
@ -17,9 +19,9 @@ export interface SessionData {
item_id: string;
qt_number: string;
qt_round: number;
qt_type: number;
qt_type: QuotationType;
target_price: number;
status: number;
status: SessionStatus;
bid_price?: SessionDataBidPrice;
bid_at?: SessionDataBidAt;
end_time: string;

View File

@ -4,5 +4,6 @@
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { DeliveryType } from './deliveryType';
export type SessionDataRejectDeliveryType = number | null;
export type SessionDataRejectDeliveryType = DeliveryType | null;

View File

@ -0,0 +1,21 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
/**
* negotiation.sessions.status 코드값. 협력사별 협상 세션 진행 상태.
*/
export type SessionStatus = typeof SessionStatus[keyof typeof SessionStatus];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const SessionStatus = {
CREATED: 1,
IN_PROGRESS: 2,
DONE: 3,
NOT_PARTICIPATED: 4,
REJECTED: 5,
} as const;

View File

@ -0,0 +1,18 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
/**
* users.role 코드값.
*/
export type UserRole = typeof UserRole[keyof typeof UserRole];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const UserRole = {
USER: 1,
MANAGER: 2,
} as const;

View File

@ -7,6 +7,8 @@ import {
import type {ResMe} from '../../api/generated/model/resMe';
import type {ErrorInfo} from '../../api/generated/model/errorInfo';
import {useAuthStore, type AuthUser, type UserRole} from '../../stores/auth';
import {UserRole as UserRoleCode} from '../../api/generated/model';
import {USER_ROLE_LABEL} from '../../lib/enumLabels';
const ACCESS_KEY = 'negodata.accessToken';
const REFRESH_KEY = 'negodata.refreshToken';
@ -26,7 +28,7 @@ function toAuthUser(me: ResMe): AuthUser {
loginId: me.id ?? '',
email: me.email ?? '',
contact: me.contact_number ?? '',
role: (me.role_label as UserRole) || '일반',
role: (USER_ROLE_LABEL[me.role ?? UserRoleCode.USER] ?? '일반') as UserRole,
};
}

View File

@ -1,18 +1,16 @@
import type { NegotiationCard } from '@/types';
import type { CardData } from '@/api/generated/model/cardData';
import { CardStatus } from '@/api/generated/model';
export type { NegotiationCard };
// 카드 목록 탭. 'ALL' 전체 / 'CARD' 일반 협상카드 / 'WILD' 와일드카드.
export type CardTab = 'ALL' | 'CARD' | 'WILD';
// 카드 status 코드(서버 CardStatus enum) ↔ UI 문자열. ACTIVE=1 / INACTIVE=2.
export const CARD_STATUS_ACTIVE = 1;
export const CARD_STATUS_INACTIVE = 2;
export const toCardStatusCode = (s: 'ACTIVE' | 'INACTIVE') =>
s === 'ACTIVE' ? CARD_STATUS_ACTIVE : CARD_STATUS_INACTIVE;
s === 'ACTIVE' ? CardStatus.ACTIVE : CardStatus.INACTIVE;
export const toCardStatusLabel = (code?: number): 'ACTIVE' | 'INACTIVE' =>
code === CARD_STATUS_INACTIVE ? 'INACTIVE' : 'ACTIVE';
code === CardStatus.INACTIVE ? 'INACTIVE' : 'ACTIVE';
// 서버 CardData(nego_cards/wild_cards 통합) → UI NegotiationCard.
export function mapCardData(c: CardData): NegotiationCard {

View File

@ -3,7 +3,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import type { ReqCreateItem as ItemCreate } from '@/api/generated/model/reqCreateItem';
import type { ReqUpdateItem as ItemUpdate } from '@/api/generated/model/reqUpdateItem';
import { useListEnums } from '@/api/generated/enums/enums';
import { DELIVERY_TYPE_OPTIONS } from '@/lib/enumLabels';
import { uploadItemImage } from '@/api/generated/item/item';
import { showToast } from '@/lib/notify';
import ImageDropzone from '@/components/ImageDropzone';
@ -121,9 +121,7 @@ export function ProductFormSheet({
defaultValues: buildDefaults(mode, product),
});
// 배송 형태 코드(delivery_type) 선택지는 서버 enum 에서 가져온다.
const { data: enumsData } = useListEnums();
const deliveryTypes = enumsData?.enums?.delivery_type ?? [];
const deliveryTypes = DELIVERY_TYPE_OPTIONS;
// minPrice는 화면 전용(서버 미전송). 검증된 값만 payload로.
const onValid = async (v: FormValues) => {

View File

@ -6,6 +6,8 @@ import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import type { Product, Partner, QuotationSetting, NegotiationCard } from '../types';
import type { CreateQuotationInput } from '../hooks/useQuotations';
import { QuotationType } from '@/api/generated/model';
import { QUOTATION_TYPE_OPTIONS } from '../types';
type QuotationCreateModalProps = {
open: boolean;
@ -28,19 +30,22 @@ export function QuotationCreateModal({
}: QuotationCreateModalProps) {
const [step, setStep] = useState(1);
const [title, setTitle] = useState('');
const [type, setType] = useState<'RE_NEGOTIATION' | 'RE_ESTIMATE'>('RE_NEGOTIATION');
const [type, setType] = useState<number>(QuotationType.REQUOTE);
const [productId, setProductId] = useState('');
const [selectedPartnerIds, setSelectedPartnerIds] = useState<string[]>([]);
const [dueDate, setDueDate] = useState('2026-06-15T18:00');
const [settingId, setSettingId] = useState(quotationSettings[0]?.qt_setting_id ?? '');
const [selectedCardIds, setSelectedCardIds] = useState<string[]>([]);
const [submitting, setSubmitting] = useState(false);
const typeOptions = QUOTATION_TYPE_OPTIONS;
if (!open) return null;
const togglePartner = (id: string) =>
setSelectedPartnerIds((prev) =>
prev.includes(id) ? prev.filter((p) => p !== id) : [...prev, id],
type === QuotationType.RENEGO
? prev.includes(id) ? [] : [id]
: prev.includes(id) ? prev.filter((p) => p !== id) : [...prev, id],
);
const toggleCard = (id: string) =>
setSelectedCardIds((prev) =>
@ -73,8 +78,8 @@ export function QuotationCreateModal({
<div className="fixed inset-0 z-[60] flex items-center justify-center">
<div className="flex flex-col items-center gap-3 rounded-xl bg-card px-8 py-6 shadow-2xl border border-border">
<Loader2 className="text-primary animate-spin" size={44} strokeWidth={2.5} />
<span className="text-sm font-semibold text-foreground font-mono">협상견적 생성 중…</span>
<span className="text-[11px] text-muted-foreground font-mono">견적 · 협상 세션 등록 중</span>
<Typography variant="small" className="font-semibold font-mono">협상견적 생성 중…</Typography>
<Typography variant="small" className="text-muted-foreground font-mono">견적 · 협상 세션 등록 중</Typography>
</div>
</div>
)}
@ -84,7 +89,7 @@ export function QuotationCreateModal({
<div className="flex items-center justify-between pb-4 border-b border-border">
<div className="flex items-center gap-2">
<PlusSquare className="text-foreground" size={18} />
<span className="text-sm font-bold text-foreground">신규 협상견적 등록 (단계 {step}/3)</span>
<Typography variant="small" className="font-bold">신규 협상견적 등록 (단계 {step}/3)</Typography>
</div>
<button onClick={onClose} className="p-1 rounded text-muted-foreground hover:bg-muted cursor-pointer">
<X size={18} />
@ -93,11 +98,11 @@ export function QuotationCreateModal({
{/* Steps indicator */}
<div className="flex items-center justify-between gap-2 py-4 border-b border-border/40 text-[10px] text-muted-foreground">
<span className={`font-semibold ${step >= 1 ? 'text-primary' : ''}`}>1. 기본 등록</span>
<Typography as="span" variant="label" className={`font-semibold ${step >= 1 ? 'text-primary' : 'text-muted-foreground'}`}>1. 기본 등록</Typography>
<ArrowRight size={10} />
<span className={`font-semibold ${step >= 2 ? 'text-primary' : ''}`}>2. 협력사 선택</span>
<Typography as="span" variant="label" className={`font-semibold ${step >= 2 ? 'text-primary' : 'text-muted-foreground'}`}>2. 협력사 선택</Typography>
<ArrowRight size={10} />
<span className={`font-semibold ${step >= 3 ? 'text-primary' : ''}`}>3. 설정 및 완료</span>
<Typography as="span" variant="label" className={`font-semibold ${step >= 3 ? 'text-primary' : 'text-muted-foreground'}`}>3. 설정 및 완료</Typography>
</div>
{/* Step content */}
@ -121,17 +126,22 @@ export function QuotationCreateModal({
<div className="space-y-1">
<Typography as="label" variant="label">유형</Typography>
<Select
value={type}
onValueChange={(v) => setType(v as 'RE_NEGOTIATION' | 'RE_ESTIMATE')}
value={String(type)}
onValueChange={(v) => {
const next = Number(v);
setType(next);
if (next === QuotationType.RENEGO) setSelectedPartnerIds((prev) => prev.slice(0, 1));
}}
>
<SelectTrigger id="wizard-type" className="w-full">
<SelectValue>
{(value) => (value === 'RE_ESTIMATE' ? '재견적' : '재협상')}
{(value) => typeOptions.find((o) => String(o.value) === value)?.label ?? ''}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="RE_NEGOTIATION">재협상</SelectItem>
<SelectItem value="RE_ESTIMATE">재견적</SelectItem>
{typeOptions.map((o) => (
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
@ -175,7 +185,7 @@ export function QuotationCreateModal({
{step === 2 && (
<div className="space-y-3">
<span className="font-semibold text-foreground block">협력사 초청 (다중선택)</span>
<Typography as="span" variant="small" className="font-semibold block">협력사 초청 ({type === QuotationType.RENEGO ? '단일선택' : '다중선택'})</Typography>
<div className="border border-border rounded overflow-hidden max-h-56 overflow-y-auto divide-y divide-border bg-background">
{partners.map((part) => {
const isChecked = selectedPartnerIds.includes(part.id ?? '');
@ -192,8 +202,8 @@ export function QuotationCreateModal({
className="accent-primary h-4 w-4"
/>
<div>
<span className="font-semibold text-foreground block">{part.name}</span>
<span className="text-[10px] text-muted-foreground">이메일: {part.managerEmail} · 등급: {part.rank}</span>
<Typography as="span" variant="small" className="font-semibold block">{part.name}</Typography>
<Typography as="span" variant="small" className="text-muted-foreground">이메일: {part.managerEmail} · 등급: {part.rank}</Typography>
</div>
</div>
<span
@ -236,7 +246,7 @@ export function QuotationCreateModal({
</div>
<div className="space-y-2">
<span className="font-semibold text-foreground block">협상카드 및 와일드카드 선택</span>
<Typography as="span" variant="small" className="font-semibold block">협상카드 및 와일드카드 선택</Typography>
<div className="grid grid-cols-2 gap-2 max-h-48 overflow-y-auto">
{cards.filter((c) => !c.isWildcard || c.status === 'ACTIVE').map((card) => {
const isChecked = selectedCardIds.includes(card.id);
@ -251,7 +261,7 @@ export function QuotationCreateModal({
<input type="checkbox" checked={isChecked} readOnly className="accent-primary h-3.5 w-3.5 mt-0.5" />
<div>
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground font-mono block leading-none">{card.code}</span>
<Typography as="span" variant="small" className="text-muted-foreground font-mono block leading-none">{card.code}</Typography>
<span
className={`text-[9px] font-mono px-1.5 py-0.5 rounded leading-none ${
card.isWildcard ? 'bg-amber-50 text-amber-700' : 'bg-zinc-100 text-zinc-600'
@ -260,7 +270,7 @@ export function QuotationCreateModal({
{card.isWildcard ? '와일드' : '협상'}
</span>
</div>
<span className="text-xs text-foreground mt-1 block leading-tight">{card.title}</span>
<Typography as="span" variant="small" className="mt-1 block leading-tight">{card.title}</Typography>
</div>
</div>
);

View File

@ -2,9 +2,10 @@ import { Sparkles } from 'lucide-react';
import type { SessionData } from '@/api/generated/model/sessionData';
import type { QuotationCardData } from '@/api/generated/model/quotationCardData';
import type { ChatMessageData } from '@/api/generated/model/chatMessageData';
import { ChatSender, CardType } from '@/api/generated/model';
import { Input } from '@/components/ui/input';
import SlateRenderer from '@/components/SlateRenderer';
import { StatusPill, chatStatusTone } from './StatusPill';
import { StatusPill, sessionStatusTone } from './StatusPill';
import { type Product, type Partner, sessionStatusLabel } from '../../types';
export function ChatTab({
@ -26,6 +27,9 @@ export function ChatTab({
currentProduct: Product | undefined;
serverCards: QuotationCardData[];
}) {
// 목표가는 협상(세션) 단위 고정값(sessions.target_price)이라 메시지마다가 아니라 헤더에 한 번만 표시한다.
const currentSession = serverSessions.find((s) => s.session_id === effectiveSessionId);
const targetPrice = currentSession?.target_price;
return (
<div className="h-[500px] border border-border rounded-lg overflow-hidden bg-card flex">
{/* Sessions list */}
@ -53,7 +57,7 @@ export function ChatTab({
>
<div className="flex items-center justify-between">
<span className="font-bold text-foreground text-xs">{name}</span>
<StatusPill tone={chatStatusTone(statusLabel)} className="text-[9px] px-1.5 rounded">
<StatusPill tone={sessionStatusTone(sd.status)} className="text-[9px] px-1.5 rounded">
{statusLabel}
</StatusPill>
</div>
@ -75,8 +79,15 @@ export function ChatTab({
<div className="text-muted-foreground">
협력사: <strong className="text-foreground">{currentSupplierName}</strong>
</div>
<div className="text-xs text-muted-foreground">
기록: <span className="font-semibold text-foreground">{chatMessages.length}</span> 메시지
<div className="flex items-center gap-4 text-xs text-muted-foreground">
{targetPrice != null && (
<span>
목표가: <strong className="text-foreground">₩{Number(targetPrice).toLocaleString()}</strong>
</span>
)}
<span>
기록: <span className="font-semibold text-foreground">{chatMessages.length}</span> 메시지
</span>
</div>
</div>
@ -91,13 +102,13 @@ export function ChatTab({
</div>
) : (
chatMessages.map((m) => {
const isBot = m.sender === 1;
const isBot = m.sender === ChatSender.BOT;
// 메시지가 쓴 협상카드 전체(이름만이 아니라 멘트/조건/메모까지) 를 chat_id 로 매칭.
const usedCard = m.card_used_yn
? serverCards.find((c) => c.session_card_id === m.chat_id)
: undefined;
const cardNodes = Array.isArray(usedCard?.edit_script) ? (usedCard.edit_script as unknown[]) : null;
const isWildCard = usedCard?.type === 2;
const isWildCard = usedCard?.type === CardType.WILD;
return (
<div key={m.chat_id} className={`flex ${isBot ? 'justify-start' : 'justify-end'}`}>
<div className="space-y-1 max-w-[85%]">
@ -118,7 +129,21 @@ export function ChatTab({
: 'bg-primary border-transparent text-primary-foreground'
}`}
>
<div className="font-bold">제시 단가 ₩{Number(m.target_price).toLocaleString()}</div>
{/* 진행 단계(chats.meta.step). 주로 봇 턴에만 존재. */}
{m.step && (
<div className="text-[10px] font-mono uppercase tracking-wide opacity-60 mb-1">
{m.step}
</div>
)}
{/* 말풍선 멘트(chats.meta.script). 봇=협상 스크립트, 협력사=입력값. */}
{m.script && (
<p className="whitespace-pre-line leading-relaxed mb-1.5">{m.script}</p>
)}
{/* 제시가: 협력사(user)가 실제로 제시한 가격만 표시. 목표가는 헤더 고정.
가격 제시 턴이 아니면(target_price=0) 숨긴다(₩0 오표시 방지). */}
{!isBot && m.target_price > 0 && (
<div className="font-bold">제시가 ₩{Number(m.target_price).toLocaleString()}</div>
)}
{usedCard && (
<div
className={`mt-2 rounded border p-2 ${

View File

@ -9,9 +9,8 @@ import {
type Product,
type Partner,
type QuotationSetting,
normalizeQuotationStatus,
normalizeQuotationType,
buildBidSummary,
quotationTypeLabel,
} from '../../types';
const fmtYn = (b: boolean | null | undefined, yes: string, no: string) =>
@ -46,14 +45,11 @@ export function DrawerHeaderCards({
// Quotations DDL 표시값
const q_name = quotation.name || '미지정';
const q_number = quotation.number || 'EST-000000-0000';
const q_type = normalizeQuotationType(quotation.type);
const q_round = quotation.round || 1;
const q_status = String(quotation.status ?? '견적생성');
const q_end_time = quotation.end_time || '미지정';
const q_manager_name = quotation.manager_name || '홍길동 파트너';
const q_manager_email = quotation.manager_email || 'gildong@negodata.com';
const q_memo = quotation.memo || '안내사항 없음';
const statusKey = normalizeQuotationStatus(quotation.status);
const bidSummaryObj = buildBidSummary(quotation, partners);
const selectedSettingObj = quotationSettings.find((qs) => qs.qt_setting_id === quotation.qt_setting_id);
@ -83,10 +79,10 @@ export function DrawerHeaderCards({
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5 font-mono text-muted-foreground">
<InfoField label="견적명" value={q_name} valueClassName="font-sans" />
<InfoField label="견적번호" value={q_number} />
<InfoField label="유형" value={q_type === 'RE_NEGOTIATION' ? '재협상' : '재견적'} />
<InfoField label="유형" value={quotationTypeLabel(quotation.type)} />
<InfoField label="차수" value={`${q_round}차`} />
<InfoField label="견적상태" labelClassName="opacity-90 font-bold mb-1">
<QuotationStatusBadge statusKey={statusKey} fallbackLabel={q_status} />
<QuotationStatusBadge status={quotation.status} />
</InfoField>
<InfoField label="마감시각" value={q_end_time} />
<InfoField label="담당자" value={`${q_manager_name} (${q_manager_email})`} valueClassName="font-sans" />
@ -144,7 +140,7 @@ export function DrawerHeaderCards({
</div>
<div className="flex-1 min-w-0">
<Link
to={`/products?edit=${currentProduct.id}`}
to={`/products?detail=${currentProduct.id}`}
className="text-foreground font-bold font-sans text-[12px] mb-2 truncate block hover:text-primary hover:underline"
title={`${currentProduct.name || ''} — 상품 상세로 이동`}
>

View File

@ -2,7 +2,7 @@ import { MessageSquare, ExternalLink, Copy } from 'lucide-react';
import { showToast } from '@/lib/notify';
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
import { StatusPill, sessionStatusTone } from './StatusPill';
import { mapServerSessionView } from '../../types';
import { mapServerSessionView, sessionStatusLabel } from '../../types';
type SessionView = ReturnType<typeof mapServerSessionView>;
@ -85,7 +85,7 @@ export function SessionsStatusTab({
</TableCell>
<TableCell className="p-3 font-semibold font-sans">{sess.item_name}</TableCell>
<TableCell className="p-3 text-center">
<StatusPill tone={sessionStatusTone(sess.status)}>{sess.status}</StatusPill>
<StatusPill tone={sessionStatusTone(sess.status)}>{sessionStatusLabel(sess.status)}</StatusPill>
</TableCell>
<TableCell className="p-3 text-right font-bold text-muted-foreground">
₩{sess.target_price?.toLocaleString() || '-'}

View File

@ -1,5 +1,7 @@
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { QuotationStatus, SessionStatus } from '@/api/generated/model';
import { quotationStatusLabel } from '../../types';
/* ── 작은 상태 pill (세션 상태 / 카드 타입 / 채팅 목록 상태) ──
기존엔 곳마다 색맵을 손으로 박았고 dark 알파(/20·/30)와 red·rose 가 미묘하게
@ -36,36 +38,28 @@ export function StatusPill({
);
}
/** 협상 세션 상태(현황 테이블) → pill 색 */
export function sessionStatusTone(status: string | null | undefined): PillTone {
if (status === '협상완료' || status === 'COMPLETED') return 'blue';
if (status === '협상거부' || status === 'REJECTED') return 'rose';
return 'emerald';
}
/** 채팅 목록의 협상 상태 라벨 → pill 색 */
export function chatStatusTone(label: string): PillTone {
if (label === '협상거부') return 'rose';
if (label === '협상완료') return 'blue';
export function sessionStatusTone(status?: number | null): PillTone {
if (status === SessionStatus.DONE) return 'blue';
if (status === SessionStatus.REJECTED) return 'rose';
return 'emerald';
}
/* ── 견적 상태 배지 (헤더, dot + border + 견적생성 시 pulse) ──
작은 pill 들과 모양이 달라(테두리·점·pulse) 별도 컴포넌트로 둔다. */
const QSTATUS_TONE: Record<string, { box: string; dot: string }> = {
견적생성: {
const QSTATUS_TONE: Record<QuotationStatus, { box: string; dot: string }> = {
[QuotationStatus.CREATED]: {
box: 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/40 dark:text-amber-300 dark:border-amber-700/50 animate-pulse',
dot: 'bg-amber-500',
},
견적진행중: {
[QuotationStatus.ACTIVE]: {
box: 'bg-emerald-100 text-emerald-800 border-emerald-300 dark:bg-emerald-950/40 dark:text-emerald-300 dark:border-emerald-700/50',
dot: 'bg-emerald-500',
},
견적마감: {
[QuotationStatus.CLOSED]: {
box: 'bg-blue-100 text-blue-800 border-blue-300 dark:bg-blue-950/40 dark:text-blue-300 dark:border-blue-700/50',
dot: 'bg-blue-500',
},
협상보류: {
[QuotationStatus.ON_HOLD]: {
box: 'bg-rose-100 text-rose-800 border-rose-300 dark:bg-rose-950/40 dark:text-rose-300 dark:border-rose-700/50',
dot: 'bg-rose-500',
},
@ -73,14 +67,8 @@ const QSTATUS_TONE: Record<string, { box: string; dot: string }> = {
const QSTATUS_FALLBACK = { box: 'bg-zinc-100 text-zinc-800 border-zinc-300', dot: 'bg-zinc-500' };
export function QuotationStatusBadge({
statusKey,
fallbackLabel,
}: {
statusKey: string;
fallbackLabel: string;
}) {
const t = QSTATUS_TONE[statusKey] ?? QSTATUS_FALLBACK;
export function QuotationStatusBadge({ status }: { status?: number | null }) {
const t = (status != null && QSTATUS_TONE[status as QuotationStatus]) || QSTATUS_FALLBACK;
return (
<span
className={cn(
@ -89,7 +77,7 @@ export function QuotationStatusBadge({
)}
>
<span className={cn('h-1.5 w-1.5 rounded-full', t.dot)} />
{statusKey || fallbackLabel}
{quotationStatusLabel(status)}
</span>
);
}

View File

@ -1,27 +1,23 @@
import { useState } from 'react';
import { StopCircle, X, UserCheck, MessageSquare, Layers } from 'lucide-react';
import { CheckCircle2, X, UserCheck, MessageSquare, Layers } from 'lucide-react';
import { Typography } from '@/components/ui/typography';
import {
useGetQuotationSessions,
useGetSessionChat,
useGetQuotationCards,
} from '@/api/generated/quotation/quotation';
import { useListItems } from '@/api/generated/item/item';
import { useGetItem } from '@/api/generated/item/item';
import { useListSuppliers } from '@/api/generated/supplier/supplier';
import { useListSettings } from '@/api/generated/quotation-setting/quotation-setting';
import type { ItemData } from '@/api/generated/model/itemData';
import type { SupplierData } from '@/api/generated/model/supplierData';
import type { QuotationSettingData } from '@/api/generated/model/quotationSettingData';
import type { QuotationData } from '@/api/generated/model/quotationData';
import {
unwrap,
mapItem,
mapSupplier,
mapSetting,
normalizeQuotationStatus,
mapServerSessionView,
mapServerCardView,
} from '../../types';
import { QuotationStatus } from '@/api/generated/model';
import { DrawerHeaderCards } from './DrawerHeaderCards';
import { SessionsStatusTab } from './SessionsStatusTab';
import { QuotationCardsTab } from './QuotationCardsTab';
@ -31,26 +27,24 @@ type DrawerTab = 'status' | 'cards' | 'chat';
type QuotationDetailSheetProps = {
quotation: QuotationData;
onStop: (id: string, name: string) => void;
onCloseQuotation: (id: string, name: string) => void;
onClose: () => void;
};
export function QuotationDetailSheet({
quotation,
onStop,
onCloseQuotation,
onClose,
}: QuotationDetailSheetProps) {
const [activeTab, setActiveTab] = useState<DrawerTab>('status');
const [showHeaderCards, setShowHeaderCards] = useState(true);
// 상품·협력사·견적세팅 목록은 sheet 안에서 직접 서버(orval)로 읽는다(부모 props 의존 제거).
const itemsQuery = useListItems({ size: 100 });
// 협력사·견적세팅 목록은 sheet 안에서 직접 서버(orval)로 읽는다(부모 props 의존 제거).
const suppliersQuery = useListSuppliers({ size: 100 });
const settingsQuery = useListSettings();
const products = (unwrap<{ items?: ItemData[] }>(itemsQuery.data)?.items ?? []).map(mapItem);
const partners = (unwrap<{ suppliers?: SupplierData[] }>(suppliersQuery.data)?.suppliers ?? []).map(mapSupplier);
const partners = (suppliersQuery.data?.suppliers ?? []).map(mapSupplier);
const quotationSettings = (
unwrap<{ settings?: QuotationSettingData[] }>(settingsQuery.data)?.settings ?? []
settingsQuery.data?.settings ?? []
).map(mapSetting);
const qtId = quotation.qt_id ?? '';
@ -70,16 +64,22 @@ export function QuotationDetailSheet({
const currentSession = serverSessions.find((s) => s.session_id === effectiveSessionId);
const currentSupplierName =
partners.find((p) => p.id === currentSession?.supplier_id)?.name || currentSession?.supplier_id || '-';
// 현재 세션의 상품(이미지·규격 등 상세 + 카드 변수 치환용 상품명).
const currentProduct = products.find((p) => p.id === currentSession?.item_id);
// 견적 1건 = 상품 1개(item_ids:[productId])라 모든 세션이 같은 상품을 공유한다.
// 카탈로그 전체 대신 그 상품 1건만 단건 조회 → 상품 수가 늘어도 무관하고, 상품 id 별로 캐시된다.
const itemId = serverSessions[0]?.item_id ?? '';
const itemQuery = useGetItem(itemId, { query: { enabled: !!itemId } });
const currentItem = itemQuery.data?.item;
// 이미지·규격 등 상세 + 카드 변수 치환용 상품명.
const currentProduct = currentItem ? mapItem(currentItem) : undefined;
const sessionViews = serverSessions.map((sd) => mapServerSessionView(sd, partners, products));
// 세션은 모두 같은 상품을 가리키므로(1견적=1상품) 단건 상품 하나로 item_name 해석이 끝난다.
const productList = currentProduct ? [currentProduct] : [];
const sessionViews = serverSessions.map((sd) => mapServerSessionView(sd, partners, productList));
const quotationCardViews = serverCards.map(mapServerCardView);
// 헤더 상단바·중지 버튼에 필요한 최소 표시값만 (나머지 견적 표시값은 DrawerHeaderCards 내부 계산).
// 헤더 상단바·마감 버튼에 필요한 최소 표시값만 (나머지 견적 표시값은 DrawerHeaderCards 내부 계산).
const q_name = quotation.name || '미지정';
const q_number = quotation.number || 'EST-000000-0000';
const statusKey = normalizeQuotationStatus(quotation.status);
const goToChat = (sessionId: string) => {
setSelectedSessionId(sessionId);
@ -115,15 +115,21 @@ export function QuotationDetailSheet({
>
<span>{showHeaderCards ? '견적 상세 정보 접기 ▲' : '견적 상세 정보 펼치기 ▼'}</span>
</button>
{statusKey === '견적진행중' && (
<button
onClick={() => onStop(quotation.qt_id ?? '', q_name)}
className="flex items-center gap-1 px-3 py-1.5 bg-red-600 hover:bg-rose-700 text-white rounded text-xs font-semibold cursor-pointer transition-colors"
>
<StopCircle size={14} />
<span>중지</span>
</button>
)}
{/* 마감 버튼은 항상 노출하되, 마감 가능한 상태(생성·진행중·보류)가 아니면 비활성화만 한다. */}
{(() => {
const canClose = quotation.status !== QuotationStatus.CLOSED;
return (
<button
onClick={() => onCloseQuotation(quotation.qt_id ?? '', q_name)}
disabled={!canClose}
title={canClose ? undefined : '이미 마감된 견적입니다.'}
className="flex items-center gap-1 px-3 py-1.5 bg-red-600 hover:bg-rose-700 text-white rounded text-xs font-semibold cursor-pointer transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-red-600"
>
<CheckCircle2 size={14} />
<span>견적 마감</span>
</button>
);
})()}
<button
onClick={onClose}
className="p-1.5 rounded-full text-muted-foreground hover:bg-muted cursor-pointer"

View File

@ -1,7 +1,8 @@
import type { ReactNode } from 'react';
import { Clock, Building2 } from 'lucide-react';
import { DataTable } from '@/components/ui/data-table';
import { type Estimate, type Product, normalizeQuotationStatus } from '../types';
import { type Estimate, type Product, quotationStatusLabel, quotationTypeLabel } from '../types';
import { QuotationType, QuotationStatus } from '@/api/generated/model';
type QuotationTableProps = {
data: Estimate[];
@ -10,15 +11,15 @@ type QuotationTableProps = {
footer?: ReactNode;
};
const statusBadgeClass = (status?: string | null) => {
switch (normalizeQuotationStatus(status)) {
case '견적생성':
const statusBadgeClass = (status?: number | null) => {
switch (status) {
case QuotationStatus.CREATED:
return 'bg-yellow-50 text-yellow-700 border-yellow-300 animate-pulse';
case '견적진행중':
case QuotationStatus.ACTIVE:
return 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/25 dark:text-emerald-400 border-emerald-300/40';
case '견적마감':
case QuotationStatus.CLOSED:
return 'bg-blue-50 text-blue-700 border-blue-300';
case '협상보류':
case QuotationStatus.ON_HOLD:
return 'bg-red-50 text-red-700 border-red-300';
default:
return 'bg-zinc-100 text-zinc-600';
@ -63,12 +64,12 @@ export function QuotationTable({ data, products, onOpenDetail, footer }: Quotati
cell: (est) => (
<span
className={`px-2 py-0.5 rounded-full text-[9px] font-bold ${
est.type === 'RE_NEGOTIATION'
est.type === QuotationType.RENEGO
? 'bg-neutral-900 text-white dark:bg-zinc-100 dark:text-black'
: 'bg-zinc-100 text-zinc-900 dark:bg-zinc-800 dark:text-zinc-200'
}`}
>
{est.type === 'RE_NEGOTIATION' ? '재협상' : '재견적'}
{quotationTypeLabel(est.type)}
</span>
),
},
@ -85,7 +86,7 @@ export function QuotationTable({ data, products, onOpenDetail, footer }: Quotati
<span
className={`inline-flex items-center gap-1 px-2.5 py-0.5 text-[10px] font-semibold rounded-full border ${statusBadgeClass(est.status)}`}
>
{normalizeQuotationStatus(est.status) || est.status}
{quotationStatusLabel(est.status)}
</span>
),
},

View File

@ -14,22 +14,20 @@ import {
useListQuotations,
useCreateQuotation,
useStopQuotation,
getGetQuotationQueryKey,
getGetQuotationSessionsQueryKey,
} from '@/api/generated/quotation/quotation';
import type { ListQuotationsParams } from '@/api/generated/model/listQuotationsParams';
import type { ReqCreateQuotation } from '@/api/generated/model/reqCreateQuotation';
import type { ItemData } from '@/api/generated/model/itemData';
import type { SupplierData } from '@/api/generated/model/supplierData';
import type { QuotationSettingData } from '@/api/generated/model/quotationSettingData';
import type { QuotationData } from '@/api/generated/model/quotationData';
import type { CardData } from '@/api/generated/model/cardData';
import { showToast } from '@/lib/notify';
import { confirm } from '@/lib/confirm';
import type { Estimate } from '@/types';
import { unwrap, mapItem, mapSupplier, mapSetting, mapQuotation } from '../types';
import type { Estimate } from '../types';
import { mapItem, mapSupplier, mapSetting, mapQuotation } from '../types';
import { QuotationStatus } from '@/api/generated/model';
export type CreateQuotationInput = {
title: string;
type: 'RE_NEGOTIATION' | 'RE_ESTIMATE';
type: number; // QuotationType 코드 (1=재협상, 2=재견적)
productId: string;
partnerIds: string[];
dueDate: string; // datetime-local 원본값
@ -63,40 +61,44 @@ export function useQuotations(params: ListQuotationsParams) {
const invalidateQuotations = () =>
queryClient.invalidateQueries({ queryKey: ['/v1/quotation/list'] });
const products = (unwrap<{ items?: ItemData[] }>(itemsQuery.data)?.items ?? []).map(mapItem);
const partners = (unwrap<{ suppliers?: SupplierData[] }>(suppliersQuery.data)?.suppliers ?? []).map(mapSupplier);
const products = (itemsQuery.data?.items ?? []).map(mapItem);
const partners = (suppliersQuery.data?.suppliers ?? []).map(mapSupplier);
// 견적 세팅은 서버가 정본 — 목록 쿼리에서 바로 파생하고, 추가/삭제 후 쿼리를 무효화해 재조회한다.
const quotationSettings = (
unwrap<{ settings?: QuotationSettingData[] }>(settingsQuery.data)?.settings ?? []
settingsQuery.data?.settings ?? []
).map(mapSetting);
const [quotations, setQuotations] = useState<Estimate[]>([]);
useEffect(() => {
const qs = unwrap<{ quotations?: QuotationData[] }>(quotationsQuery.data)?.quotations;
const qs = quotationsQuery.data?.quotations;
if (qs) setQuotations(qs.map(mapQuotation));
}, [quotationsQuery.data]);
// 서버 전체 건수(선택 필터 반영) — 페이지네이션용.
const total = unwrap<{ total?: number }>(quotationsQuery.data)?.total ?? 0;
const total = quotationsQuery.data?.total ?? 0;
// 협상카드 카탈로그는 서버(orval)에서 읽어 단계 3/3 카드 선택지로 쓴다.
const cards = (unwrap<{ cards?: CardData[] }>(cardsQuery.data)?.cards ?? []).map(mapCardData);
const cards = (cardsQuery.data?.cards ?? []).map(mapCardData);
// 협상 강제중단 → 서버 stop_quotation 호출(상태 '견적마감'으로 영속). 성공 시 목록 무효화로 서버값 재동기화.
const stopNegotiation = async (id: string, name: string) => {
if (!(await confirm({ title: '협상 강제중단', description: `현재 입찰 중인 [${name}] 단가 협상 절차를 즉시 조기 중단(강제종료)하시겠습니까?`, confirmText: '중단', destructive: true }))) return;
// 견적 마감 → 서버 stop_quotation 호출(상태 '견적마감'으로 영속 + 협상생성 세션은 미참여로 전이).
// 성공 시 목록 무효화로 서버값 재동기화.
const closeQuotation = async (id: string, name: string) => {
if (!(await confirm({ title: '견적 마감', description: `[${name}] 견적을 마감하시겠습니까? 마감하면 진행 중인 협상이 종료되고 되돌릴 수 없습니다.`, confirmText: '마감', destructive: true }))) return;
// 낙관적 갱신 — 서버가 CLOSED 로 바꾸므로 화면도 '견적마감'으로 선반영.
setQuotations((prev) => prev.map((e) => (e.id === id ? { ...e, status: '견적마감' } : e)));
setQuotations((prev) => prev.map((e) => (e.id === id ? { ...e, status: QuotationStatus.CLOSED } : e)));
stopQuotationMutation.mutate(
{ qtId: id },
{
onSuccess: () => {
invalidateQuotations();
showToast(`[${name}] 협상이 중단되어 '견적마감' 처리되었습니다.`, 'info');
// 열려있는 상세 Sheet 도 즉시 동기화(단건 견적 상태 + 세션 상태 재조회).
queryClient.invalidateQueries({ queryKey: getGetQuotationQueryKey(id) });
queryClient.invalidateQueries({ queryKey: getGetQuotationSessionsQueryKey(id) });
showToast(`[${name}] 견적이 마감되었습니다.`, 'info');
},
onError: () => {
invalidateQuotations(); // 실패 시 서버 진짜값으로 롤백
showToast('견적 중단에 실패했습니다. 잠시 후 다시 시도해 주세요.', 'error');
showToast('견적 마감에 실패했습니다. 잠시 후 다시 시도해 주세요.', 'error');
},
},
);
@ -166,7 +168,7 @@ export function useQuotations(params: ListQuotationsParams) {
const payload: ReqCreateQuotation = {
qt_setting_id: input.settingId,
name: input.title,
type: input.type === 'RE_ESTIMATE' ? 2 : 1,
type: input.type,
end_time: new Date(input.dueDate).toISOString(),
item_ids: [input.productId],
supplier_ids: input.partnerIds,
@ -200,7 +202,7 @@ export function useQuotations(params: ListQuotationsParams) {
quotations,
total,
quotationSettings,
stopNegotiation,
closeQuotation,
addSetting,
deleteSetting,
createQuotation,

View File

@ -4,39 +4,48 @@ import type { QuotationSettingData } from '@/api/generated/model/quotationSettin
import type { QuotationData } from '@/api/generated/model/quotationData';
import type { SessionData } from '@/api/generated/model/sessionData';
import type { QuotationCardData } from '@/api/generated/model/quotationCardData';
import type {
Product,
Partner,
QuotationSetting,
Estimate,
ChatSession,
NegotiationCard,
} from '@/types';
import { QuotationType, QuotationStatus, SessionStatus, CardType } from '@/api/generated/model';
import { DELIVERY_TYPE_LABEL } from '@/lib/enumLabels';
import { toMinPrice } from '@/features/products/types';
import type { Product, Partner, NegotiationCard } from '@/types';
export type { Product, Partner, QuotationSetting, Estimate, ChatSession, NegotiationCard } from '@/types';
export type { Product, Partner, NegotiationCard } from '@/types';
export type Estimate = Partial<QuotationData> & {
id?: string;
dueDate?: string;
title?: string;
productId?: string;
productName?: string;
partnerIds?: string[];
participationCount?: number;
winnerPartnerId?: string | null;
finalPrice?: number;
isEqualPrice?: boolean;
usedCardIds?: string[];
settingApplied?: boolean | string;
};
export interface QuotationSetting {
qt_setting_id: string;
user_id: string;
target_margin: string;
anchoring_value: string;
card_use_count: string;
created_at: string;
updated_at: string;
deleted: boolean;
}
// ── 서버 응답 → UI 모델 매퍼 ─────────────────────────────────────────────
// customFetch 가 응답 본문을 그대로 반환하므로 query.data 가 곧 봉투(ResXxxList) — 추가 언랩 불필요.
export function unwrap<T>(env: unknown): T | undefined {
return (env as T | undefined) ?? undefined;
}
export function mapItem(it: ItemData): Product {
return { ...it, id: it.item_id, minPrice: Math.round((it.price || 0) * 0.83), status: 'ACTIVE' } as Product;
return { ...it, id: it.item_id, minPrice: toMinPrice(it.price), status: 'ACTIVE' } as Product;
}
export function mapSupplier(sp: SupplierData): Partner {
return {
supplier_id: sp.supplier_id,
company_id: sp.company_id,
name: sp.name,
code: sp.code ?? null,
manager_name: sp.manager_name ?? null,
manager_email: sp.manager_email ?? null,
priority: sp.priority ?? null,
created_at: sp.created_at ?? undefined,
updated_at: sp.updated_at ?? undefined,
...sp,
id: sp.supplier_id,
managerName: sp.manager_name || '',
managerEmail: sp.manager_email || '',
@ -66,8 +75,8 @@ export function mapQuotation(q: QuotationData): Estimate {
...(q as unknown as Partial<Estimate>),
id: q.qt_id,
title: q.name,
type: normalizeQuotationType(q.type),
status: normalizeQuotationStatus(q.status) || String(q.status ?? ''),
type: q.type,
status: q.status,
settingApplied: q.qt_setting_id, // 드로어 견적세팅 카드가 qt_setting_id 로 매칭
productId: q.item_id ?? undefined, // 서버 목록 조인(세션 대표 상품). products 목록과 id 매칭용
productName: q.item_name ?? undefined, // products 목록에 없을 때 표기 폴백
@ -89,59 +98,32 @@ function formatDueDate(end?: string | null): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
// ── 견적상태 정규화(영문 enum / 한글 DDL 혼용 대응) ──────────────────────
export type QtStatusKey = '견적생성' | '견적진행중' | '견적마감' | '협상보류';
export const QUOTATION_STATUS_FILTERS: QtStatusKey[] = [
'견적생성',
'견적진행중',
'견적마감',
'협상보류',
];
// QuotationStatus 코드(SMALLINT) ↔ 한글 상태키. 영문 enum/한글 DDL/숫자 코드 혼용을 모두 흡수.
export function normalizeQuotationStatus(status?: string | number | null): QtStatusKey | '' {
switch (status) {
case 1:
case 'PROCESSING':
case '견적생성':
return '견적생성';
case 2:
case 'ACTIVE':
case '견적진행중':
return '견적진행중';
case 3:
case 'COMPLETED':
case '견적마감':
return '견적마감';
case 4:
case 'STOPPED':
case '협상보류':
return '협상보류';
default:
return '';
}
}
// QuotationType 코드(1=재협상, 2=재견적) ↔ UI 유형값. 이미 문자열이면 그대로 통과.
export function normalizeQuotationType(type?: string | number | null): 'RE_NEGOTIATION' | 'RE_ESTIMATE' {
if (type === 1 || type === 'RE_NEGOTIATION') return 'RE_NEGOTIATION';
if (type === 2 || type === 'RE_ESTIMATE') return 'RE_ESTIMATE';
return type === '재협상' ? 'RE_NEGOTIATION' : 'RE_ESTIMATE';
}
// UI 필터값 → 서버 코드(SMALLINT). 서버 목록 필터(status/type 쿼리)로 보낼 때 사용.
export const QUOTATION_STATUS_CODE: Record<string, number> = {
견적생성: 1,
견적진행중: 2,
견적마감: 3,
협상보류: 4,
export const QUOTATION_STATUS_LABEL: Record<QuotationStatus, QtStatusKey> = {
[QuotationStatus.CREATED]: '견적생성',
[QuotationStatus.ACTIVE]: '견적진행중',
[QuotationStatus.CLOSED]: '견적마감',
[QuotationStatus.ON_HOLD]: '협상보류',
};
export const QUOTATION_TYPE_CODE: Record<string, number> = {
RE_NEGOTIATION: 1,
RE_ESTIMATE: 2,
export const quotationStatusLabel = (s?: number | null): string =>
s != null ? QUOTATION_STATUS_LABEL[s as QuotationStatus] ?? String(s) : '';
export const QUOTATION_STATUS_OPTIONS = Object.values(QuotationStatus).map((value) => ({
value,
label: QUOTATION_STATUS_LABEL[value],
}));
export const QUOTATION_TYPE_LABEL: Record<QuotationType, string> = {
[QuotationType.RENEGO]: '재협상',
[QuotationType.REQUOTE]: '재견적',
};
export const quotationTypeLabel = (t?: number | null): string =>
t != null ? QUOTATION_TYPE_LABEL[t as QuotationType] ?? String(t) : '';
export const QUOTATION_TYPE_OPTIONS = [QuotationType.REQUOTE, QuotationType.RENEGO].map((value) => ({
value,
label: QUOTATION_TYPE_LABEL[value],
}));
// ── 상세 드로어용 파생 뷰 모델(서버 미연동 영역의 목업 보강 포함) ────────
@ -162,7 +144,7 @@ export type SessionView = {
supplier_name: string;
item_id: string;
item_name: string;
status: string;
status: number;
target_price: number;
bid_price: number | null;
bid_at: string;
@ -175,50 +157,16 @@ export type SessionView = {
export type QuotationCardView = {
session_card_id: string;
card_id: string | null; // 실제 카드 id(협상=nego_card_id, 와일드=wild_card_id) — /cards?edit= 링크용
card_id: string | null; // 실제 카드 id(협상=nego_card_id, 와일드=wild_card_id) — /cards?detail= 링크용
card_name: string;
type: string;
};
// 견적당 1개의 입찰 요약(bid_summary). est-1~3은 데모용 정적 매핑, 그 외는 견적 데이터에서 산출.
export function buildBidSummary(q: QuotationData, partners: Partner[]): BidSummaryView {
if (q.qt_id === 'est-1') {
return {
bid_summary_id: 'bid-summary-111-uuid',
status: '입찰진행중 (ACTIVE)',
qt_iteration: 2,
has_preferred: true,
preferred_sp_id: 'part-1',
preferred_sp_name: '(주)우성테크놀로지',
equal_data: '-',
};
}
if (q.qt_id === 'est-2') {
return {
bid_summary_id: 'bid-summary-222-uuid',
status: '입찰종료 (COMPLETED)',
qt_iteration: 1,
has_preferred: true,
preferred_sp_id: 'part-2',
preferred_sp_name: '대현정밀공업 (주)',
equal_data: JSON.stringify({ 'part-2': 730000, 'part-3': 730000 }),
};
}
if (q.qt_id === 'est-3') {
return {
bid_summary_id: 'bid-summary-333-uuid',
status: '입찰활성화 (ACTIVE)',
qt_iteration: 1,
has_preferred: false,
preferred_sp_id: null,
preferred_sp_name: '-',
equal_data: '-',
};
}
const winnerId = q.preferred_sp_id ?? null;
return {
bid_summary_id: `bid-summary-${q.qt_id}`,
status: normalizeQuotationStatus(q.status) === '견적마감' ? '입찰종료 (COMPLETED)' : '입찰활성화 (ACTIVE)',
status: q.status === QuotationStatus.CLOSED ? '입찰종료 (COMPLETED)' : '입찰활성화 (ACTIVE)',
qt_iteration: q.iteration ?? 1,
has_preferred: !!winnerId,
preferred_sp_id: winnerId,
@ -227,62 +175,20 @@ export function buildBidSummary(q: QuotationData, partners: Partner[]): BidSumma
};
}
// 협력사별 1개의 세션(sessions). 채팅 세션 + 상품/협력사 정보를 합성.
export function buildSessions(
est: Estimate,
sessions: ChatSession[],
partners: Partner[],
products: Product[],
): SessionView[] {
return sessions.map((sess) => {
const supplierObj = partners.find((p) => p.id === sess.id);
const matchedProduct = products.find((p) => p.id === est.productId);
let reject_reason: string | null = null;
let reject_price: number | null = null;
let reject_delivery_type: string | null = null;
if (sess.id === 'part-2' && est.id === 'est-1') {
reject_reason = '셀 공급 마진 미달로 단가 수용 한계 봉착';
reject_price = 1480000;
reject_delivery_type = '특수 보온 수송 차량 필요';
}
return {
session_id: `sess-${est.id}-${sess.id}`,
qt_id: est.id ?? '',
supplier_id: sess.id,
supplier_name: supplierObj?.name || sess.partnerName,
item_id: est.productId || 'prod-1',
item_name: matchedProduct?.name || '부품',
status: sess.status || '협상중',
target_price: Math.round((matchedProduct?.price || 1000000) * 0.9),
bid_price: sess.currentBid || null,
bid_at: sess.bidTime || '2026-06-11 09:00',
reject_reason,
reject_price,
reject_delivery_type,
end_time: est.end_time || est.dueDate || '미지정',
url: '',
};
});
}
// ── 서버 연동 매퍼(negotiation.sessions / chats / 사용 카드) ──────────────
// 세션상태 코드→라벨. 서버 /v1/enums(session_status) · SHARED_ENUMS.md 5-state 와 동일해야 한다.
// (정본은 서버 enum — 여기 값은 그걸 미러링한 것이며 드리프트 시 서버 기준으로 맞춘다.)
export const SESSION_STATUS_LABEL: Record<number, string> = {
1: '협상생성',
2: '협상중',
3: '협상완료',
4: '미참여',
5: '협상거부',
export const SESSION_STATUS_LABEL: Record<SessionStatus, string> = {
[SessionStatus.CREATED]: '협상생성',
[SessionStatus.IN_PROGRESS]: '협상중',
[SessionStatus.DONE]: '협상완료',
[SessionStatus.NOT_PARTICIPATED]: '미참여',
[SessionStatus.REJECTED]: '협상거부',
};
// 코드→라벨 단일 진입점. 미정의 코드는 코드 문자열 그대로.
export const sessionStatusLabel = (code?: number | null): string =>
(code != null ? SESSION_STATUS_LABEL[code] : undefined) ?? String(code ?? '');
const DELIVERY_TYPE_LABEL: Record<number, string> = { 1: '협력사배송', 2: '지정택배배송', 3: '픽업배송' };
(code != null ? SESSION_STATUS_LABEL[code as SessionStatus] : undefined) ?? String(code ?? '');
// ISO 문자열 → 'YYYY-MM-DD HH:mm'. 빈 값/파싱 실패는 '-'.
export function fmtDateTime(s?: string | null): string {
@ -305,7 +211,7 @@ export function mapServerSessionView(sd: SessionData, partners: Partner[], produ
supplier_name: supplier?.name || sd.supplier_id,
item_id: sd.item_id,
item_name: product?.name || '부품',
status: SESSION_STATUS_LABEL[sd.status] || String(sd.status),
status: sd.status,
target_price: sd.target_price ?? 0,
bid_price: sd.bid_price ?? null,
bid_at: sd.bid_at ? fmtDateTime(sd.bid_at) : '-',
@ -325,7 +231,7 @@ export function mapServerCardView(c: QuotationCardData): QuotationCardView {
session_card_id: c.session_card_id,
card_id: c.nego_card_id ?? c.wild_card_id ?? null,
card_name: c.name || '-',
type: c.type === 2 ? '와일드 카드' : '협상 카드',
type: c.type === CardType.WILD ? '와일드 카드' : '협상 카드',
};
}

View File

@ -0,0 +1,16 @@
import { DeliveryType, UserRole } from '@/api/generated/model';
export const DELIVERY_TYPE_LABEL: Record<DeliveryType, string> = {
[DeliveryType.PARTNER]: '협력사배송',
[DeliveryType.COURIER]: '지정택배배송',
[DeliveryType.PICKUP]: '픽업배송',
};
export const DELIVERY_TYPE_OPTIONS = Object.values(DeliveryType).map((value) => ({
value,
label: DELIVERY_TYPE_LABEL[value],
}));
export const USER_ROLE_LABEL: Record<UserRole, string> = {
[UserRole.USER]: '일반',
[UserRole.MANAGER]: '관리자',
};

View File

@ -11,7 +11,7 @@ import { QuotationTable } from '@/features/quotations/components/QuotationTable'
import { QuotationDetailSheet } from '@/features/quotations/components/QuotationDetailSheet';
import { QuotationCreateModal } from '@/features/quotations/components/QuotationCreateModal';
import { QuotationSettingsModal } from '@/features/quotations/components/QuotationSettingsModal';
import { QUOTATION_STATUS_FILTERS, QUOTATION_STATUS_CODE, QUOTATION_TYPE_CODE } from '@/features/quotations/types';
import { QUOTATION_STATUS_OPTIONS, QUOTATION_TYPE_OPTIONS } from '@/features/quotations/types';
import type { ListQuotationsParams } from '@/api/generated/model/listQuotationsParams';
export default function QuotationPage() {
@ -19,10 +19,12 @@ export default function QuotationPage() {
const list = useServerList({ pageSize: 10, initialFilters: { status: 'ALL', type: 'ALL' } });
const statusFilter = list.filters.status;
const typeFilter = list.filters.type;
const statusOptions = QUOTATION_STATUS_OPTIONS;
const typeOptions = QUOTATION_TYPE_OPTIONS;
const params: ListQuotationsParams = {
search: list.debouncedSearch || undefined,
status: statusFilter !== 'ALL' ? String(QUOTATION_STATUS_CODE[statusFilter] ?? '') : undefined,
type: typeFilter !== 'ALL' ? String(QUOTATION_TYPE_CODE[typeFilter] ?? '') : undefined,
status: statusFilter !== 'ALL' ? statusFilter : undefined,
type: typeFilter !== 'ALL' ? typeFilter : undefined,
page: list.page,
size: list.pageSize,
};
@ -34,7 +36,7 @@ export default function QuotationPage() {
quotations,
total,
quotationSettings,
stopNegotiation,
closeQuotation,
addSetting,
deleteSetting,
createQuotation,
@ -67,10 +69,10 @@ export default function QuotationPage() {
<button
id="quotation-create-btn"
onClick={() => overlay.open('create')}
className="flex items-center gap-2 px-4 py-2.5 bg-primary text-primary-foreground text-xs font-bold rounded hover:opacity-95 cursor-pointer transition-colors animate-pulse"
className="flex items-center gap-2 px-4 py-2.5 bg-primary text-primary-foreground text-xs font-bold rounded hover:opacity-95 cursor-pointer transition-colors"
>
<Plus size={15} />
<span>신규 협상견적 등록</span>
<span>신규 견적 등록</span>
</button>
</>
}
@ -86,12 +88,18 @@ export default function QuotationPage() {
<div className="grid grid-cols-2 gap-2">
<Select value={statusFilter} onValueChange={(v) => list.setFilter('status', v as string)}>
<SelectTrigger id="quotation-status-filter" className="font-bold">
<SelectValue />
<SelectValue>
{(value) =>
value === 'ALL'
? '전체 견적상태'
: statusOptions.find((o) => String(o.value) === value)?.label ?? ''
}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">전체 견적상태</SelectItem>
{QUOTATION_STATUS_FILTERS.map((s) => (
<SelectItem key={s} value={s}>{s}</SelectItem>
{statusOptions.map((o) => (
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
@ -99,13 +107,18 @@ export default function QuotationPage() {
<Select value={typeFilter} onValueChange={(v) => list.setFilter('type', v as string)}>
<SelectTrigger id="quotation-type-filter">
<SelectValue>
{(value) => (value === 'ALL' ? '전체 유형' : value === 'RE_NEGOTIATION' ? '재협상' : '재견적')}
{(value) =>
value === 'ALL'
? '전체 유형'
: typeOptions.find((o) => String(o.value) === value)?.label ?? ''
}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">전체 유형</SelectItem>
<SelectItem value="RE_NEGOTIATION">재협상</SelectItem>
<SelectItem value="RE_ESTIMATE">재견적</SelectItem>
{typeOptions.map((o) => (
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
@ -132,7 +145,7 @@ export default function QuotationPage() {
<QuotationDetailSheet
key={activeQuotation.qt_id}
quotation={activeQuotation}
onStop={stopNegotiation}
onCloseQuotation={closeQuotation}
onClose={overlay.close}
/>
)}