[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
|
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):
|
class ErrorType(Enum):
|
||||||
"""서버 전역 결과 코드. Res_WebPacketProtocol.result 에 담겨 클라이언트로 전달된다.
|
"""서버 전역 결과 코드. Res_WebPacketProtocol.result 에 담겨 클라이언트로 전달된다.
|
||||||
HTTP status 와 겹치지 않도록 구간을 분리해서 관리한다.
|
HTTP status 와 겹치지 않도록 구간을 분리해서 관리한다.
|
||||||
@ -84,35 +95,35 @@ class DBWRType(Enum):
|
|||||||
|
|
||||||
|
|
||||||
# 도메인 코드값
|
# 도메인 코드값
|
||||||
class UserStatus(Enum):
|
class UserStatus(CodeEnum):
|
||||||
"""users.status 코드값."""
|
"""users.status 코드값."""
|
||||||
|
|
||||||
ACTIVE = 1
|
ACTIVE = 1
|
||||||
INACTIVE = 2
|
INACTIVE = 2
|
||||||
|
|
||||||
|
|
||||||
class UserRole(Enum):
|
class UserRole(CodeEnum):
|
||||||
"""users.role 코드값."""
|
"""users.role 코드값."""
|
||||||
|
|
||||||
USER = 1
|
USER = 1
|
||||||
MANAGER = 2
|
MANAGER = 2
|
||||||
|
|
||||||
|
|
||||||
class CompanyStatus(Enum):
|
class CompanyStatus(CodeEnum):
|
||||||
"""companies.status 코드값."""
|
"""companies.status 코드값."""
|
||||||
|
|
||||||
ACTIVE = 1
|
ACTIVE = 1
|
||||||
INACTIVE = 2
|
INACTIVE = 2
|
||||||
|
|
||||||
|
|
||||||
class QuotationType(Enum):
|
class QuotationType(CodeEnum):
|
||||||
"""quotations.type 코드값. 1=renego(재협상 1:1), 2=requote(재견적 1:N)."""
|
"""quotations.type 코드값. 1=renego(재협상 1:1), 2=requote(재견적 1:N)."""
|
||||||
|
|
||||||
RENEGO = 1
|
RENEGO = 1
|
||||||
REQUOTE = 2
|
REQUOTE = 2
|
||||||
|
|
||||||
|
|
||||||
class QuotationStatus(Enum):
|
class QuotationStatus(CodeEnum):
|
||||||
"""quotations.status 코드값(SMALLINT). 프론트 견적상태 뱃지와 매핑된다."""
|
"""quotations.status 코드값(SMALLINT). 프론트 견적상태 뱃지와 매핑된다."""
|
||||||
|
|
||||||
CREATED = 1
|
CREATED = 1
|
||||||
@ -121,7 +132,7 @@ class QuotationStatus(Enum):
|
|||||||
ON_HOLD = 4
|
ON_HOLD = 4
|
||||||
|
|
||||||
|
|
||||||
class SessionStatus(Enum):
|
class SessionStatus(CodeEnum):
|
||||||
"""negotiation.sessions.status 코드값. 협력사별 협상 세션 진행 상태."""
|
"""negotiation.sessions.status 코드값. 협력사별 협상 세션 진행 상태."""
|
||||||
|
|
||||||
CREATED = 1
|
CREATED = 1
|
||||||
@ -131,14 +142,14 @@ class SessionStatus(Enum):
|
|||||||
REJECTED = 5
|
REJECTED = 5
|
||||||
|
|
||||||
|
|
||||||
class ChatSender(Enum):
|
class ChatSender(CodeEnum):
|
||||||
"""negotiation.chats.sender 코드값. 채팅 발신 주체."""
|
"""negotiation.chats.sender 코드값. 채팅 발신 주체."""
|
||||||
|
|
||||||
BOT = 1
|
BOT = 1
|
||||||
USER = 2
|
USER = 2
|
||||||
|
|
||||||
|
|
||||||
class DeliveryType(Enum):
|
class DeliveryType(CodeEnum):
|
||||||
"""items.delivery_type 코드값. 협상 채팅의 배송형태 선택지와 동일 집합."""
|
"""items.delivery_type 코드값. 협상 채팅의 배송형태 선택지와 동일 집합."""
|
||||||
|
|
||||||
PARTNER = 1 # 협력사배송
|
PARTNER = 1 # 협력사배송
|
||||||
@ -146,50 +157,15 @@ class DeliveryType(Enum):
|
|||||||
PICKUP = 3 # 픽업배송
|
PICKUP = 3 # 픽업배송
|
||||||
|
|
||||||
|
|
||||||
class CardStatus(Enum):
|
class CardStatus(CodeEnum):
|
||||||
"""nego_cards.status 코드값. 와일드카드의 협상 적용 여부(수동 승인). 일반 협상카드는 상시 ACTIVE."""
|
"""nego_cards.status 코드값. 와일드카드의 협상 적용 여부(수동 승인). 일반 협상카드는 상시 ACTIVE."""
|
||||||
|
|
||||||
ACTIVE = 1
|
ACTIVE = 1
|
||||||
INACTIVE = 2
|
INACTIVE = 2
|
||||||
|
|
||||||
|
|
||||||
# 도메인 enum 한글 라벨. 프론트 드롭다운 표시는 이 라벨을 쓴다(값=코드).
|
class CardType(CodeEnum):
|
||||||
ENUM_LABELS = {
|
"""negotiation.chats.card_type / quotation_cards.type 코드값. 1=nego_card, 2=wild_card."""
|
||||||
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: "대기",
|
|
||||||
}
|
|
||||||
|
|
||||||
# 프론트로 내려주는 도메인 코드 enum 모음. 새 코드 enum 추가 시 여기에 등록한다.
|
NEGO = 1
|
||||||
DOMAIN_ENUMS = {
|
WILD = 2
|
||||||
"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,
|
|
||||||
}
|
|
||||||
|
|||||||
@ -9,22 +9,24 @@ from common.database.db_session_manager import DB_SESSION_MNG
|
|||||||
from common.logger import LOG
|
from common.logger import LOG
|
||||||
from common.utils.gtime import GTime
|
from common.utils.gtime import GTime
|
||||||
from config.server_configs import web_server_config
|
from config.server_configs import web_server_config
|
||||||
|
from scheduler import shutdown_scheduler, start_scheduler
|
||||||
import router.v1.auth.account
|
import router.v1.auth.account
|
||||||
import router.v1.item.item
|
import router.v1.item.item
|
||||||
import router.v1.supplier.supplier
|
import router.v1.supplier.supplier
|
||||||
import router.v1.card.card
|
import router.v1.card.card
|
||||||
import router.v1.quotation.quotation
|
import router.v1.quotation.quotation
|
||||||
import router.v1.quotation_setting.quotation_setting
|
import router.v1.quotation_setting.quotation_setting
|
||||||
import router.v1.enums.enums
|
|
||||||
|
|
||||||
API_SERVER_START_TIME = GTime.UTCStr()
|
API_SERVER_START_TIME = GTime.UTCStr()
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
# startup
|
# startup: 마감 스케줄러 기동(SCHEDULER_ENABLED=1 인 프로세스에서만)
|
||||||
|
start_scheduler()
|
||||||
yield
|
yield
|
||||||
# shutdown: DB 엔진 커넥션 풀 정리
|
# shutdown: 스케줄러 정지 + DB 엔진 커넥션 풀 정리
|
||||||
|
shutdown_scheduler()
|
||||||
await DB_SESSION_MNG.dispose_all()
|
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.card.card.router)
|
||||||
app.include_router(router.v1.quotation.quotation.router)
|
app.include_router(router.v1.quotation.quotation.router)
|
||||||
app.include_router(router.v1.quotation_setting.quotation_setting.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
|
name: Optional[str] = None
|
||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
contact_number: Optional[str] = None
|
contact_number: Optional[str] = None
|
||||||
role: int = UserRole.USER.value
|
role: UserRole = UserRole.USER
|
||||||
role_label: str = ""
|
|
||||||
company: Optional[CompanyData] = Field(default=None)
|
company: Optional[CompanyData] = Field(default=None)
|
||||||
|
|||||||
@ -44,7 +44,7 @@ class CardData(WebPacketProtocol):
|
|||||||
number: Optional[str] = None
|
number: Optional[str] = None
|
||||||
script: Optional[str] = None
|
script: Optional[str] = None
|
||||||
edit_script: Optional[Any] = None
|
edit_script: Optional[Any] = None
|
||||||
status: int = CardStatus.ACTIVE.value
|
status: CardStatus = CardStatus.ACTIVE
|
||||||
condition: Optional[str] = None
|
condition: Optional[str] = None
|
||||||
memo: Optional[str] = None
|
memo: Optional[str] = None
|
||||||
created_at: Optional[datetime] = 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 pydantic import ConfigDict
|
||||||
|
|
||||||
|
from common.enums import DeliveryType
|
||||||
from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol
|
from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol
|
||||||
|
|
||||||
|
|
||||||
@ -71,7 +72,7 @@ class ItemData(WebPacketProtocol):
|
|||||||
moq: Optional[str] = None
|
moq: Optional[str] = None
|
||||||
lead_time: Optional[int] = None
|
lead_time: Optional[int] = None
|
||||||
quantity_unit: Optional[str] = None
|
quantity_unit: Optional[str] = None
|
||||||
delivery_type: Optional[int] = None
|
delivery_type: Optional[DeliveryType] = None
|
||||||
vat_yn: Optional[bool] = None
|
vat_yn: Optional[bool] = None
|
||||||
delivery_fee_yn: Optional[bool] = None
|
delivery_fee_yn: Optional[bool] = None
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
|
|||||||
@ -4,6 +4,7 @@ from typing import Any, Optional
|
|||||||
|
|
||||||
from pydantic import ConfigDict
|
from pydantic import ConfigDict
|
||||||
|
|
||||||
|
from common.enums import CardType, ChatSender, DeliveryType, QuotationStatus, QuotationType, SessionStatus
|
||||||
from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol
|
from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol
|
||||||
|
|
||||||
|
|
||||||
@ -39,9 +40,9 @@ class QuotationData(WebPacketProtocol):
|
|||||||
version_id: uuid.UUID
|
version_id: uuid.UUID
|
||||||
name: str
|
name: str
|
||||||
number: str
|
number: str
|
||||||
type: int
|
type: QuotationType
|
||||||
round: int = 1
|
round: int = 1
|
||||||
status: int
|
status: QuotationStatus
|
||||||
start_time: datetime
|
start_time: datetime
|
||||||
end_time: datetime
|
end_time: datetime
|
||||||
manager_name: Optional[str] = None
|
manager_name: Optional[str] = None
|
||||||
@ -82,15 +83,15 @@ class SessionData(WebPacketProtocol):
|
|||||||
item_id: uuid.UUID
|
item_id: uuid.UUID
|
||||||
qt_number: str
|
qt_number: str
|
||||||
qt_round: int
|
qt_round: int
|
||||||
qt_type: int
|
qt_type: QuotationType
|
||||||
target_price: int
|
target_price: int
|
||||||
status: int
|
status: SessionStatus
|
||||||
bid_price: Optional[int] = None
|
bid_price: Optional[int] = None
|
||||||
bid_at: Optional[datetime] = None
|
bid_at: Optional[datetime] = None
|
||||||
end_time: datetime
|
end_time: datetime
|
||||||
reject_reason: Optional[str] = None
|
reject_reason: Optional[str] = None
|
||||||
reject_price: Optional[int] = 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 로 구성
|
url: str = "" # 세션 chat 실행 URL(공급사 협상 프론트). DB 미저장 — session_id 로 구성
|
||||||
|
|
||||||
|
|
||||||
@ -124,11 +125,13 @@ class ChatMessageData(WebPacketProtocol):
|
|||||||
session_id: uuid.UUID
|
session_id: uuid.UUID
|
||||||
card_id: Optional[uuid.UUID] = None
|
card_id: Optional[uuid.UUID] = None
|
||||||
index: int
|
index: int
|
||||||
sender: int
|
sender: ChatSender
|
||||||
target_price: int
|
target_price: int
|
||||||
card_used_yn: Optional[bool] = None
|
card_used_yn: Optional[bool] = None
|
||||||
indicator_value: Optional[float] = 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):
|
class Res_SessionChat(Res_WebPacketProtocol):
|
||||||
@ -150,7 +153,7 @@ class QuotationCardData(WebPacketProtocol):
|
|||||||
qt_id: Optional[uuid.UUID] = None
|
qt_id: Optional[uuid.UUID] = None
|
||||||
nego_card_id: Optional[uuid.UUID] = None
|
nego_card_id: Optional[uuid.UUID] = None
|
||||||
wild_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
|
number: Optional[str] = None
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
script: 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.db_session_manager import DB_SESSION_MNG
|
||||||
from common.database.model.models import users
|
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.logger import LOG
|
||||||
from common.models.gmodel import UserInfo
|
from common.models.gmodel import UserInfo
|
||||||
from crud.user_crud import IUserCRUD, UserCRUD
|
from crud.user_crud import IUserCRUD, UserCRUD
|
||||||
@ -156,7 +156,6 @@ class AuthService:
|
|||||||
res.email = user.email
|
res.email = user.email
|
||||||
res.contact_number = user.contact_number
|
res.contact_number = user.contact_number
|
||||||
res.role = user.role
|
res.role = user.role
|
||||||
res.role_label = ENUM_LABELS.get(UserRole(user.role), str(user.role))
|
|
||||||
res.company = company
|
res.company = company
|
||||||
return res
|
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 { CardDataNumber } from './cardDataNumber';
|
||||||
import type { CardDataScript } from './cardDataScript';
|
import type { CardDataScript } from './cardDataScript';
|
||||||
import type { CardDataEditScript } from './cardDataEditScript';
|
import type { CardDataEditScript } from './cardDataEditScript';
|
||||||
|
import type { CardStatus } from './cardStatus';
|
||||||
import type { CardDataCondition } from './cardDataCondition';
|
import type { CardDataCondition } from './cardDataCondition';
|
||||||
import type { CardDataMemo } from './cardDataMemo';
|
import type { CardDataMemo } from './cardDataMemo';
|
||||||
import type { CardDataCreatedAt } from './cardDataCreatedAt';
|
import type { CardDataCreatedAt } from './cardDataCreatedAt';
|
||||||
@ -22,7 +23,7 @@ export interface CardData {
|
|||||||
number?: CardDataNumber;
|
number?: CardDataNumber;
|
||||||
script?: CardDataScript;
|
script?: CardDataScript;
|
||||||
edit_script?: CardDataEditScript;
|
edit_script?: CardDataEditScript;
|
||||||
status?: number;
|
status?: CardStatus;
|
||||||
condition?: CardDataCondition;
|
condition?: CardDataCondition;
|
||||||
memo?: CardDataMemo;
|
memo?: CardDataMemo;
|
||||||
created_at?: CardDataCreatedAt;
|
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
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
import type { ChatMessageDataCardId } from './chatMessageDataCardId';
|
import type { ChatMessageDataCardId } from './chatMessageDataCardId';
|
||||||
|
import type { ChatSender } from './chatSender';
|
||||||
import type { ChatMessageDataCardUsedYn } from './chatMessageDataCardUsedYn';
|
import type { ChatMessageDataCardUsedYn } from './chatMessageDataCardUsedYn';
|
||||||
import type { ChatMessageDataIndicatorValue } from './chatMessageDataIndicatorValue';
|
import type { ChatMessageDataIndicatorValue } from './chatMessageDataIndicatorValue';
|
||||||
import type { ChatMessageDataCardType } from './chatMessageDataCardType';
|
import type { ChatMessageDataCardType } from './chatMessageDataCardType';
|
||||||
|
import type { ChatMessageDataScript } from './chatMessageDataScript';
|
||||||
|
import type { ChatMessageDataStep } from './chatMessageDataStep';
|
||||||
|
|
||||||
export interface ChatMessageData {
|
export interface ChatMessageData {
|
||||||
chat_id: string;
|
chat_id: string;
|
||||||
session_id: string;
|
session_id: string;
|
||||||
card_id?: ChatMessageDataCardId;
|
card_id?: ChatMessageDataCardId;
|
||||||
index: number;
|
index: number;
|
||||||
sender: number;
|
sender: ChatSender;
|
||||||
target_price: number;
|
target_price: number;
|
||||||
card_used_yn?: ChatMessageDataCardUsedYn;
|
card_used_yn?: ChatMessageDataCardUsedYn;
|
||||||
indicator_value?: ChatMessageDataIndicatorValue;
|
indicator_value?: ChatMessageDataIndicatorValue;
|
||||||
card_type?: ChatMessageDataCardType;
|
card_type?: ChatMessageDataCardType;
|
||||||
|
script?: ChatMessageDataScript;
|
||||||
|
step?: ChatMessageDataStep;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,5 +4,6 @@
|
|||||||
* Negodata Api Server
|
* Negodata Api Server
|
||||||
* OpenAPI spec version: 0.1.0
|
* 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
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface EnumOption {
|
export type ChatMessageDataScript = string | null;
|
||||||
value: number;
|
|
||||||
name: string;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
@ -5,4 +5,4 @@
|
|||||||
* OpenAPI spec version: 0.1.0
|
* 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 './cardDataScript';
|
||||||
export * from './cardDataUpdatedAt';
|
export * from './cardDataUpdatedAt';
|
||||||
export * from './cardDataUserId';
|
export * from './cardDataUserId';
|
||||||
|
export * from './cardStatus';
|
||||||
|
export * from './cardType';
|
||||||
export * from './chatMessageData';
|
export * from './chatMessageData';
|
||||||
export * from './chatMessageDataCardId';
|
export * from './chatMessageDataCardId';
|
||||||
export * from './chatMessageDataCardType';
|
export * from './chatMessageDataCardType';
|
||||||
export * from './chatMessageDataCardUsedYn';
|
export * from './chatMessageDataCardUsedYn';
|
||||||
export * from './chatMessageDataIndicatorValue';
|
export * from './chatMessageDataIndicatorValue';
|
||||||
|
export * from './chatMessageDataScript';
|
||||||
|
export * from './chatMessageDataStep';
|
||||||
|
export * from './chatSender';
|
||||||
export * from './companyData';
|
export * from './companyData';
|
||||||
export * from './enumOption';
|
export * from './deliveryType';
|
||||||
export * from './errorInfo';
|
export * from './errorInfo';
|
||||||
export * from './errorInfoCode';
|
export * from './errorInfoCode';
|
||||||
export * from './errorInfoDesc';
|
export * from './errorInfoDesc';
|
||||||
@ -80,6 +85,8 @@ export * from './quotationSettingData';
|
|||||||
export * from './quotationSettingDataCreatedAt';
|
export * from './quotationSettingDataCreatedAt';
|
||||||
export * from './quotationSettingDataUpdatedAt';
|
export * from './quotationSettingDataUpdatedAt';
|
||||||
export * from './quotationSettingDataUserId';
|
export * from './quotationSettingDataUserId';
|
||||||
|
export * from './quotationStatus';
|
||||||
|
export * from './quotationType';
|
||||||
export * from './reqCheckCodes';
|
export * from './reqCheckCodes';
|
||||||
export * from './reqCreateAccount';
|
export * from './reqCreateAccount';
|
||||||
export * from './reqCreateCard';
|
export * from './reqCreateCard';
|
||||||
@ -179,9 +186,6 @@ export * from './resDeleteQuotationSetting';
|
|||||||
export * from './resDeleteQuotationSettingMsg';
|
export * from './resDeleteQuotationSettingMsg';
|
||||||
export * from './resDeleteSupplier';
|
export * from './resDeleteSupplier';
|
||||||
export * from './resDeleteSupplierMsg';
|
export * from './resDeleteSupplierMsg';
|
||||||
export * from './resEnums';
|
|
||||||
export * from './resEnumsEnums';
|
|
||||||
export * from './resEnumsMsg';
|
|
||||||
export * from './resItem';
|
export * from './resItem';
|
||||||
export * from './resItemCategories';
|
export * from './resItemCategories';
|
||||||
export * from './resItemCategoriesMsg';
|
export * from './resItemCategoriesMsg';
|
||||||
@ -248,6 +252,7 @@ export * from './sessionDataBidPrice';
|
|||||||
export * from './sessionDataRejectDeliveryType';
|
export * from './sessionDataRejectDeliveryType';
|
||||||
export * from './sessionDataRejectPrice';
|
export * from './sessionDataRejectPrice';
|
||||||
export * from './sessionDataRejectReason';
|
export * from './sessionDataRejectReason';
|
||||||
|
export * from './sessionStatus';
|
||||||
export * from './supplierData';
|
export * from './supplierData';
|
||||||
export * from './supplierDataCode';
|
export * from './supplierDataCode';
|
||||||
export * from './supplierDataCreatedAt';
|
export * from './supplierDataCreatedAt';
|
||||||
@ -256,6 +261,7 @@ export * from './supplierDataManagerEmail';
|
|||||||
export * from './supplierDataManagerName';
|
export * from './supplierDataManagerName';
|
||||||
export * from './supplierDataPriority';
|
export * from './supplierDataPriority';
|
||||||
export * from './supplierDataUpdatedAt';
|
export * from './supplierDataUpdatedAt';
|
||||||
|
export * from './userRole';
|
||||||
export * from './validationError';
|
export * from './validationError';
|
||||||
export * from './validationErrorCtx';
|
export * from './validationErrorCtx';
|
||||||
export * from './validationErrorLocItem';
|
export * from './validationErrorLocItem';
|
||||||
@ -4,5 +4,6 @@
|
|||||||
* Negodata Api Server
|
* Negodata Api Server
|
||||||
* OpenAPI spec version: 0.1.0
|
* 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
|
* Negodata Api Server
|
||||||
* OpenAPI spec version: 0.1.0
|
* 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
|
* Negodata Api Server
|
||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
|
import type { QuotationType } from './quotationType';
|
||||||
|
import type { QuotationStatus } from './quotationStatus';
|
||||||
import type { QuotationDataManagerName } from './quotationDataManagerName';
|
import type { QuotationDataManagerName } from './quotationDataManagerName';
|
||||||
import type { QuotationDataManagerEmail } from './quotationDataManagerEmail';
|
import type { QuotationDataManagerEmail } from './quotationDataManagerEmail';
|
||||||
import type { QuotationDataManagerContactNumber } from './quotationDataManagerContactNumber';
|
import type { QuotationDataManagerContactNumber } from './quotationDataManagerContactNumber';
|
||||||
@ -25,9 +27,9 @@ export interface QuotationData {
|
|||||||
version_id: string;
|
version_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
number: string;
|
number: string;
|
||||||
type: number;
|
type: QuotationType;
|
||||||
round?: number;
|
round?: number;
|
||||||
status: number;
|
status: QuotationStatus;
|
||||||
start_time: string;
|
start_time: string;
|
||||||
end_time: string;
|
end_time: string;
|
||||||
manager_name?: QuotationDataManagerName;
|
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;
|
page?: number;
|
||||||
size?: number;
|
size?: number;
|
||||||
cards?: CardData[];
|
cards?: CardData[];
|
||||||
/** 협상카드 탭 카운트(검색 필터 반영) */
|
|
||||||
total_nego?: number;
|
total_nego?: number;
|
||||||
/** 와일드카드 탭 카운트(검색 필터 반영) */
|
|
||||||
total_wild?: 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 { ResMeName } from './resMeName';
|
||||||
import type { ResMeEmail } from './resMeEmail';
|
import type { ResMeEmail } from './resMeEmail';
|
||||||
import type { ResMeContactNumber } from './resMeContactNumber';
|
import type { ResMeContactNumber } from './resMeContactNumber';
|
||||||
|
import type { UserRole } from './userRole';
|
||||||
import type { ResMeCompany } from './resMeCompany';
|
import type { ResMeCompany } from './resMeCompany';
|
||||||
|
|
||||||
export interface ResMe {
|
export interface ResMe {
|
||||||
@ -19,7 +20,6 @@ export interface ResMe {
|
|||||||
name?: ResMeName;
|
name?: ResMeName;
|
||||||
email?: ResMeEmail;
|
email?: ResMeEmail;
|
||||||
contact_number?: ResMeContactNumber;
|
contact_number?: ResMeContactNumber;
|
||||||
role?: number;
|
role?: UserRole;
|
||||||
role_label?: string;
|
|
||||||
company?: ResMeCompany;
|
company?: ResMeCompany;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,8 @@
|
|||||||
* Negodata Api Server
|
* Negodata Api Server
|
||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
|
import type { QuotationType } from './quotationType';
|
||||||
|
import type { SessionStatus } from './sessionStatus';
|
||||||
import type { SessionDataBidPrice } from './sessionDataBidPrice';
|
import type { SessionDataBidPrice } from './sessionDataBidPrice';
|
||||||
import type { SessionDataBidAt } from './sessionDataBidAt';
|
import type { SessionDataBidAt } from './sessionDataBidAt';
|
||||||
import type { SessionDataRejectReason } from './sessionDataRejectReason';
|
import type { SessionDataRejectReason } from './sessionDataRejectReason';
|
||||||
@ -17,9 +19,9 @@ export interface SessionData {
|
|||||||
item_id: string;
|
item_id: string;
|
||||||
qt_number: string;
|
qt_number: string;
|
||||||
qt_round: number;
|
qt_round: number;
|
||||||
qt_type: number;
|
qt_type: QuotationType;
|
||||||
target_price: number;
|
target_price: number;
|
||||||
status: number;
|
status: SessionStatus;
|
||||||
bid_price?: SessionDataBidPrice;
|
bid_price?: SessionDataBidPrice;
|
||||||
bid_at?: SessionDataBidAt;
|
bid_at?: SessionDataBidAt;
|
||||||
end_time: string;
|
end_time: string;
|
||||||
|
|||||||
@ -4,5 +4,6 @@
|
|||||||
* Negodata Api Server
|
* Negodata Api Server
|
||||||
* OpenAPI spec version: 0.1.0
|
* 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 {ResMe} from '../../api/generated/model/resMe';
|
||||||
import type {ErrorInfo} from '../../api/generated/model/errorInfo';
|
import type {ErrorInfo} from '../../api/generated/model/errorInfo';
|
||||||
import {useAuthStore, type AuthUser, type UserRole} from '../../stores/auth';
|
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 ACCESS_KEY = 'negodata.accessToken';
|
||||||
const REFRESH_KEY = 'negodata.refreshToken';
|
const REFRESH_KEY = 'negodata.refreshToken';
|
||||||
@ -26,7 +28,7 @@ function toAuthUser(me: ResMe): AuthUser {
|
|||||||
loginId: me.id ?? '',
|
loginId: me.id ?? '',
|
||||||
email: me.email ?? '',
|
email: me.email ?? '',
|
||||||
contact: me.contact_number ?? '',
|
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 { NegotiationCard } from '@/types';
|
||||||
import type { CardData } from '@/api/generated/model/cardData';
|
import type { CardData } from '@/api/generated/model/cardData';
|
||||||
|
import { CardStatus } from '@/api/generated/model';
|
||||||
|
|
||||||
export type { NegotiationCard };
|
export type { NegotiationCard };
|
||||||
|
|
||||||
// 카드 목록 탭. 'ALL' 전체 / 'CARD' 일반 협상카드 / 'WILD' 와일드카드.
|
// 카드 목록 탭. 'ALL' 전체 / 'CARD' 일반 협상카드 / 'WILD' 와일드카드.
|
||||||
export type CardTab = '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') =>
|
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' =>
|
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.
|
// 서버 CardData(nego_cards/wild_cards 통합) → UI NegotiationCard.
|
||||||
export function mapCardData(c: CardData): NegotiationCard {
|
export function mapCardData(c: CardData): NegotiationCard {
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import type { ReqCreateItem as ItemCreate } from '@/api/generated/model/reqCreateItem';
|
import type { ReqCreateItem as ItemCreate } from '@/api/generated/model/reqCreateItem';
|
||||||
import type { ReqUpdateItem as ItemUpdate } from '@/api/generated/model/reqUpdateItem';
|
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 { uploadItemImage } from '@/api/generated/item/item';
|
||||||
import { showToast } from '@/lib/notify';
|
import { showToast } from '@/lib/notify';
|
||||||
import ImageDropzone from '@/components/ImageDropzone';
|
import ImageDropzone from '@/components/ImageDropzone';
|
||||||
@ -121,9 +121,7 @@ export function ProductFormSheet({
|
|||||||
defaultValues: buildDefaults(mode, product),
|
defaultValues: buildDefaults(mode, product),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 배송 형태 코드(delivery_type) 선택지는 서버 enum 에서 가져온다.
|
const deliveryTypes = DELIVERY_TYPE_OPTIONS;
|
||||||
const { data: enumsData } = useListEnums();
|
|
||||||
const deliveryTypes = enumsData?.enums?.delivery_type ?? [];
|
|
||||||
|
|
||||||
// minPrice는 화면 전용(서버 미전송). 검증된 값만 payload로.
|
// minPrice는 화면 전용(서버 미전송). 검증된 값만 payload로.
|
||||||
const onValid = async (v: FormValues) => {
|
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import type { Product, Partner, QuotationSetting, NegotiationCard } from '../types';
|
import type { Product, Partner, QuotationSetting, NegotiationCard } from '../types';
|
||||||
import type { CreateQuotationInput } from '../hooks/useQuotations';
|
import type { CreateQuotationInput } from '../hooks/useQuotations';
|
||||||
|
import { QuotationType } from '@/api/generated/model';
|
||||||
|
import { QUOTATION_TYPE_OPTIONS } from '../types';
|
||||||
|
|
||||||
type QuotationCreateModalProps = {
|
type QuotationCreateModalProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@ -28,19 +30,22 @@ export function QuotationCreateModal({
|
|||||||
}: QuotationCreateModalProps) {
|
}: QuotationCreateModalProps) {
|
||||||
const [step, setStep] = useState(1);
|
const [step, setStep] = useState(1);
|
||||||
const [title, setTitle] = useState('');
|
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 [productId, setProductId] = useState('');
|
||||||
const [selectedPartnerIds, setSelectedPartnerIds] = useState<string[]>([]);
|
const [selectedPartnerIds, setSelectedPartnerIds] = useState<string[]>([]);
|
||||||
const [dueDate, setDueDate] = useState('2026-06-15T18:00');
|
const [dueDate, setDueDate] = useState('2026-06-15T18:00');
|
||||||
const [settingId, setSettingId] = useState(quotationSettings[0]?.qt_setting_id ?? '');
|
const [settingId, setSettingId] = useState(quotationSettings[0]?.qt_setting_id ?? '');
|
||||||
const [selectedCardIds, setSelectedCardIds] = useState<string[]>([]);
|
const [selectedCardIds, setSelectedCardIds] = useState<string[]>([]);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const typeOptions = QUOTATION_TYPE_OPTIONS;
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
const togglePartner = (id: string) =>
|
const togglePartner = (id: string) =>
|
||||||
setSelectedPartnerIds((prev) =>
|
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) =>
|
const toggleCard = (id: string) =>
|
||||||
setSelectedCardIds((prev) =>
|
setSelectedCardIds((prev) =>
|
||||||
@ -73,8 +78,8 @@ export function QuotationCreateModal({
|
|||||||
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
<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">
|
<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} />
|
<Loader2 className="text-primary animate-spin" size={44} strokeWidth={2.5} />
|
||||||
<span className="text-sm font-semibold text-foreground font-mono">협상견적 생성 중…</span>
|
<Typography variant="small" className="font-semibold font-mono">협상견적 생성 중…</Typography>
|
||||||
<span className="text-[11px] text-muted-foreground font-mono">견적 · 협상 세션 등록 중</span>
|
<Typography variant="small" className="text-muted-foreground font-mono">견적 · 협상 세션 등록 중</Typography>
|
||||||
</div>
|
</div>
|
||||||
</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 justify-between pb-4 border-b border-border">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<PlusSquare className="text-foreground" size={18} />
|
<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>
|
</div>
|
||||||
<button onClick={onClose} className="p-1 rounded text-muted-foreground hover:bg-muted cursor-pointer">
|
<button onClick={onClose} className="p-1 rounded text-muted-foreground hover:bg-muted cursor-pointer">
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
@ -93,11 +98,11 @@ export function QuotationCreateModal({
|
|||||||
|
|
||||||
{/* Steps indicator */}
|
{/* Steps indicator */}
|
||||||
<div className="flex items-center justify-between gap-2 py-4 border-b border-border/40 text-[10px] text-muted-foreground">
|
<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} />
|
<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} />
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Step content */}
|
{/* Step content */}
|
||||||
@ -121,17 +126,22 @@ export function QuotationCreateModal({
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Typography as="label" variant="label">유형</Typography>
|
<Typography as="label" variant="label">유형</Typography>
|
||||||
<Select
|
<Select
|
||||||
value={type}
|
value={String(type)}
|
||||||
onValueChange={(v) => setType(v as 'RE_NEGOTIATION' | 'RE_ESTIMATE')}
|
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">
|
<SelectTrigger id="wizard-type" className="w-full">
|
||||||
<SelectValue>
|
<SelectValue>
|
||||||
{(value) => (value === 'RE_ESTIMATE' ? '재견적' : '재협상')}
|
{(value) => typeOptions.find((o) => String(o.value) === value)?.label ?? ''}
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="RE_NEGOTIATION">재협상</SelectItem>
|
{typeOptions.map((o) => (
|
||||||
<SelectItem value="RE_ESTIMATE">재견적</SelectItem>
|
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@ -175,7 +185,7 @@ export function QuotationCreateModal({
|
|||||||
|
|
||||||
{step === 2 && (
|
{step === 2 && (
|
||||||
<div className="space-y-3">
|
<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">
|
<div className="border border-border rounded overflow-hidden max-h-56 overflow-y-auto divide-y divide-border bg-background">
|
||||||
{partners.map((part) => {
|
{partners.map((part) => {
|
||||||
const isChecked = selectedPartnerIds.includes(part.id ?? '');
|
const isChecked = selectedPartnerIds.includes(part.id ?? '');
|
||||||
@ -192,8 +202,8 @@ export function QuotationCreateModal({
|
|||||||
className="accent-primary h-4 w-4"
|
className="accent-primary h-4 w-4"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<span className="font-semibold text-foreground block">{part.name}</span>
|
<Typography as="span" variant="small" className="font-semibold block">{part.name}</Typography>
|
||||||
<span className="text-[10px] text-muted-foreground">이메일: {part.managerEmail} · 등급: {part.rank}</span>
|
<Typography as="span" variant="small" className="text-muted-foreground">이메일: {part.managerEmail} · 등급: {part.rank}</Typography>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
@ -236,7 +246,7 @@ export function QuotationCreateModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<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">
|
<div className="grid grid-cols-2 gap-2 max-h-48 overflow-y-auto">
|
||||||
{cards.filter((c) => !c.isWildcard || c.status === 'ACTIVE').map((card) => {
|
{cards.filter((c) => !c.isWildcard || c.status === 'ACTIVE').map((card) => {
|
||||||
const isChecked = selectedCardIds.includes(card.id);
|
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" />
|
<input type="checkbox" checked={isChecked} readOnly className="accent-primary h-3.5 w-3.5 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-1.5">
|
<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
|
<span
|
||||||
className={`text-[9px] font-mono px-1.5 py-0.5 rounded leading-none ${
|
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'
|
card.isWildcard ? 'bg-amber-50 text-amber-700' : 'bg-zinc-100 text-zinc-600'
|
||||||
@ -260,7 +270,7 @@ export function QuotationCreateModal({
|
|||||||
{card.isWildcard ? '와일드' : '협상'}
|
{card.isWildcard ? '와일드' : '협상'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,9 +2,10 @@ import { Sparkles } from 'lucide-react';
|
|||||||
import type { SessionData } from '@/api/generated/model/sessionData';
|
import type { SessionData } from '@/api/generated/model/sessionData';
|
||||||
import type { QuotationCardData } from '@/api/generated/model/quotationCardData';
|
import type { QuotationCardData } from '@/api/generated/model/quotationCardData';
|
||||||
import type { ChatMessageData } from '@/api/generated/model/chatMessageData';
|
import type { ChatMessageData } from '@/api/generated/model/chatMessageData';
|
||||||
|
import { ChatSender, CardType } from '@/api/generated/model';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import SlateRenderer from '@/components/SlateRenderer';
|
import SlateRenderer from '@/components/SlateRenderer';
|
||||||
import { StatusPill, chatStatusTone } from './StatusPill';
|
import { StatusPill, sessionStatusTone } from './StatusPill';
|
||||||
import { type Product, type Partner, sessionStatusLabel } from '../../types';
|
import { type Product, type Partner, sessionStatusLabel } from '../../types';
|
||||||
|
|
||||||
export function ChatTab({
|
export function ChatTab({
|
||||||
@ -26,6 +27,9 @@ export function ChatTab({
|
|||||||
currentProduct: Product | undefined;
|
currentProduct: Product | undefined;
|
||||||
serverCards: QuotationCardData[];
|
serverCards: QuotationCardData[];
|
||||||
}) {
|
}) {
|
||||||
|
// 목표가는 협상(세션) 단위 고정값(sessions.target_price)이라 메시지마다가 아니라 헤더에 한 번만 표시한다.
|
||||||
|
const currentSession = serverSessions.find((s) => s.session_id === effectiveSessionId);
|
||||||
|
const targetPrice = currentSession?.target_price;
|
||||||
return (
|
return (
|
||||||
<div className="h-[500px] border border-border rounded-lg overflow-hidden bg-card flex">
|
<div className="h-[500px] border border-border rounded-lg overflow-hidden bg-card flex">
|
||||||
{/* Sessions list */}
|
{/* Sessions list */}
|
||||||
@ -53,7 +57,7 @@ export function ChatTab({
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="font-bold text-foreground text-xs">{name}</span>
|
<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}
|
{statusLabel}
|
||||||
</StatusPill>
|
</StatusPill>
|
||||||
</div>
|
</div>
|
||||||
@ -75,8 +79,15 @@ export function ChatTab({
|
|||||||
<div className="text-muted-foreground">
|
<div className="text-muted-foreground">
|
||||||
협력사: <strong className="text-foreground">{currentSupplierName}</strong>
|
협력사: <strong className="text-foreground">{currentSupplierName}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
기록: <span className="font-semibold text-foreground">{chatMessages.length}</span> 메시지
|
{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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -91,13 +102,13 @@ export function ChatTab({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
chatMessages.map((m) => {
|
chatMessages.map((m) => {
|
||||||
const isBot = m.sender === 1;
|
const isBot = m.sender === ChatSender.BOT;
|
||||||
// 메시지가 쓴 협상카드 전체(이름만이 아니라 멘트/조건/메모까지) 를 chat_id 로 매칭.
|
// 메시지가 쓴 협상카드 전체(이름만이 아니라 멘트/조건/메모까지) 를 chat_id 로 매칭.
|
||||||
const usedCard = m.card_used_yn
|
const usedCard = m.card_used_yn
|
||||||
? serverCards.find((c) => c.session_card_id === m.chat_id)
|
? serverCards.find((c) => c.session_card_id === m.chat_id)
|
||||||
: undefined;
|
: undefined;
|
||||||
const cardNodes = Array.isArray(usedCard?.edit_script) ? (usedCard.edit_script as unknown[]) : null;
|
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 (
|
return (
|
||||||
<div key={m.chat_id} className={`flex ${isBot ? 'justify-start' : 'justify-end'}`}>
|
<div key={m.chat_id} className={`flex ${isBot ? 'justify-start' : 'justify-end'}`}>
|
||||||
<div className="space-y-1 max-w-[85%]">
|
<div className="space-y-1 max-w-[85%]">
|
||||||
@ -118,7 +129,21 @@ export function ChatTab({
|
|||||||
: 'bg-primary border-transparent text-primary-foreground'
|
: '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 && (
|
{usedCard && (
|
||||||
<div
|
<div
|
||||||
className={`mt-2 rounded border p-2 ${
|
className={`mt-2 rounded border p-2 ${
|
||||||
|
|||||||
@ -9,9 +9,8 @@ import {
|
|||||||
type Product,
|
type Product,
|
||||||
type Partner,
|
type Partner,
|
||||||
type QuotationSetting,
|
type QuotationSetting,
|
||||||
normalizeQuotationStatus,
|
|
||||||
normalizeQuotationType,
|
|
||||||
buildBidSummary,
|
buildBidSummary,
|
||||||
|
quotationTypeLabel,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
|
|
||||||
const fmtYn = (b: boolean | null | undefined, yes: string, no: string) =>
|
const fmtYn = (b: boolean | null | undefined, yes: string, no: string) =>
|
||||||
@ -46,14 +45,11 @@ export function DrawerHeaderCards({
|
|||||||
// Quotations DDL 표시값
|
// Quotations DDL 표시값
|
||||||
const q_name = quotation.name || '미지정';
|
const q_name = quotation.name || '미지정';
|
||||||
const q_number = quotation.number || 'EST-000000-0000';
|
const q_number = quotation.number || 'EST-000000-0000';
|
||||||
const q_type = normalizeQuotationType(quotation.type);
|
|
||||||
const q_round = quotation.round || 1;
|
const q_round = quotation.round || 1;
|
||||||
const q_status = String(quotation.status ?? '견적생성');
|
|
||||||
const q_end_time = quotation.end_time || '미지정';
|
const q_end_time = quotation.end_time || '미지정';
|
||||||
const q_manager_name = quotation.manager_name || '홍길동 파트너';
|
const q_manager_name = quotation.manager_name || '홍길동 파트너';
|
||||||
const q_manager_email = quotation.manager_email || 'gildong@negodata.com';
|
const q_manager_email = quotation.manager_email || 'gildong@negodata.com';
|
||||||
const q_memo = quotation.memo || '안내사항 없음';
|
const q_memo = quotation.memo || '안내사항 없음';
|
||||||
const statusKey = normalizeQuotationStatus(quotation.status);
|
|
||||||
|
|
||||||
const bidSummaryObj = buildBidSummary(quotation, partners);
|
const bidSummaryObj = buildBidSummary(quotation, partners);
|
||||||
const selectedSettingObj = quotationSettings.find((qs) => qs.qt_setting_id === quotation.qt_setting_id);
|
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">
|
<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_name} valueClassName="font-sans" />
|
||||||
<InfoField label="견적번호" value={q_number} />
|
<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="차수" value={`${q_round}차`} />
|
||||||
<InfoField label="견적상태" labelClassName="opacity-90 font-bold mb-1">
|
<InfoField label="견적상태" labelClassName="opacity-90 font-bold mb-1">
|
||||||
<QuotationStatusBadge statusKey={statusKey} fallbackLabel={q_status} />
|
<QuotationStatusBadge status={quotation.status} />
|
||||||
</InfoField>
|
</InfoField>
|
||||||
<InfoField label="마감시각" value={q_end_time} />
|
<InfoField label="마감시각" value={q_end_time} />
|
||||||
<InfoField label="담당자" value={`${q_manager_name} (${q_manager_email})`} valueClassName="font-sans" />
|
<InfoField label="담당자" value={`${q_manager_name} (${q_manager_email})`} valueClassName="font-sans" />
|
||||||
@ -144,7 +140,7 @@ export function DrawerHeaderCards({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<Link
|
<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"
|
className="text-foreground font-bold font-sans text-[12px] mb-2 truncate block hover:text-primary hover:underline"
|
||||||
title={`${currentProduct.name || ''} — 상품 상세로 이동`}
|
title={`${currentProduct.name || ''} — 상품 상세로 이동`}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { MessageSquare, ExternalLink, Copy } from 'lucide-react';
|
|||||||
import { showToast } from '@/lib/notify';
|
import { showToast } from '@/lib/notify';
|
||||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
|
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
|
||||||
import { StatusPill, sessionStatusTone } from './StatusPill';
|
import { StatusPill, sessionStatusTone } from './StatusPill';
|
||||||
import { mapServerSessionView } from '../../types';
|
import { mapServerSessionView, sessionStatusLabel } from '../../types';
|
||||||
|
|
||||||
type SessionView = ReturnType<typeof mapServerSessionView>;
|
type SessionView = ReturnType<typeof mapServerSessionView>;
|
||||||
|
|
||||||
@ -85,7 +85,7 @@ export function SessionsStatusTab({
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="p-3 font-semibold font-sans">{sess.item_name}</TableCell>
|
<TableCell className="p-3 font-semibold font-sans">{sess.item_name}</TableCell>
|
||||||
<TableCell className="p-3 text-center">
|
<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>
|
||||||
<TableCell className="p-3 text-right font-bold text-muted-foreground">
|
<TableCell className="p-3 text-right font-bold text-muted-foreground">
|
||||||
₩{sess.target_price?.toLocaleString() || '-'}
|
₩{sess.target_price?.toLocaleString() || '-'}
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { QuotationStatus, SessionStatus } from '@/api/generated/model';
|
||||||
|
import { quotationStatusLabel } from '../../types';
|
||||||
|
|
||||||
/* ── 작은 상태 pill (세션 상태 / 카드 타입 / 채팅 목록 상태) ──
|
/* ── 작은 상태 pill (세션 상태 / 카드 타입 / 채팅 목록 상태) ──
|
||||||
기존엔 곳마다 색맵을 손으로 박았고 dark 알파(/20·/30)와 red·rose 가 미묘하게
|
기존엔 곳마다 색맵을 손으로 박았고 dark 알파(/20·/30)와 red·rose 가 미묘하게
|
||||||
@ -36,36 +38,28 @@ export function StatusPill({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 협상 세션 상태(현황 테이블) → pill 색 */
|
export function sessionStatusTone(status?: number | null): PillTone {
|
||||||
export function sessionStatusTone(status: string | null | undefined): PillTone {
|
if (status === SessionStatus.DONE) return 'blue';
|
||||||
if (status === '협상완료' || status === 'COMPLETED') return 'blue';
|
if (status === SessionStatus.REJECTED) return 'rose';
|
||||||
if (status === '협상거부' || status === 'REJECTED') return 'rose';
|
|
||||||
return 'emerald';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 채팅 목록의 협상 상태 라벨 → pill 색 */
|
|
||||||
export function chatStatusTone(label: string): PillTone {
|
|
||||||
if (label === '협상거부') return 'rose';
|
|
||||||
if (label === '협상완료') return 'blue';
|
|
||||||
return 'emerald';
|
return 'emerald';
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 견적 상태 배지 (헤더, dot + border + 견적생성 시 pulse) ──
|
/* ── 견적 상태 배지 (헤더, dot + border + 견적생성 시 pulse) ──
|
||||||
작은 pill 들과 모양이 달라(테두리·점·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',
|
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',
|
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',
|
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',
|
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',
|
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',
|
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',
|
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',
|
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' };
|
const QSTATUS_FALLBACK = { box: 'bg-zinc-100 text-zinc-800 border-zinc-300', dot: 'bg-zinc-500' };
|
||||||
|
|
||||||
export function QuotationStatusBadge({
|
export function QuotationStatusBadge({ status }: { status?: number | null }) {
|
||||||
statusKey,
|
const t = (status != null && QSTATUS_TONE[status as QuotationStatus]) || QSTATUS_FALLBACK;
|
||||||
fallbackLabel,
|
|
||||||
}: {
|
|
||||||
statusKey: string;
|
|
||||||
fallbackLabel: string;
|
|
||||||
}) {
|
|
||||||
const t = QSTATUS_TONE[statusKey] ?? QSTATUS_FALLBACK;
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -89,7 +77,7 @@ export function QuotationStatusBadge({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className={cn('h-1.5 w-1.5 rounded-full', t.dot)} />
|
<span className={cn('h-1.5 w-1.5 rounded-full', t.dot)} />
|
||||||
{statusKey || fallbackLabel}
|
{quotationStatusLabel(status)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,27 +1,23 @@
|
|||||||
import { useState } from 'react';
|
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 { Typography } from '@/components/ui/typography';
|
||||||
import {
|
import {
|
||||||
useGetQuotationSessions,
|
useGetQuotationSessions,
|
||||||
useGetSessionChat,
|
useGetSessionChat,
|
||||||
useGetQuotationCards,
|
useGetQuotationCards,
|
||||||
} from '@/api/generated/quotation/quotation';
|
} 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 { useListSuppliers } from '@/api/generated/supplier/supplier';
|
||||||
import { useListSettings } from '@/api/generated/quotation-setting/quotation-setting';
|
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 type { QuotationData } from '@/api/generated/model/quotationData';
|
||||||
import {
|
import {
|
||||||
unwrap,
|
|
||||||
mapItem,
|
mapItem,
|
||||||
mapSupplier,
|
mapSupplier,
|
||||||
mapSetting,
|
mapSetting,
|
||||||
normalizeQuotationStatus,
|
|
||||||
mapServerSessionView,
|
mapServerSessionView,
|
||||||
mapServerCardView,
|
mapServerCardView,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
|
import { QuotationStatus } from '@/api/generated/model';
|
||||||
import { DrawerHeaderCards } from './DrawerHeaderCards';
|
import { DrawerHeaderCards } from './DrawerHeaderCards';
|
||||||
import { SessionsStatusTab } from './SessionsStatusTab';
|
import { SessionsStatusTab } from './SessionsStatusTab';
|
||||||
import { QuotationCardsTab } from './QuotationCardsTab';
|
import { QuotationCardsTab } from './QuotationCardsTab';
|
||||||
@ -31,26 +27,24 @@ type DrawerTab = 'status' | 'cards' | 'chat';
|
|||||||
|
|
||||||
type QuotationDetailSheetProps = {
|
type QuotationDetailSheetProps = {
|
||||||
quotation: QuotationData;
|
quotation: QuotationData;
|
||||||
onStop: (id: string, name: string) => void;
|
onCloseQuotation: (id: string, name: string) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function QuotationDetailSheet({
|
export function QuotationDetailSheet({
|
||||||
quotation,
|
quotation,
|
||||||
onStop,
|
onCloseQuotation,
|
||||||
onClose,
|
onClose,
|
||||||
}: QuotationDetailSheetProps) {
|
}: QuotationDetailSheetProps) {
|
||||||
const [activeTab, setActiveTab] = useState<DrawerTab>('status');
|
const [activeTab, setActiveTab] = useState<DrawerTab>('status');
|
||||||
const [showHeaderCards, setShowHeaderCards] = useState(true);
|
const [showHeaderCards, setShowHeaderCards] = useState(true);
|
||||||
|
|
||||||
// 상품·협력사·견적세팅 목록은 sheet 안에서 직접 서버(orval)로 읽는다(부모 props 의존 제거).
|
// 협력사·견적세팅 목록은 sheet 안에서 직접 서버(orval)로 읽는다(부모 props 의존 제거).
|
||||||
const itemsQuery = useListItems({ size: 100 });
|
|
||||||
const suppliersQuery = useListSuppliers({ size: 100 });
|
const suppliersQuery = useListSuppliers({ size: 100 });
|
||||||
const settingsQuery = useListSettings();
|
const settingsQuery = useListSettings();
|
||||||
const products = (unwrap<{ items?: ItemData[] }>(itemsQuery.data)?.items ?? []).map(mapItem);
|
const partners = (suppliersQuery.data?.suppliers ?? []).map(mapSupplier);
|
||||||
const partners = (unwrap<{ suppliers?: SupplierData[] }>(suppliersQuery.data)?.suppliers ?? []).map(mapSupplier);
|
|
||||||
const quotationSettings = (
|
const quotationSettings = (
|
||||||
unwrap<{ settings?: QuotationSettingData[] }>(settingsQuery.data)?.settings ?? []
|
settingsQuery.data?.settings ?? []
|
||||||
).map(mapSetting);
|
).map(mapSetting);
|
||||||
|
|
||||||
const qtId = quotation.qt_id ?? '';
|
const qtId = quotation.qt_id ?? '';
|
||||||
@ -70,16 +64,22 @@ export function QuotationDetailSheet({
|
|||||||
const currentSession = serverSessions.find((s) => s.session_id === effectiveSessionId);
|
const currentSession = serverSessions.find((s) => s.session_id === effectiveSessionId);
|
||||||
const currentSupplierName =
|
const currentSupplierName =
|
||||||
partners.find((p) => p.id === currentSession?.supplier_id)?.name || currentSession?.supplier_id || '-';
|
partners.find((p) => p.id === currentSession?.supplier_id)?.name || currentSession?.supplier_id || '-';
|
||||||
// 현재 세션의 상품(이미지·규격 등 상세 + 카드 변수 치환용 상품명).
|
// 견적 1건 = 상품 1개(item_ids:[productId])라 모든 세션이 같은 상품을 공유한다.
|
||||||
const currentProduct = products.find((p) => p.id === currentSession?.item_id);
|
// 카탈로그 전체 대신 그 상품 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);
|
const quotationCardViews = serverCards.map(mapServerCardView);
|
||||||
|
|
||||||
// 헤더 상단바·중지 버튼에 필요한 최소 표시값만 (나머지 견적 표시값은 DrawerHeaderCards 내부 계산).
|
// 헤더 상단바·마감 버튼에 필요한 최소 표시값만 (나머지 견적 표시값은 DrawerHeaderCards 내부 계산).
|
||||||
const q_name = quotation.name || '미지정';
|
const q_name = quotation.name || '미지정';
|
||||||
const q_number = quotation.number || 'EST-000000-0000';
|
const q_number = quotation.number || 'EST-000000-0000';
|
||||||
const statusKey = normalizeQuotationStatus(quotation.status);
|
|
||||||
|
|
||||||
const goToChat = (sessionId: string) => {
|
const goToChat = (sessionId: string) => {
|
||||||
setSelectedSessionId(sessionId);
|
setSelectedSessionId(sessionId);
|
||||||
@ -115,15 +115,21 @@ export function QuotationDetailSheet({
|
|||||||
>
|
>
|
||||||
<span>{showHeaderCards ? '견적 상세 정보 접기 ▲' : '견적 상세 정보 펼치기 ▼'}</span>
|
<span>{showHeaderCards ? '견적 상세 정보 접기 ▲' : '견적 상세 정보 펼치기 ▼'}</span>
|
||||||
</button>
|
</button>
|
||||||
{statusKey === '견적진행중' && (
|
{/* 마감 버튼은 항상 노출하되, 마감 가능한 상태(생성·진행중·보류)가 아니면 비활성화만 한다. */}
|
||||||
<button
|
{(() => {
|
||||||
onClick={() => onStop(quotation.qt_id ?? '', q_name)}
|
const canClose = quotation.status !== QuotationStatus.CLOSED;
|
||||||
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"
|
return (
|
||||||
>
|
<button
|
||||||
<StopCircle size={14} />
|
onClick={() => onCloseQuotation(quotation.qt_id ?? '', q_name)}
|
||||||
<span>중지</span>
|
disabled={!canClose}
|
||||||
</button>
|
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
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-1.5 rounded-full text-muted-foreground hover:bg-muted cursor-pointer"
|
className="p-1.5 rounded-full text-muted-foreground hover:bg-muted cursor-pointer"
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { Clock, Building2 } from 'lucide-react';
|
import { Clock, Building2 } from 'lucide-react';
|
||||||
import { DataTable } from '@/components/ui/data-table';
|
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 = {
|
type QuotationTableProps = {
|
||||||
data: Estimate[];
|
data: Estimate[];
|
||||||
@ -10,15 +11,15 @@ type QuotationTableProps = {
|
|||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusBadgeClass = (status?: string | null) => {
|
const statusBadgeClass = (status?: number | null) => {
|
||||||
switch (normalizeQuotationStatus(status)) {
|
switch (status) {
|
||||||
case '견적생성':
|
case QuotationStatus.CREATED:
|
||||||
return 'bg-yellow-50 text-yellow-700 border-yellow-300 animate-pulse';
|
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';
|
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';
|
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';
|
return 'bg-red-50 text-red-700 border-red-300';
|
||||||
default:
|
default:
|
||||||
return 'bg-zinc-100 text-zinc-600';
|
return 'bg-zinc-100 text-zinc-600';
|
||||||
@ -63,12 +64,12 @@ export function QuotationTable({ data, products, onOpenDetail, footer }: Quotati
|
|||||||
cell: (est) => (
|
cell: (est) => (
|
||||||
<span
|
<span
|
||||||
className={`px-2 py-0.5 rounded-full text-[9px] font-bold ${
|
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-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'
|
: 'bg-zinc-100 text-zinc-900 dark:bg-zinc-800 dark:text-zinc-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{est.type === 'RE_NEGOTIATION' ? '재협상' : '재견적'}
|
{quotationTypeLabel(est.type)}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -85,7 +86,7 @@ export function QuotationTable({ data, products, onOpenDetail, footer }: Quotati
|
|||||||
<span
|
<span
|
||||||
className={`inline-flex items-center gap-1 px-2.5 py-0.5 text-[10px] font-semibold rounded-full border ${statusBadgeClass(est.status)}`}
|
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>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -14,22 +14,20 @@ import {
|
|||||||
useListQuotations,
|
useListQuotations,
|
||||||
useCreateQuotation,
|
useCreateQuotation,
|
||||||
useStopQuotation,
|
useStopQuotation,
|
||||||
|
getGetQuotationQueryKey,
|
||||||
|
getGetQuotationSessionsQueryKey,
|
||||||
} from '@/api/generated/quotation/quotation';
|
} from '@/api/generated/quotation/quotation';
|
||||||
import type { ListQuotationsParams } from '@/api/generated/model/listQuotationsParams';
|
import type { ListQuotationsParams } from '@/api/generated/model/listQuotationsParams';
|
||||||
import type { ReqCreateQuotation } from '@/api/generated/model/reqCreateQuotation';
|
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 { showToast } from '@/lib/notify';
|
||||||
import { confirm } from '@/lib/confirm';
|
import { confirm } from '@/lib/confirm';
|
||||||
import type { Estimate } from '@/types';
|
import type { Estimate } from '../types';
|
||||||
import { unwrap, mapItem, mapSupplier, mapSetting, mapQuotation } from '../types';
|
import { mapItem, mapSupplier, mapSetting, mapQuotation } from '../types';
|
||||||
|
import { QuotationStatus } from '@/api/generated/model';
|
||||||
|
|
||||||
export type CreateQuotationInput = {
|
export type CreateQuotationInput = {
|
||||||
title: string;
|
title: string;
|
||||||
type: 'RE_NEGOTIATION' | 'RE_ESTIMATE';
|
type: number; // QuotationType 코드 (1=재협상, 2=재견적)
|
||||||
productId: string;
|
productId: string;
|
||||||
partnerIds: string[];
|
partnerIds: string[];
|
||||||
dueDate: string; // datetime-local 원본값
|
dueDate: string; // datetime-local 원본값
|
||||||
@ -63,40 +61,44 @@ export function useQuotations(params: ListQuotationsParams) {
|
|||||||
const invalidateQuotations = () =>
|
const invalidateQuotations = () =>
|
||||||
queryClient.invalidateQueries({ queryKey: ['/v1/quotation/list'] });
|
queryClient.invalidateQueries({ queryKey: ['/v1/quotation/list'] });
|
||||||
|
|
||||||
const products = (unwrap<{ items?: ItemData[] }>(itemsQuery.data)?.items ?? []).map(mapItem);
|
const products = (itemsQuery.data?.items ?? []).map(mapItem);
|
||||||
const partners = (unwrap<{ suppliers?: SupplierData[] }>(suppliersQuery.data)?.suppliers ?? []).map(mapSupplier);
|
const partners = (suppliersQuery.data?.suppliers ?? []).map(mapSupplier);
|
||||||
|
|
||||||
// 견적 세팅은 서버가 정본 — 목록 쿼리에서 바로 파생하고, 추가/삭제 후 쿼리를 무효화해 재조회한다.
|
// 견적 세팅은 서버가 정본 — 목록 쿼리에서 바로 파생하고, 추가/삭제 후 쿼리를 무효화해 재조회한다.
|
||||||
const quotationSettings = (
|
const quotationSettings = (
|
||||||
unwrap<{ settings?: QuotationSettingData[] }>(settingsQuery.data)?.settings ?? []
|
settingsQuery.data?.settings ?? []
|
||||||
).map(mapSetting);
|
).map(mapSetting);
|
||||||
|
|
||||||
const [quotations, setQuotations] = useState<Estimate[]>([]);
|
const [quotations, setQuotations] = useState<Estimate[]>([]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const qs = unwrap<{ quotations?: QuotationData[] }>(quotationsQuery.data)?.quotations;
|
const qs = quotationsQuery.data?.quotations;
|
||||||
if (qs) setQuotations(qs.map(mapQuotation));
|
if (qs) setQuotations(qs.map(mapQuotation));
|
||||||
}, [quotationsQuery.data]);
|
}, [quotationsQuery.data]);
|
||||||
// 서버 전체 건수(선택 필터 반영) — 페이지네이션용.
|
// 서버 전체 건수(선택 필터 반영) — 페이지네이션용.
|
||||||
const total = unwrap<{ total?: number }>(quotationsQuery.data)?.total ?? 0;
|
const total = quotationsQuery.data?.total ?? 0;
|
||||||
|
|
||||||
// 협상카드 카탈로그는 서버(orval)에서 읽어 단계 3/3 카드 선택지로 쓴다.
|
// 협상카드 카탈로그는 서버(orval)에서 읽어 단계 3/3 카드 선택지로 쓴다.
|
||||||
const cards = (unwrap<{ cards?: CardData[] }>(cardsQuery.data)?.cards ?? []).map(mapCardData);
|
const cards = (cardsQuery.data?.cards ?? []).map(mapCardData);
|
||||||
|
|
||||||
// 협상 강제중단 → 서버 stop_quotation 호출(상태 '견적마감'으로 영속). 성공 시 목록 무효화로 서버값 재동기화.
|
// 견적 마감 → 서버 stop_quotation 호출(상태 '견적마감'으로 영속 + 협상생성 세션은 미참여로 전이).
|
||||||
const stopNegotiation = async (id: string, name: string) => {
|
// 성공 시 목록 무효화로 서버값 재동기화.
|
||||||
if (!(await confirm({ title: '협상 강제중단', description: `현재 입찰 중인 [${name}] 단가 협상 절차를 즉시 조기 중단(강제종료)하시겠습니까?`, confirmText: '중단', destructive: true }))) return;
|
const closeQuotation = async (id: string, name: string) => {
|
||||||
|
if (!(await confirm({ title: '견적 마감', description: `[${name}] 견적을 마감하시겠습니까? 마감하면 진행 중인 협상이 종료되고 되돌릴 수 없습니다.`, confirmText: '마감', destructive: true }))) return;
|
||||||
// 낙관적 갱신 — 서버가 CLOSED 로 바꾸므로 화면도 '견적마감'으로 선반영.
|
// 낙관적 갱신 — 서버가 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(
|
stopQuotationMutation.mutate(
|
||||||
{ qtId: id },
|
{ qtId: id },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
invalidateQuotations();
|
invalidateQuotations();
|
||||||
showToast(`[${name}] 협상이 중단되어 '견적마감' 처리되었습니다.`, 'info');
|
// 열려있는 상세 Sheet 도 즉시 동기화(단건 견적 상태 + 세션 상태 재조회).
|
||||||
|
queryClient.invalidateQueries({ queryKey: getGetQuotationQueryKey(id) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: getGetQuotationSessionsQueryKey(id) });
|
||||||
|
showToast(`[${name}] 견적이 마감되었습니다.`, 'info');
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
invalidateQuotations(); // 실패 시 서버 진짜값으로 롤백
|
invalidateQuotations(); // 실패 시 서버 진짜값으로 롤백
|
||||||
showToast('견적 중단에 실패했습니다. 잠시 후 다시 시도해 주세요.', 'error');
|
showToast('견적 마감에 실패했습니다. 잠시 후 다시 시도해 주세요.', 'error');
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -166,7 +168,7 @@ export function useQuotations(params: ListQuotationsParams) {
|
|||||||
const payload: ReqCreateQuotation = {
|
const payload: ReqCreateQuotation = {
|
||||||
qt_setting_id: input.settingId,
|
qt_setting_id: input.settingId,
|
||||||
name: input.title,
|
name: input.title,
|
||||||
type: input.type === 'RE_ESTIMATE' ? 2 : 1,
|
type: input.type,
|
||||||
end_time: new Date(input.dueDate).toISOString(),
|
end_time: new Date(input.dueDate).toISOString(),
|
||||||
item_ids: [input.productId],
|
item_ids: [input.productId],
|
||||||
supplier_ids: input.partnerIds,
|
supplier_ids: input.partnerIds,
|
||||||
@ -200,7 +202,7 @@ export function useQuotations(params: ListQuotationsParams) {
|
|||||||
quotations,
|
quotations,
|
||||||
total,
|
total,
|
||||||
quotationSettings,
|
quotationSettings,
|
||||||
stopNegotiation,
|
closeQuotation,
|
||||||
addSetting,
|
addSetting,
|
||||||
deleteSetting,
|
deleteSetting,
|
||||||
createQuotation,
|
createQuotation,
|
||||||
|
|||||||
@ -4,39 +4,48 @@ import type { QuotationSettingData } from '@/api/generated/model/quotationSettin
|
|||||||
import type { QuotationData } from '@/api/generated/model/quotationData';
|
import type { QuotationData } from '@/api/generated/model/quotationData';
|
||||||
import type { SessionData } from '@/api/generated/model/sessionData';
|
import type { SessionData } from '@/api/generated/model/sessionData';
|
||||||
import type { QuotationCardData } from '@/api/generated/model/quotationCardData';
|
import type { QuotationCardData } from '@/api/generated/model/quotationCardData';
|
||||||
import type {
|
import { QuotationType, QuotationStatus, SessionStatus, CardType } from '@/api/generated/model';
|
||||||
Product,
|
import { DELIVERY_TYPE_LABEL } from '@/lib/enumLabels';
|
||||||
Partner,
|
import { toMinPrice } from '@/features/products/types';
|
||||||
QuotationSetting,
|
import type { Product, Partner, NegotiationCard } from '@/types';
|
||||||
Estimate,
|
|
||||||
ChatSession,
|
|
||||||
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 모델 매퍼 ─────────────────────────────────────────────
|
// ── 서버 응답 → 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 {
|
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 {
|
export function mapSupplier(sp: SupplierData): Partner {
|
||||||
return {
|
return {
|
||||||
supplier_id: sp.supplier_id,
|
...sp,
|
||||||
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,
|
|
||||||
id: sp.supplier_id,
|
id: sp.supplier_id,
|
||||||
managerName: sp.manager_name || '',
|
managerName: sp.manager_name || '',
|
||||||
managerEmail: sp.manager_email || '',
|
managerEmail: sp.manager_email || '',
|
||||||
@ -66,8 +75,8 @@ export function mapQuotation(q: QuotationData): Estimate {
|
|||||||
...(q as unknown as Partial<Estimate>),
|
...(q as unknown as Partial<Estimate>),
|
||||||
id: q.qt_id,
|
id: q.qt_id,
|
||||||
title: q.name,
|
title: q.name,
|
||||||
type: normalizeQuotationType(q.type),
|
type: q.type,
|
||||||
status: normalizeQuotationStatus(q.status) || String(q.status ?? ''),
|
status: q.status,
|
||||||
settingApplied: q.qt_setting_id, // 드로어 견적세팅 카드가 qt_setting_id 로 매칭
|
settingApplied: q.qt_setting_id, // 드로어 견적세팅 카드가 qt_setting_id 로 매칭
|
||||||
productId: q.item_id ?? undefined, // 서버 목록 조인(세션 대표 상품). products 목록과 id 매칭용
|
productId: q.item_id ?? undefined, // 서버 목록 조인(세션 대표 상품). products 목록과 id 매칭용
|
||||||
productName: q.item_name ?? undefined, // products 목록에 없을 때 표기 폴백
|
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())}`;
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 견적상태 정규화(영문 enum / 한글 DDL 혼용 대응) ──────────────────────
|
|
||||||
|
|
||||||
export type QtStatusKey = '견적생성' | '견적진행중' | '견적마감' | '협상보류';
|
export type QtStatusKey = '견적생성' | '견적진행중' | '견적마감' | '협상보류';
|
||||||
|
|
||||||
export const QUOTATION_STATUS_FILTERS: QtStatusKey[] = [
|
export const QUOTATION_STATUS_LABEL: Record<QuotationStatus, QtStatusKey> = {
|
||||||
'견적생성',
|
[QuotationStatus.CREATED]: '견적생성',
|
||||||
'견적진행중',
|
[QuotationStatus.ACTIVE]: '견적진행중',
|
||||||
'견적마감',
|
[QuotationStatus.CLOSED]: '견적마감',
|
||||||
'협상보류',
|
[QuotationStatus.ON_HOLD]: '협상보류',
|
||||||
];
|
|
||||||
|
|
||||||
// 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_TYPE_CODE: Record<string, number> = {
|
export const quotationStatusLabel = (s?: number | null): string =>
|
||||||
RE_NEGOTIATION: 1,
|
s != null ? QUOTATION_STATUS_LABEL[s as QuotationStatus] ?? String(s) : '';
|
||||||
RE_ESTIMATE: 2,
|
|
||||||
|
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;
|
supplier_name: string;
|
||||||
item_id: string;
|
item_id: string;
|
||||||
item_name: string;
|
item_name: string;
|
||||||
status: string;
|
status: number;
|
||||||
target_price: number;
|
target_price: number;
|
||||||
bid_price: number | null;
|
bid_price: number | null;
|
||||||
bid_at: string;
|
bid_at: string;
|
||||||
@ -175,50 +157,16 @@ export type SessionView = {
|
|||||||
|
|
||||||
export type QuotationCardView = {
|
export type QuotationCardView = {
|
||||||
session_card_id: string;
|
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;
|
card_name: string;
|
||||||
type: string;
|
type: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 견적당 1개의 입찰 요약(bid_summary). est-1~3은 데모용 정적 매핑, 그 외는 견적 데이터에서 산출.
|
|
||||||
export function buildBidSummary(q: QuotationData, partners: Partner[]): BidSummaryView {
|
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;
|
const winnerId = q.preferred_sp_id ?? null;
|
||||||
return {
|
return {
|
||||||
bid_summary_id: `bid-summary-${q.qt_id}`,
|
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,
|
qt_iteration: q.iteration ?? 1,
|
||||||
has_preferred: !!winnerId,
|
has_preferred: !!winnerId,
|
||||||
preferred_sp_id: 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 / 사용 카드) ──────────────
|
// ── 서버 연동 매퍼(negotiation.sessions / chats / 사용 카드) ──────────────
|
||||||
|
|
||||||
// 세션상태 코드→라벨. 서버 /v1/enums(session_status) · SHARED_ENUMS.md 5-state 와 동일해야 한다.
|
// 세션상태 코드→라벨. 서버 /v1/enums(session_status) · SHARED_ENUMS.md 5-state 와 동일해야 한다.
|
||||||
// (정본은 서버 enum — 여기 값은 그걸 미러링한 것이며 드리프트 시 서버 기준으로 맞춘다.)
|
// (정본은 서버 enum — 여기 값은 그걸 미러링한 것이며 드리프트 시 서버 기준으로 맞춘다.)
|
||||||
export const SESSION_STATUS_LABEL: Record<number, string> = {
|
export const SESSION_STATUS_LABEL: Record<SessionStatus, string> = {
|
||||||
1: '협상생성',
|
[SessionStatus.CREATED]: '협상생성',
|
||||||
2: '협상중',
|
[SessionStatus.IN_PROGRESS]: '협상중',
|
||||||
3: '협상완료',
|
[SessionStatus.DONE]: '협상완료',
|
||||||
4: '미참여',
|
[SessionStatus.NOT_PARTICIPATED]: '미참여',
|
||||||
5: '협상거부',
|
[SessionStatus.REJECTED]: '협상거부',
|
||||||
};
|
};
|
||||||
// 코드→라벨 단일 진입점. 미정의 코드는 코드 문자열 그대로.
|
// 코드→라벨 단일 진입점. 미정의 코드는 코드 문자열 그대로.
|
||||||
export const sessionStatusLabel = (code?: number | null): string =>
|
export const sessionStatusLabel = (code?: number | null): string =>
|
||||||
(code != null ? SESSION_STATUS_LABEL[code] : undefined) ?? String(code ?? '');
|
(code != null ? SESSION_STATUS_LABEL[code as SessionStatus] : undefined) ?? String(code ?? '');
|
||||||
const DELIVERY_TYPE_LABEL: Record<number, string> = { 1: '협력사배송', 2: '지정택배배송', 3: '픽업배송' };
|
|
||||||
|
|
||||||
// ISO 문자열 → 'YYYY-MM-DD HH:mm'. 빈 값/파싱 실패는 '-'.
|
// ISO 문자열 → 'YYYY-MM-DD HH:mm'. 빈 값/파싱 실패는 '-'.
|
||||||
export function fmtDateTime(s?: string | null): string {
|
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,
|
supplier_name: supplier?.name || sd.supplier_id,
|
||||||
item_id: sd.item_id,
|
item_id: sd.item_id,
|
||||||
item_name: product?.name || '부품',
|
item_name: product?.name || '부품',
|
||||||
status: SESSION_STATUS_LABEL[sd.status] || String(sd.status),
|
status: sd.status,
|
||||||
target_price: sd.target_price ?? 0,
|
target_price: sd.target_price ?? 0,
|
||||||
bid_price: sd.bid_price ?? null,
|
bid_price: sd.bid_price ?? null,
|
||||||
bid_at: sd.bid_at ? fmtDateTime(sd.bid_at) : '-',
|
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,
|
session_card_id: c.session_card_id,
|
||||||
card_id: c.nego_card_id ?? c.wild_card_id ?? null,
|
card_id: c.nego_card_id ?? c.wild_card_id ?? null,
|
||||||
card_name: c.name || '-',
|
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 { QuotationDetailSheet } from '@/features/quotations/components/QuotationDetailSheet';
|
||||||
import { QuotationCreateModal } from '@/features/quotations/components/QuotationCreateModal';
|
import { QuotationCreateModal } from '@/features/quotations/components/QuotationCreateModal';
|
||||||
import { QuotationSettingsModal } from '@/features/quotations/components/QuotationSettingsModal';
|
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';
|
import type { ListQuotationsParams } from '@/api/generated/model/listQuotationsParams';
|
||||||
|
|
||||||
export default function QuotationPage() {
|
export default function QuotationPage() {
|
||||||
@ -19,10 +19,12 @@ export default function QuotationPage() {
|
|||||||
const list = useServerList({ pageSize: 10, initialFilters: { status: 'ALL', type: 'ALL' } });
|
const list = useServerList({ pageSize: 10, initialFilters: { status: 'ALL', type: 'ALL' } });
|
||||||
const statusFilter = list.filters.status;
|
const statusFilter = list.filters.status;
|
||||||
const typeFilter = list.filters.type;
|
const typeFilter = list.filters.type;
|
||||||
|
const statusOptions = QUOTATION_STATUS_OPTIONS;
|
||||||
|
const typeOptions = QUOTATION_TYPE_OPTIONS;
|
||||||
const params: ListQuotationsParams = {
|
const params: ListQuotationsParams = {
|
||||||
search: list.debouncedSearch || undefined,
|
search: list.debouncedSearch || undefined,
|
||||||
status: statusFilter !== 'ALL' ? String(QUOTATION_STATUS_CODE[statusFilter] ?? '') : undefined,
|
status: statusFilter !== 'ALL' ? statusFilter : undefined,
|
||||||
type: typeFilter !== 'ALL' ? String(QUOTATION_TYPE_CODE[typeFilter] ?? '') : undefined,
|
type: typeFilter !== 'ALL' ? typeFilter : undefined,
|
||||||
page: list.page,
|
page: list.page,
|
||||||
size: list.pageSize,
|
size: list.pageSize,
|
||||||
};
|
};
|
||||||
@ -34,7 +36,7 @@ export default function QuotationPage() {
|
|||||||
quotations,
|
quotations,
|
||||||
total,
|
total,
|
||||||
quotationSettings,
|
quotationSettings,
|
||||||
stopNegotiation,
|
closeQuotation,
|
||||||
addSetting,
|
addSetting,
|
||||||
deleteSetting,
|
deleteSetting,
|
||||||
createQuotation,
|
createQuotation,
|
||||||
@ -67,10 +69,10 @@ export default function QuotationPage() {
|
|||||||
<button
|
<button
|
||||||
id="quotation-create-btn"
|
id="quotation-create-btn"
|
||||||
onClick={() => overlay.open('create')}
|
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} />
|
<Plus size={15} />
|
||||||
<span>신규 협상견적 등록</span>
|
<span>신규 견적 등록</span>
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@ -86,12 +88,18 @@ export default function QuotationPage() {
|
|||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<Select value={statusFilter} onValueChange={(v) => list.setFilter('status', v as string)}>
|
<Select value={statusFilter} onValueChange={(v) => list.setFilter('status', v as string)}>
|
||||||
<SelectTrigger id="quotation-status-filter" className="font-bold">
|
<SelectTrigger id="quotation-status-filter" className="font-bold">
|
||||||
<SelectValue />
|
<SelectValue>
|
||||||
|
{(value) =>
|
||||||
|
value === 'ALL'
|
||||||
|
? '전체 견적상태'
|
||||||
|
: statusOptions.find((o) => String(o.value) === value)?.label ?? ''
|
||||||
|
}
|
||||||
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="ALL">전체 견적상태</SelectItem>
|
<SelectItem value="ALL">전체 견적상태</SelectItem>
|
||||||
{QUOTATION_STATUS_FILTERS.map((s) => (
|
{statusOptions.map((o) => (
|
||||||
<SelectItem key={s} value={s}>{s}</SelectItem>
|
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@ -99,13 +107,18 @@ export default function QuotationPage() {
|
|||||||
<Select value={typeFilter} onValueChange={(v) => list.setFilter('type', v as string)}>
|
<Select value={typeFilter} onValueChange={(v) => list.setFilter('type', v as string)}>
|
||||||
<SelectTrigger id="quotation-type-filter">
|
<SelectTrigger id="quotation-type-filter">
|
||||||
<SelectValue>
|
<SelectValue>
|
||||||
{(value) => (value === 'ALL' ? '전체 유형' : value === 'RE_NEGOTIATION' ? '재협상' : '재견적')}
|
{(value) =>
|
||||||
|
value === 'ALL'
|
||||||
|
? '전체 유형'
|
||||||
|
: typeOptions.find((o) => String(o.value) === value)?.label ?? ''
|
||||||
|
}
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="ALL">전체 유형</SelectItem>
|
<SelectItem value="ALL">전체 유형</SelectItem>
|
||||||
<SelectItem value="RE_NEGOTIATION">재협상</SelectItem>
|
{typeOptions.map((o) => (
|
||||||
<SelectItem value="RE_ESTIMATE">재견적</SelectItem>
|
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@ -132,7 +145,7 @@ export default function QuotationPage() {
|
|||||||
<QuotationDetailSheet
|
<QuotationDetailSheet
|
||||||
key={activeQuotation.qt_id}
|
key={activeQuotation.qt_id}
|
||||||
quotation={activeQuotation}
|
quotation={activeQuotation}
|
||||||
onStop={stopNegotiation}
|
onCloseQuotation={closeQuotation}
|
||||||
onClose={overlay.close}
|
onClose={overlay.close}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user