[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:
parent
6fa024137f
commit
eb6612af1c
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
@ -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]] = {}
|
||||
@ -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
|
||||
|
||||
@ -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 # 협상 멘트(평문)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
18
negodata/front/src/api/generated/model/cardStatus.ts
Normal file
18
negodata/front/src/api/generated/model/cardStatus.ts
Normal 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;
|
||||
18
negodata/front/src/api/generated/model/cardType.ts
Normal file
18
negodata/front/src/api/generated/model/cardType.ts
Normal 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;
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -5,8 +5,4 @@
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export interface EnumOption {
|
||||
value: number;
|
||||
name: string;
|
||||
label: string;
|
||||
}
|
||||
export type ChatMessageDataScript = string | null;
|
||||
@ -5,4 +5,4 @@
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type ResEnumsMsg = string | null;
|
||||
export type ChatMessageDataStep = string | null;
|
||||
18
negodata/front/src/api/generated/model/chatSender.ts
Normal file
18
negodata/front/src/api/generated/model/chatSender.ts
Normal 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;
|
||||
19
negodata/front/src/api/generated/model/deliveryType.ts
Normal file
19
negodata/front/src/api/generated/model/deliveryType.ts
Normal 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;
|
||||
@ -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';
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
20
negodata/front/src/api/generated/model/quotationStatus.ts
Normal file
20
negodata/front/src/api/generated/model/quotationStatus.ts
Normal 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;
|
||||
18
negodata/front/src/api/generated/model/quotationType.ts
Normal file
18
negodata/front/src/api/generated/model/quotationType.ts
Normal 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;
|
||||
@ -15,8 +15,6 @@ export interface ResCardList {
|
||||
page?: number;
|
||||
size?: number;
|
||||
cards?: CardData[];
|
||||
/** 협상카드 탭 카운트(검색 필터 반영) */
|
||||
total_nego?: number;
|
||||
/** 와일드카드 탭 카운트(검색 필터 반영) */
|
||||
total_wild?: number;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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[]};
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
21
negodata/front/src/api/generated/model/sessionStatus.ts
Normal file
21
negodata/front/src/api/generated/model/sessionStatus.ts
Normal 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;
|
||||
18
negodata/front/src/api/generated/model/userRole.ts
Normal file
18
negodata/front/src/api/generated/model/userRole.ts
Normal 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;
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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 ${
|
||||
|
||||
@ -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 || ''} — 상품 상세로 이동`}
|
||||
>
|
||||
|
||||
@ -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() || '-'}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
),
|
||||
},
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 ? '와일드 카드' : '협상 카드',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
16
negodata/front/src/lib/enumLabels.ts
Normal file
16
negodata/front/src/lib/enumLabels.ts
Normal 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]: '관리자',
|
||||
};
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user