This commit is contained in:
parent
88edcfed63
commit
5d0fbbcf6a
@ -6,7 +6,7 @@ from sqlalchemy import asc, desc, select, update
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
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 chats, items, sessions
|
from common.database.model.models import chats, items, sessions, nego_cards
|
||||||
from common.enums import ErrorType, SessionStatus
|
from common.enums import ErrorType, SessionStatus
|
||||||
from common.logger import LOG
|
from common.logger import LOG
|
||||||
|
|
||||||
@ -47,6 +47,10 @@ class IChatCRUD(ABC):
|
|||||||
async def update_last_offer_price(self, cdb: AsyncSession, session_id, price: int) -> ErrorType:
|
async def update_last_offer_price(self, cdb: AsyncSession, session_id, price: int) -> ErrorType:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_nego_card_id_by_number(self, cdb: AsyncSession, number: str):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ChatCRUD(IChatCRUD):
|
class ChatCRUD(IChatCRUD):
|
||||||
async def list_by_session(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, list]:
|
async def list_by_session(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, list]:
|
||||||
@ -112,6 +116,18 @@ class ChatCRUD(IChatCRUD):
|
|||||||
LOG.e_no_callstack(ex)
|
LOG.e_no_callstack(ex)
|
||||||
return ErrorType.DB_RUN_FAILED, None
|
return ErrorType.DB_RUN_FAILED, None
|
||||||
|
|
||||||
|
async def get_nego_card_id_by_number(self, cdb: AsyncSession, number: str):
|
||||||
|
# 협상카드 번호(agent turn.card_id) → nego_card_id(UUID). 없으면 None. 카드 사용 로그(chats.card_id) 저장용.
|
||||||
|
try:
|
||||||
|
query = select(nego_cards.nego_card_id).where(nego_cards.number == number, nego_cards.deleted == False).limit(1) # noqa: E712
|
||||||
|
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query, f"get_nego_card_id_by_number({number}) failed.")
|
||||||
|
if err_type != ErrorType.SUCCESS or not row_list:
|
||||||
|
return None
|
||||||
|
return row_list[0]
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(ex)
|
||||||
|
return None
|
||||||
|
|
||||||
async def finalize_session(
|
async def finalize_session(
|
||||||
self, cdb: AsyncSession, session_id, status: int,
|
self, cdb: AsyncSession, session_id, status: int,
|
||||||
bid_price: Optional[int] = None, reject_reason: Optional[str] = None, reject_price: Optional[int] = None,
|
bid_price: Optional[int] = None, reject_reason: Optional[str] = None, reject_price: Optional[int] = None,
|
||||||
|
|||||||
@ -41,6 +41,10 @@ class ISessionCRUD(ABC):
|
|||||||
async def update_session_reject(self, cdb: AsyncSession, session_id, status: int, reject_reason: str) -> ErrorType:
|
async def update_session_reject(self, cdb: AsyncSession, session_id, status: int, reject_reason: str) -> ErrorType:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def update_session_custom(self, cdb: AsyncSession, session_id, supplier_id, custom: dict) -> ErrorType:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class SessionCRUD(ISessionCRUD):
|
class SessionCRUD(ISessionCRUD):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -86,6 +90,7 @@ class SessionCRUD(ISessionCRUD):
|
|||||||
items.name,
|
items.name,
|
||||||
items.model_name,
|
items.model_name,
|
||||||
items.manufacturer,
|
items.manufacturer,
|
||||||
|
sessions.custom,
|
||||||
)
|
)
|
||||||
.join(items, items.item_id == sessions.item_id)
|
.join(items, items.item_id == sessions.item_id)
|
||||||
.join(quotations, quotations.qt_id == sessions.quotation_id)
|
.join(quotations, quotations.qt_id == sessions.quotation_id)
|
||||||
@ -173,3 +178,16 @@ class SessionCRUD(ISessionCRUD):
|
|||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
LOG.e_no_callstack(ex)
|
LOG.e_no_callstack(ex)
|
||||||
return ErrorType.DB_RUN_FAILED
|
return ErrorType.DB_RUN_FAILED
|
||||||
|
|
||||||
|
async def update_session_custom(self, cdb: AsyncSession, session_id, supplier_id, custom: dict) -> ErrorType:
|
||||||
|
# 협상완료 부가정보(sessions.custom) 저장. 본인 공급사 세션만(supplier_id 가드).
|
||||||
|
try:
|
||||||
|
query = (
|
||||||
|
update(sessions)
|
||||||
|
.where(sessions.session_id == session_id, sessions.supplier_id == supplier_id)
|
||||||
|
.values(custom=custom)
|
||||||
|
)
|
||||||
|
return await DB_SESSION_MNG.add(cdb, query)
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(ex)
|
||||||
|
return ErrorType.DB_RUN_FAILED
|
||||||
|
|||||||
@ -5,7 +5,7 @@ from sqlalchemy import delete, select, update
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
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 supplier_user_tokens, supplier_users, suppliers
|
from common.database.model.models import supplier_user_tokens, supplier_users, suppliers, companies
|
||||||
from common.enums import ErrorType, TokenType
|
from common.enums import ErrorType, TokenType
|
||||||
from common.logger import LOG
|
from common.logger import LOG
|
||||||
from common.utils.gtime import GTime
|
from common.utils.gtime import GTime
|
||||||
@ -28,6 +28,10 @@ class IUserCRUD(ABC):
|
|||||||
async def get_supplier_name(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, str]:
|
async def get_supplier_name(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, str]:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_company_settings(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, dict]:
|
||||||
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def is_account(self, cdb: AsyncSession, login_id: str) -> ErrorType:
|
async def is_account(self, cdb: AsyncSession, login_id: str) -> ErrorType:
|
||||||
pass
|
pass
|
||||||
@ -117,6 +121,25 @@ class UserCRUD(IUserCRUD):
|
|||||||
LOG.e_no_callstack(ex)
|
LOG.e_no_callstack(ex)
|
||||||
return ErrorType.DB_RUN_FAILED, None
|
return ErrorType.DB_RUN_FAILED, None
|
||||||
|
|
||||||
|
async def get_company_settings(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, dict]:
|
||||||
|
"""공급사 소속 회사 설정(companies.settings) 전체. 브랜딩·협상완료 필드 등이 들어있다. 미설정이면 빈 dict."""
|
||||||
|
try:
|
||||||
|
query = (
|
||||||
|
select(companies.settings)
|
||||||
|
.join(suppliers, suppliers.company_id == companies.company_id)
|
||||||
|
.where(suppliers.supplier_id == supplier_id, suppliers.deleted == False, companies.deleted == False) # noqa: E712
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query, f"get_company_settings(supplier_id:{supplier_id}) failed.")
|
||||||
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
return err_type, {}
|
||||||
|
settings = row_list[0] if row_list else None
|
||||||
|
return ErrorType.SUCCESS, settings or {}
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(ex)
|
||||||
|
return ErrorType.DB_RUN_FAILED, {}
|
||||||
|
|
||||||
|
|
||||||
async def is_account(self, cdb: AsyncSession, login_id: str) -> ErrorType:
|
async def is_account(self, cdb: AsyncSession, login_id: str) -> ErrorType:
|
||||||
try:
|
try:
|
||||||
query = (
|
query = (
|
||||||
|
|||||||
@ -48,6 +48,8 @@ class Res_Me(Res_WebPacketProtocol):
|
|||||||
supplier_id: str = Field("", description="소속 공급사 uuid")
|
supplier_id: str = Field("", description="소속 공급사 uuid")
|
||||||
supplier_name: str = Field("", description="공급사명")
|
supplier_name: str = Field("", description="공급사명")
|
||||||
role: int = Field(0, description="권한 코드 1=user, 2=manager (UserRole)")
|
role: int = Field(0, description="권한 코드 1=user, 2=manager (UserRole)")
|
||||||
|
branding: dict = Field(default_factory=dict, description="소속 회사 브랜딩(companies.settings.branding). 서비스명/로고/색")
|
||||||
|
session_fields: list = Field(default_factory=list, description="협상완료 부가정보 필드 정의(companies.settings.session_fields). 공급사가 타결 후 입력")
|
||||||
|
|
||||||
|
|
||||||
class Res_Logout(Res_WebPacketProtocol):
|
class Res_Logout(Res_WebPacketProtocol):
|
||||||
|
|||||||
@ -77,6 +77,7 @@ class Res_ChatInit(Res_WebPacketProtocol):
|
|||||||
item_min_order_quantity: str = Field("", description="최소 주문 수량")
|
item_min_order_quantity: str = Field("", description="최소 주문 수량")
|
||||||
item_vat_yn: Optional[bool] = Field(None, description="VAT 포함 여부(미설정 시 null)")
|
item_vat_yn: Optional[bool] = Field(None, description="VAT 포함 여부(미설정 시 null)")
|
||||||
item_delivery_fee_yn: Optional[bool] = Field(None, description="배송비 포함 여부(미설정 시 null)")
|
item_delivery_fee_yn: Optional[bool] = Field(None, description="배송비 포함 여부(미설정 시 null)")
|
||||||
|
custom: dict = Field(default_factory=dict, description="협상완료 부가정보 기존 입력값(sessions.custom). 재진입 시 폼 프리필용")
|
||||||
|
|
||||||
|
|
||||||
# 대화 히스토리(재진입 복원)
|
# 대화 히스토리(재진입 복원)
|
||||||
|
|||||||
@ -14,6 +14,7 @@ class ListItem(WebPacketProtocol):
|
|||||||
item_name: str = Field("", description="상품명")
|
item_name: str = Field("", description="상품명")
|
||||||
model_name: str = Field("", description="모델명")
|
model_name: str = Field("", description="모델명")
|
||||||
maker_name: str = Field("", description="제조사")
|
maker_name: str = Field("", description="제조사")
|
||||||
|
custom: dict = Field(default_factory=dict, description="협상완료 부가정보 값(sessions.custom). 미입력이면 빈 dict")
|
||||||
|
|
||||||
|
|
||||||
class Res_SessionList(Res_WebPacketProtocol):
|
class Res_SessionList(Res_WebPacketProtocol):
|
||||||
@ -33,3 +34,11 @@ class Req_Reject(WebPacketProtocol):
|
|||||||
|
|
||||||
class Res_Reject(Res_WebPacketProtocol):
|
class Res_Reject(Res_WebPacketProtocol):
|
||||||
session_id: str = Field("", description="거부 처리된 세션 uuid")
|
session_id: str = Field("", description="거부 처리된 세션 uuid")
|
||||||
|
|
||||||
|
|
||||||
|
class Req_ExtraInfo(WebPacketProtocol):
|
||||||
|
custom: dict = Field(default_factory=dict, description="협상완료 부가정보 값 {key: value} (회사 정의 session_fields 대로)")
|
||||||
|
|
||||||
|
|
||||||
|
class Res_ExtraInfo(Res_WebPacketProtocol):
|
||||||
|
session_id: str = Field("", description="부가정보 저장된 세션 uuid")
|
||||||
|
|||||||
@ -6,7 +6,7 @@ from fastapi.security import HTTPAuthorizationCredentials
|
|||||||
from common.models.gmodel import UserInfo
|
from common.models.gmodel import UserInfo
|
||||||
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse, security
|
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse, security
|
||||||
from services.negotiation_service import NegotiationService
|
from services.negotiation_service import NegotiationService
|
||||||
from .protocol import Req_Reject, Res_Participate, Res_Reject, Res_SessionList
|
from .protocol import Req_ExtraInfo, Req_Reject, Res_ExtraInfo, Res_Participate, Res_Reject, Res_SessionList
|
||||||
|
|
||||||
router = APIRouter(prefix="/v1/negotiation", tags=["Negotiation"], responses={404: {"description": "Not found"}})
|
router = APIRouter(prefix="/v1/negotiation", tags=["Negotiation"], responses={404: {"description": "Not found"}})
|
||||||
|
|
||||||
@ -61,3 +61,19 @@ async def reject(
|
|||||||
service: NegotiationService = Depends(),
|
service: NegotiationService = Depends(),
|
||||||
):
|
):
|
||||||
return RemoveNoneResponse(await service.reject(user_info, credentials.credentials, session_id, req.reject_reason))
|
return RemoveNoneResponse(await service.reject(user_info, credentials.credentials, session_id, req.reject_reason))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
path="/sessions/{session_id}/extra-info",
|
||||||
|
response_model=Res_ExtraInfo,
|
||||||
|
summary="협상완료 부가정보 저장",
|
||||||
|
description="협상 타결(완료) 세션에 부가정보(표준납기/MOQ/발주배수/배송유형 등, 회사 정의 session_fields)를 저장한다. 본인 공급사의 완료 세션만 허용.",
|
||||||
|
)
|
||||||
|
async def save_extra_info(
|
||||||
|
session_id: str = Path(description="대상 협상 세션 uuid"),
|
||||||
|
req: Req_ExtraInfo = ...,
|
||||||
|
user_info: UserInfo = Depends(IsValidAccessToken),
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
|
service: NegotiationService = Depends(),
|
||||||
|
):
|
||||||
|
return RemoveNoneResponse(await service.save_extra_info(user_info, credentials.credentials, session_id, req))
|
||||||
|
|||||||
@ -250,6 +250,15 @@ class AuthService:
|
|||||||
res.supplier_id = info.supplier_id
|
res.supplier_id = info.supplier_id
|
||||||
res.supplier_name = info.supplier_name
|
res.supplier_name = info.supplier_name
|
||||||
res.role = info.role
|
res.role = info.role
|
||||||
|
# 소속 회사 설정(companies.settings) — 로고/서비스명(branding) + 협상완료 부가필드(session_fields). 실패해도 기본값.
|
||||||
|
_e, settings = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
suppliers.DBType(),
|
||||||
|
DBWRType.DB_READ.value,
|
||||||
|
lambda s: self.user_crud.get_company_settings(s, uuid.UUID(info.supplier_id)),
|
||||||
|
)
|
||||||
|
settings = settings or {}
|
||||||
|
res.branding = settings.get("branding") or {}
|
||||||
|
res.session_fields = settings.get("session_fields") or []
|
||||||
return res
|
return res
|
||||||
|
|
||||||
async def popup_status(self, user_info: UserInfo, access_token: str) -> Res_PopupStatus:
|
async def popup_status(self, user_info: UserInfo, access_token: str) -> Res_PopupStatus:
|
||||||
|
|||||||
@ -103,13 +103,18 @@ class ChatService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_bot_chat(sess, seq: int, turn, bot_chat_type: Optional[str] = None, summary: Optional[dict] = None) -> chats:
|
def _build_bot_chat(sess, seq: int, turn, bot_chat_type: Optional[str] = None, summary: Optional[dict] = None, nego_card_uuid=None) -> chats:
|
||||||
# bot_chat_type/summary 도 meta 에 영속화 → 히스토리 복원 시 폼 재현. indicator_value 는 전용 컬럼에도 적재.
|
# bot_chat_type/summary 도 meta 에 영속화 → 히스토리 복원 시 폼 재현. indicator_value 는 전용 컬럼에도 적재.
|
||||||
|
# nego_card_uuid: turn.card_id(번호)를 UUID 로 변환한 값(nego 카드). 있으면 chats.card_id/card_type/card_used_yn 컬럼에 적재
|
||||||
|
# → negodata 가 이 컬럼으로 카드 사용/효과를 조인한다. (wild 카드는 agent 가 card_id 미제공 — 별도 작업)
|
||||||
return chats(
|
return chats(
|
||||||
chat_id=uuid.uuid4(), session_id=sess.session_id, seq=seq,
|
chat_id=uuid.uuid4(), session_id=sess.session_id, seq=seq,
|
||||||
sender=ChatSender.BOT.value,
|
sender=ChatSender.BOT.value,
|
||||||
target_price=int(sess.target_price or 0),
|
target_price=int(sess.target_price or 0),
|
||||||
indicator_value=turn.indicator_value,
|
indicator_value=turn.indicator_value,
|
||||||
|
card_id=nego_card_uuid,
|
||||||
|
card_type=1 if nego_card_uuid else None, # CardType: 1=nego (wild=2 는 별도)
|
||||||
|
card_used_yn=True if nego_card_uuid else None,
|
||||||
meta={
|
meta={
|
||||||
"script": turn.script, "step": turn.step, "client_step": turn.client_step,
|
"script": turn.script, "step": turn.step, "client_step": turn.client_step,
|
||||||
"input_mode": turn.input_mode, "input_options": turn.input_options,
|
"input_mode": turn.input_mode, "input_options": turn.input_options,
|
||||||
@ -221,6 +226,7 @@ class ChatService:
|
|||||||
res.item_min_order_quantity = item.moq or ""
|
res.item_min_order_quantity = item.moq or ""
|
||||||
res.item_vat_yn = item.vat_yn
|
res.item_vat_yn = item.vat_yn
|
||||||
res.item_delivery_fee_yn = item.delivery_fee_yn
|
res.item_delivery_fee_yn = item.delivery_fee_yn
|
||||||
|
res.custom = sess.custom or {}
|
||||||
return res
|
return res
|
||||||
|
|
||||||
# ---- messages -------------------------------------------------------
|
# ---- messages -------------------------------------------------------
|
||||||
@ -371,8 +377,16 @@ class ChatService:
|
|||||||
# 유저 미입력 가격 타결 케이스 — 마지막 유저 제시가와 다를 수 있다).
|
# 유저 미입력 가격 타결 케이스 — 마지막 유저 제시가와 다를 수 있다).
|
||||||
summary = await self._build_summary(sess, quote, item, final_price, turn.settled_price or last_price)
|
summary = await self._build_summary(sess, quote, item, final_price, turn.settled_price or last_price)
|
||||||
|
|
||||||
|
# nego 카드 번호(turn.card_id) → UUID 변환. 카드 사용 로그(chats.card_id/type/used)를 negodata 조인용으로 남긴다.
|
||||||
|
nego_card_uuid = None
|
||||||
|
if turn.card_id:
|
||||||
|
nego_card_uuid = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
chats.DBType(), DBWRType.DB_READ.value,
|
||||||
|
lambda s: self.chat_crud.get_nego_card_id_by_number(s, str(turn.card_id)),
|
||||||
|
)
|
||||||
|
|
||||||
# 봇 메시지 + 종료 시 확정(성공=DONE+입찰가 / 실패=REJECTED+거부사유·제시가). 한 트랜잭션.
|
# 봇 메시지 + 종료 시 확정(성공=DONE+입찰가 / 실패=REJECTED+거부사유·제시가). 한 트랜잭션.
|
||||||
bot_msg = self._build_bot_chat(sess, seq=max_seq + 2, turn=turn, bot_chat_type=bot_chat_type, summary=summary)
|
bot_msg = self._build_bot_chat(sess, seq=max_seq + 2, turn=turn, bot_chat_type=bot_chat_type, summary=summary, nego_card_uuid=nego_card_uuid)
|
||||||
funcs = [lambda s: self.chat_crud.insert_message(s, bot_msg)]
|
funcs = [lambda s: self.chat_crud.insert_message(s, bot_msg)]
|
||||||
# 가격 입력 턴 → 마지막 제시가를 봇 메시지 저장과 같은 트랜잭션으로 갱신.
|
# 가격 입력 턴 → 마지막 제시가를 봇 메시지 저장과 같은 트랜잭션으로 갱신.
|
||||||
# 앵커링 표본 판정의 "가격 흔적"(가격을 써낸 협상만 집계 — 중간 이탈해도 실패로 측정 가능).
|
# 앵커링 표본 판정의 "가격 흔적"(가격을 써낸 협상만 집계 — 중간 이탈해도 실패로 측정 가능).
|
||||||
|
|||||||
@ -8,7 +8,7 @@ from common.database.model.models import sessions
|
|||||||
from common.enums import DBWRType, ErrorType, QuotationStatus, SessionStatus
|
from common.enums import DBWRType, ErrorType, QuotationStatus, SessionStatus
|
||||||
from common.models.gmodel import UserInfo
|
from common.models.gmodel import UserInfo
|
||||||
from crud.session_crud import ISessionCRUD, SessionCRUD
|
from crud.session_crud import ISessionCRUD, SessionCRUD
|
||||||
from router.v1.negotiation.protocol import ListItem, Res_Participate, Res_Reject, Res_SessionList
|
from router.v1.negotiation.protocol import ListItem, Req_ExtraInfo, Res_ExtraInfo, Res_Participate, Res_Reject, Res_SessionList
|
||||||
from services.auth_service import AuthService
|
from services.auth_service import AuthService
|
||||||
|
|
||||||
|
|
||||||
@ -65,6 +65,7 @@ class NegotiationService:
|
|||||||
item_name=r[6] or "",
|
item_name=r[6] or "",
|
||||||
model_name=r[7] or "",
|
model_name=r[7] or "",
|
||||||
maker_name=r[8] or "",
|
maker_name=r[8] or "",
|
||||||
|
custom=r[9] or {},
|
||||||
)
|
)
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
@ -73,6 +74,53 @@ class NegotiationService:
|
|||||||
res.page_size = page_size
|
res.page_size = page_size
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
async def save_extra_info(self, user_info: UserInfo, access_token: str, session_id_str: str, req: Req_ExtraInfo) -> Res_ExtraInfo:
|
||||||
|
"""협상완료(타결) 부가정보 저장. 견적 마감 여부와 무관하게, 본인 공급사의 '협상완료' 세션에만 허용.
|
||||||
|
|
||||||
|
_load_actionable_session 은 견적마감·마감시간을 막으므로(타결 후엔 마감됐을 수 있음) 쓰지 않고 직접 검증한다.
|
||||||
|
"""
|
||||||
|
res = Res_ExtraInfo()
|
||||||
|
|
||||||
|
# 1) 인증
|
||||||
|
err_type, info = await self.auth.authenticate(user_info, access_token)
|
||||||
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
res.result.SetResult(err_type)
|
||||||
|
return res
|
||||||
|
try:
|
||||||
|
session_id = uuid.UUID(session_id_str)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
res.result.SetResult(ErrorType.NEGO_NOT_FOUND)
|
||||||
|
return res
|
||||||
|
|
||||||
|
# 2) 세션 조회 + 소유 검증
|
||||||
|
err_type, sess = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
sessions.DBType(), DBWRType.DB_READ.value,
|
||||||
|
lambda s: self.session_crud.get_session_by_id(s, session_id),
|
||||||
|
)
|
||||||
|
if err_type != ErrorType.SUCCESS or sess is None:
|
||||||
|
res.result.SetResult(ErrorType.NEGO_NOT_FOUND)
|
||||||
|
return res
|
||||||
|
if str(sess.supplier_id) != info.supplier_id:
|
||||||
|
res.result.SetResult(ErrorType.NEGO_FORBIDDEN)
|
||||||
|
return res
|
||||||
|
|
||||||
|
# 3) 협상완료(타결) 세션만 부가정보 입력 허용
|
||||||
|
if sess.status != SessionStatus.DONE.value:
|
||||||
|
res.result.SetResult(ErrorType.NEGO_NOT_PARTICIPABLE)
|
||||||
|
return res
|
||||||
|
|
||||||
|
# 4) 저장(supplier_id 가드 crud)
|
||||||
|
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||||
|
[sessions.DBType()],
|
||||||
|
[lambda s: self.session_crud.update_session_custom(s, session_id, uuid.UUID(info.supplier_id), req.custom or {})],
|
||||||
|
)
|
||||||
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
res.result.SetResult(err_type)
|
||||||
|
return res
|
||||||
|
|
||||||
|
res.session_id = str(session_id)
|
||||||
|
return res
|
||||||
|
|
||||||
async def _load_actionable_session(self, user_info: UserInfo, access_token: str, session_id_str: str, blocked_statuses: tuple):
|
async def _load_actionable_session(self, user_info: UserInfo, access_token: str, session_id_str: str, blocked_statuses: tuple):
|
||||||
"""참여/거부 공통 전처리: 인증 → 세션/견적 로드 → 소유·상태·견적마감·마감시간 검증.
|
"""참여/거부 공통 전처리: 인증 → 세션/견적 로드 → 소유·상태·견적마감·마감시간 검증.
|
||||||
성공 시 (SUCCESS, sess, quote), 실패 시 (err_type, None, None) 을 반환한다.
|
성공 시 (SUCCESS, sess, quote), 실패 시 (err_type, None, None) 을 반환한다.
|
||||||
|
|||||||
@ -54,6 +54,20 @@ export interface RefreshTokenResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- 내 정보 (GET /v1/auth/me) -------------------------------------------
|
// --- 내 정보 (GET /v1/auth/me) -------------------------------------------
|
||||||
|
export interface Branding {
|
||||||
|
service_name?: string
|
||||||
|
logo_url?: string
|
||||||
|
primary_color?: string
|
||||||
|
email_header?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 협상완료 부가정보 필드 정의(companies.settings.session_fields)
|
||||||
|
export interface SessionField {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
type: 'text' | 'number' | 'boolean'
|
||||||
|
}
|
||||||
|
|
||||||
export interface MeResponse {
|
export interface MeResponse {
|
||||||
result: ApiResult
|
result: ApiResult
|
||||||
su_id: string
|
su_id: string
|
||||||
@ -62,6 +76,8 @@ export interface MeResponse {
|
|||||||
supplier_id: string
|
supplier_id: string
|
||||||
supplier_name: string
|
supplier_name: string
|
||||||
role: number
|
role: number
|
||||||
|
branding?: Branding
|
||||||
|
session_fields?: SessionField[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 로그아웃 -------------------------------------------------------------
|
// --- 로그아웃 -------------------------------------------------------------
|
||||||
@ -94,6 +110,8 @@ export interface AuthUser {
|
|||||||
supplierId: string
|
supplierId: string
|
||||||
supplierName: string
|
supplierName: string
|
||||||
role: number
|
role: number
|
||||||
|
branding: Branding
|
||||||
|
sessionFields: SessionField[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toAuthUser(res: MeResponse): AuthUser {
|
export function toAuthUser(res: MeResponse): AuthUser {
|
||||||
@ -104,5 +122,7 @@ export function toAuthUser(res: MeResponse): AuthUser {
|
|||||||
supplierId: res.supplier_id,
|
supplierId: res.supplier_id,
|
||||||
supplierName: res.supplier_name,
|
supplierName: res.supplier_name,
|
||||||
role: res.role,
|
role: res.role,
|
||||||
|
branding: res.branding ?? {},
|
||||||
|
sessionFields: res.session_fields ?? [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,6 +46,7 @@ export interface ChatInitResponse {
|
|||||||
item_min_order_quantity?: string
|
item_min_order_quantity?: string
|
||||||
item_vat_yn?: boolean
|
item_vat_yn?: boolean
|
||||||
item_delivery_fee_yn?: boolean
|
item_delivery_fee_yn?: boolean
|
||||||
|
custom?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatMessagesResponse {
|
export interface ChatMessagesResponse {
|
||||||
@ -98,6 +99,7 @@ export function mapInit(r: ChatInitResponse): ChatInitData {
|
|||||||
item_delivery_fee_yn:
|
item_delivery_fee_yn:
|
||||||
r.item_delivery_fee_yn == null ? '' : r.item_delivery_fee_yn ? '배송비포함' : '배송비별도',
|
r.item_delivery_fee_yn == null ? '' : r.item_delivery_fee_yn ? '배송비포함' : '배송비별도',
|
||||||
item_min_order_quantity: r.item_min_order_quantity ?? '',
|
item_min_order_quantity: r.item_min_order_quantity ?? '',
|
||||||
|
custom: r.custom ?? {},
|
||||||
item_lead_time: r.item_lead_time ?? '',
|
item_lead_time: r.item_lead_time ?? '',
|
||||||
item_spec: r.item_spec ?? '',
|
item_spec: r.item_spec ?? '',
|
||||||
quotation_memo: r.quotation_memo ?? '',
|
quotation_memo: r.quotation_memo ?? '',
|
||||||
|
|||||||
@ -2,5 +2,5 @@
|
|||||||
export { negotiationApi } from './negotiation.api'
|
export { negotiationApi } from './negotiation.api'
|
||||||
export { negotiationKeys } from './negotiation.keys'
|
export { negotiationKeys } from './negotiation.keys'
|
||||||
export { useSessionListQuery } from './negotiation.queries'
|
export { useSessionListQuery } from './negotiation.queries'
|
||||||
export { useParticipateMutation, useRejectMutation } from './negotiation.mutations'
|
export { useParticipateMutation, useRejectMutation, useSaveExtraInfoMutation } from './negotiation.mutations'
|
||||||
export * from './negotiation.type'
|
export * from './negotiation.type'
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
// 협상 엔드포인트 호출 함수 (순수 HTTP 레이어, React 의존 없음).
|
// 협상 엔드포인트 호출 함수 (순수 HTTP 레이어, React 의존 없음).
|
||||||
import { http } from '@/apis/http'
|
import { http } from '@/apis/http'
|
||||||
import type {
|
import type {
|
||||||
|
ExtraInfoRequest,
|
||||||
|
ExtraInfoResponse,
|
||||||
ParticipateResponse,
|
ParticipateResponse,
|
||||||
RejectRequest,
|
RejectRequest,
|
||||||
RejectResponse,
|
RejectResponse,
|
||||||
@ -31,4 +33,13 @@ export const negotiationApi = {
|
|||||||
)
|
)
|
||||||
return res.data
|
return res.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** POST /v1/negotiation/sessions/{id}/extra-info — 협상완료 부가정보 저장 */
|
||||||
|
saveExtraInfo: async (sessionId: string, body: ExtraInfoRequest): Promise<ExtraInfoResponse> => {
|
||||||
|
const res = await http.post<ExtraInfoResponse>(
|
||||||
|
`/v1/negotiation/sessions/${sessionId}/extra-info`,
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { negotiationApi } from './negotiation.api'
|
import { negotiationApi } from './negotiation.api'
|
||||||
import { negotiationKeys } from './negotiation.keys'
|
import { negotiationKeys } from './negotiation.keys'
|
||||||
import type { RejectRequest } from './negotiation.type'
|
import type { ExtraInfoRequest, RejectRequest } from './negotiation.type'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 협상 세션 참여: 성공 시 세션 목록 캐시를 무효화해 상태를 갱신한다.
|
* 협상 세션 참여: 성공 시 세션 목록 캐시를 무효화해 상태를 갱신한다.
|
||||||
@ -30,3 +30,17 @@ export function useRejectMutation() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 협상완료 부가정보 저장: 성공 시 세션 목록 캐시를 무효화한다.
|
||||||
|
*/
|
||||||
|
export function useSaveExtraInfoMutation() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ sessionId, request }: { sessionId: string; request: ExtraInfoRequest }) =>
|
||||||
|
negotiationApi.saveExtraInfo(sessionId, request),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: negotiationKeys.sessions() })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@ -55,6 +55,7 @@ export interface SessionListItem {
|
|||||||
item_name: string
|
item_name: string
|
||||||
model_name: string
|
model_name: string
|
||||||
maker_name: string
|
maker_name: string
|
||||||
|
custom: Record<string, unknown> // 협상완료 부가정보(sessions.custom). 미입력이면 {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionListResponse {
|
export interface SessionListResponse {
|
||||||
@ -65,6 +66,15 @@ export interface SessionListResponse {
|
|||||||
page_size: number
|
page_size: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 협상완료 부가정보 (POST /v1/negotiation/sessions/{id}/extra-info) -----
|
||||||
|
export interface ExtraInfoRequest {
|
||||||
|
custom: Record<string, unknown>
|
||||||
|
}
|
||||||
|
export interface ExtraInfoResponse {
|
||||||
|
result: ApiResult
|
||||||
|
session_id: string
|
||||||
|
}
|
||||||
|
|
||||||
// --- 참여 (POST /v1/negotiation/sessions/{id}/participate) ----------------
|
// --- 참여 (POST /v1/negotiation/sessions/{id}/participate) ----------------
|
||||||
export interface ParticipateResponse {
|
export interface ParticipateResponse {
|
||||||
result: ApiResult
|
result: ApiResult
|
||||||
|
|||||||
@ -22,6 +22,9 @@ export interface LogoProps extends Omit<ComponentProps<'div'>, 'children'> {
|
|||||||
size?: LogoSize
|
size?: LogoSize
|
||||||
withText?: boolean
|
withText?: boolean
|
||||||
alt?: string
|
alt?: string
|
||||||
|
/** 회사 브랜딩 오버라이드(companies.settings.branding). 없으면 기본 iMarket Korea. */
|
||||||
|
serviceName?: string
|
||||||
|
logoUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Logo({
|
export function Logo({
|
||||||
@ -29,21 +32,24 @@ export function Logo({
|
|||||||
size = 'md',
|
size = 'md',
|
||||||
withText = true,
|
withText = true,
|
||||||
alt = 'iMarket Korea',
|
alt = 'iMarket Korea',
|
||||||
|
serviceName,
|
||||||
|
logoUrl,
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: LogoProps) {
|
}: LogoProps) {
|
||||||
const s = sizes[size]
|
const s = sizes[size]
|
||||||
|
const name = serviceName || 'iMarket Korea'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex items-center', s.gap, className)} {...props}>
|
<div className={cn('flex items-center', s.gap, className)} {...props}>
|
||||||
<img
|
<img
|
||||||
src={sources[variant]}
|
src={logoUrl || sources[variant]}
|
||||||
alt={withText ? '' : alt}
|
alt={withText ? '' : (serviceName || alt)}
|
||||||
className={cn('w-auto select-none', s.img)}
|
className={cn('w-auto select-none', s.img)}
|
||||||
/>
|
/>
|
||||||
{withText && (
|
{withText && (
|
||||||
<span className={cn('font-bold tracking-[-0.4px] text-foreground', s.text)}>
|
<span className={cn('font-bold tracking-[-0.4px] text-foreground', s.text)}>
|
||||||
iMarket Korea
|
{name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import type { ChatMessage as ChatMessageType } from '@/features/chat/types'
|
|||||||
import { renderEmphasis } from '@/features/chat/lib/emphasis'
|
import { renderEmphasis } from '@/features/chat/lib/emphasis'
|
||||||
import { Indicator } from '@/features/chat/components/templates/Indicator'
|
import { Indicator } from '@/features/chat/components/templates/Indicator'
|
||||||
import { Summary } from '@/features/chat/components/templates/Summary'
|
import { Summary } from '@/features/chat/components/templates/Summary'
|
||||||
|
import { ExtraInfoForm } from '@/features/chat/components/templates/ExtraInfoForm'
|
||||||
import { BidSummary } from '@/features/chat/components/templates/BidSummary'
|
import { BidSummary } from '@/features/chat/components/templates/BidSummary'
|
||||||
import { RejectRSP } from '@/features/chat/components/templates/RejectRSP'
|
import { RejectRSP } from '@/features/chat/components/templates/RejectRSP'
|
||||||
import { RejectCM } from '@/features/chat/components/templates/RejectCM'
|
import { RejectCM } from '@/features/chat/components/templates/RejectCM'
|
||||||
@ -108,7 +109,12 @@ const BotMessage = memo(function BotMessage({ message }: { message: ChatMessageT
|
|||||||
{showIndicator && message.bot_chat_type === 'indicator' && message.indicator_value != null && (
|
{showIndicator && message.bot_chat_type === 'indicator' && message.indicator_value != null && (
|
||||||
<Indicator number={message.indicator_value} />
|
<Indicator number={message.indicator_value} />
|
||||||
)}
|
)}
|
||||||
{message.bot_chat_type === 'summaryRSP' && message.summary && <Summary data={message.summary} />}
|
{message.bot_chat_type === 'summaryRSP' && message.summary && (
|
||||||
|
<>
|
||||||
|
<Summary data={message.summary} />
|
||||||
|
<ExtraInfoForm />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{message.bot_chat_type === 'summaryCM' && message.summary && (
|
{message.bot_chat_type === 'summaryCM' && message.summary && (
|
||||||
<BidSummary
|
<BidSummary
|
||||||
itemName={message.summary.item_name}
|
itemName={message.summary.item_name}
|
||||||
|
|||||||
@ -0,0 +1,109 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { CheckCircle2 } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { useMeQuery, useSaveExtraInfoMutation, getApiErrorMessage } from '@/apis'
|
||||||
|
import type { SessionField } from '@/apis/auth/auth.type'
|
||||||
|
import { useChatStore } from '@/features/chat/stores/useChatStore'
|
||||||
|
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
||||||
|
|
||||||
|
// 협상 타결(Summary) 직후 부가정보 입력 폼. 필드 정의(session_fields)는 회사 설정(/me)에서 온다.
|
||||||
|
// 저장하면 sessions.custom 에 기록되고 negodata 견적상세에 표시된다. 정의가 없으면 렌더하지 않는다.
|
||||||
|
export function ExtraInfoForm() {
|
||||||
|
const sessionId = useChatStore((s) => s.sessionId)
|
||||||
|
const { data: user } = useMeQuery()
|
||||||
|
const fields: SessionField[] = user?.sessionFields ?? []
|
||||||
|
const save = useSaveExtraInfoMutation()
|
||||||
|
const existing = useChatInitStore((s) => s.custom) // 기존 입력값(재진입 프리필)
|
||||||
|
|
||||||
|
const [values, setValues] = useState<Record<string, unknown>>({})
|
||||||
|
const [inited, setInited] = useState(false)
|
||||||
|
const [saved, setSaved] = useState(false)
|
||||||
|
|
||||||
|
// fields(회사 설정)와 기존값(sessions.custom)이 준비된 첫 시점에 프리필 — 이후 사용자 편집은 보존.
|
||||||
|
useEffect(() => {
|
||||||
|
if (inited || fields.length === 0) return
|
||||||
|
const init: Record<string, unknown> = {}
|
||||||
|
for (const f of fields) init[f.key] = existing?.[f.key] ?? (f.type === 'boolean' ? false : '')
|
||||||
|
setValues(init)
|
||||||
|
setInited(true)
|
||||||
|
}, [inited, fields, existing])
|
||||||
|
|
||||||
|
if (fields.length === 0) return null
|
||||||
|
|
||||||
|
const set = (key: string, value: unknown) => setValues((v) => ({ ...v, [key]: value }))
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const custom: Record<string, unknown> = {}
|
||||||
|
for (const f of fields) {
|
||||||
|
const v = values[f.key]
|
||||||
|
if (f.type === 'boolean') custom[f.key] = !!v
|
||||||
|
else if (v !== '' && v != null) custom[f.key] = f.type === 'number' ? Number(v) : v
|
||||||
|
}
|
||||||
|
save.mutate(
|
||||||
|
{ sessionId, request: { custom } },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setSaved(true)
|
||||||
|
toast.success('부가정보가 저장되었습니다.')
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(getApiErrorMessage(error, '부가정보 저장에 실패했습니다.')),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full rounded-2xl border border-border bg-white p-5 shadow-sm">
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="size-5 text-brand-600" />
|
||||||
|
<h3 className="text-sm font-bold text-neutral-90">부가정보 입력</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm leading-relaxed text-neutral-70 break-keep">
|
||||||
|
협상이 완료되었습니다. 아래 정보를 입력해 주세요. (저장 후에도 세션 목록에서 수정할 수 있습니다.)
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
{fields.map((f) => (
|
||||||
|
<div key={f.key} className="space-y-1.5">
|
||||||
|
<label className="block text-sm font-semibold text-neutral-80">{f.label}</label>
|
||||||
|
{f.type === 'boolean' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={!!values[f.key]}
|
||||||
|
onClick={() => set(f.key, !values[f.key])}
|
||||||
|
disabled={saved}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-50 ${
|
||||||
|
values[f.key] ? 'bg-brand-600' : 'bg-neutral-30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block size-5 transform rounded-full bg-white shadow transition-transform ${
|
||||||
|
values[f.key] ? 'translate-x-5' : 'translate-x-0.5'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type={f.type === 'number' ? 'number' : 'text'}
|
||||||
|
value={String(values[f.key] ?? '')}
|
||||||
|
onChange={(e) => set(f.key, e.target.value)}
|
||||||
|
disabled={saved}
|
||||||
|
className="h-11 w-full rounded-xl border border-border bg-white px-3 text-sm outline-none focus:border-brand-600 focus:ring-1 focus:ring-brand-600 disabled:bg-neutral-10"
|
||||||
|
placeholder={f.label}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={save.isPending || saved}
|
||||||
|
className="mt-4 h-11 w-full rounded-xl bg-brand-600 text-sm font-bold text-white transition-colors hover:bg-brand-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saved ? '저장 완료' : save.isPending ? '저장 중…' : '부가정보 저장'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -29,6 +29,7 @@ const initialState: ChatInitData = {
|
|||||||
item_spec: '',
|
item_spec: '',
|
||||||
quotation_memo: '',
|
quotation_memo: '',
|
||||||
quotation_end_time: '',
|
quotation_end_time: '',
|
||||||
|
custom: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useChatInitStore = create<ChatInitStore>((set) => ({
|
export const useChatInitStore = create<ChatInitStore>((set) => ({
|
||||||
|
|||||||
@ -81,4 +81,5 @@ export type ChatInitData = {
|
|||||||
item_spec: string
|
item_spec: string
|
||||||
quotation_memo: string
|
quotation_memo: string
|
||||||
quotation_end_time: string
|
quotation_end_time: string
|
||||||
|
custom: Record<string, unknown> // 협상완료 부가정보 기존 입력값(프리필용)
|
||||||
}
|
}
|
||||||
|
|||||||
115
frontend/src/features/list/components/ExtraInfoPopup.tsx
Normal file
115
frontend/src/features/list/components/ExtraInfoPopup.tsx
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { Modal } from '@/components'
|
||||||
|
import { useMeQuery } from '@/apis'
|
||||||
|
import type { SessionField } from '@/apis/auth/auth.type'
|
||||||
|
import type { ListItem } from '../types'
|
||||||
|
|
||||||
|
export interface ExtraInfoPopupProps {
|
||||||
|
target: ListItem
|
||||||
|
onClose: () => void
|
||||||
|
/** 부가정보 저장 (custom = {key: value}) */
|
||||||
|
onSubmit: (custom: Record<string, unknown>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// 협상완료 부가정보 입력 팝업. 필드 정의(session_fields)는 회사 설정(/me)에서 오고, 기존 입력값(target.custom)으로 프리필.
|
||||||
|
export function ExtraInfoPopup({ target, onClose, onSubmit }: ExtraInfoPopupProps) {
|
||||||
|
const { data: user } = useMeQuery()
|
||||||
|
const fields: SessionField[] = user?.sessionFields ?? []
|
||||||
|
|
||||||
|
const [values, setValues] = useState<Record<string, unknown>>(() => {
|
||||||
|
const init: Record<string, unknown> = {}
|
||||||
|
for (const f of fields) init[f.key] = target.custom?.[f.key] ?? (f.type === 'boolean' ? false : '')
|
||||||
|
return init
|
||||||
|
})
|
||||||
|
|
||||||
|
const set = (key: string, value: unknown) => setValues((v) => ({ ...v, [key]: value }))
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
// 빈 값은 제외하고 저장(부분 입력 허용)
|
||||||
|
const out: Record<string, unknown> = {}
|
||||||
|
for (const f of fields) {
|
||||||
|
const v = values[f.key]
|
||||||
|
if (f.type === 'boolean') out[f.key] = !!v
|
||||||
|
else if (v !== '' && v != null) out[f.key] = f.type === 'number' ? Number(v) : v
|
||||||
|
}
|
||||||
|
onSubmit(out)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal onClose={onClose}>
|
||||||
|
<div className="w-full max-w-md overflow-hidden rounded-2xl border border-border bg-white shadow-xl animate-scale-in">
|
||||||
|
<div className="flex items-center justify-between border-b border-border p-5">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-bold text-neutral-90">협상완료 부가정보</h3>
|
||||||
|
<p className="mt-0.5 text-xs text-neutral-60">{target.qt_number} · {target.item_name}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="닫기"
|
||||||
|
className="flex size-8 items-center justify-center rounded-full text-neutral-60 hover:bg-neutral-10"
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 p-5">
|
||||||
|
{fields.length === 0 ? (
|
||||||
|
<p className="py-6 text-center text-sm text-neutral-60">입력할 부가정보 항목이 없습니다.</p>
|
||||||
|
) : (
|
||||||
|
fields.map((f) => (
|
||||||
|
<div key={f.key} className="space-y-1.5">
|
||||||
|
<label className="block text-sm font-semibold text-neutral-80">{f.label}</label>
|
||||||
|
{f.type === 'boolean' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={!!values[f.key]}
|
||||||
|
onClick={() => set(f.key, !values[f.key])}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||||
|
values[f.key] ? 'bg-brand-600' : 'bg-neutral-30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block size-5 transform rounded-full bg-white shadow transition-transform ${
|
||||||
|
values[f.key] ? 'translate-x-5' : 'translate-x-0.5'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type={f.type === 'number' ? 'number' : 'text'}
|
||||||
|
value={String(values[f.key] ?? '')}
|
||||||
|
onChange={(e) => set(f.key, e.target.value)}
|
||||||
|
className="h-11 w-full rounded-xl border border-border bg-white px-3 text-sm outline-none focus:border-brand-600 focus:ring-1 focus:ring-brand-600"
|
||||||
|
placeholder={f.label}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 border-t border-border p-5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="h-11 flex-1 rounded-xl border border-border text-sm font-bold text-neutral-70 hover:bg-neutral-10"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={fields.length === 0}
|
||||||
|
className="h-11 flex-1 rounded-xl bg-brand-600 text-sm font-bold text-white hover:bg-brand-700 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
저장
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -9,10 +9,11 @@ interface WorkspaceCardsProps {
|
|||||||
busyId?: string | null
|
busyId?: string | null
|
||||||
onEnter: (item: ListItem) => void
|
onEnter: (item: ListItem) => void
|
||||||
onReject: (item: ListItem) => void
|
onReject: (item: ListItem) => void
|
||||||
|
onExtraInfo: (item: ListItem) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// 모바일(lg 미만) 협상 목록: 테이블 대신 카드 스택.
|
// 모바일(lg 미만) 협상 목록: 테이블 대신 카드 스택.
|
||||||
export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject }: WorkspaceCardsProps) {
|
export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject, onExtraInfo }: WorkspaceCardsProps) {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-16">
|
<div className="flex items-center justify-center py-16">
|
||||||
@ -27,7 +28,7 @@ export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject }:
|
|||||||
return (
|
return (
|
||||||
<div className="divide-y divide-border">
|
<div className="divide-y divide-border">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<Card key={item.session_id} item={item} busy={busyId === item.session_id} onEnter={onEnter} onReject={onReject} />
|
<Card key={item.session_id} item={item} busy={busyId === item.session_id} onEnter={onEnter} onReject={onReject} onExtraInfo={onExtraInfo} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -38,25 +39,26 @@ function Card({
|
|||||||
busy,
|
busy,
|
||||||
onEnter,
|
onEnter,
|
||||||
onReject,
|
onReject,
|
||||||
|
onExtraInfo,
|
||||||
}: {
|
}: {
|
||||||
item: ListItem
|
item: ListItem
|
||||||
busy: boolean
|
busy: boolean
|
||||||
onEnter: (item: ListItem) => void
|
onEnter: (item: ListItem) => void
|
||||||
onReject: (item: ListItem) => void
|
onReject: (item: ListItem) => void
|
||||||
|
onExtraInfo: (item: ListItem) => void
|
||||||
}) {
|
}) {
|
||||||
const meta = statusMeta(item.session_status)
|
const meta = statusMeta(item.session_status)
|
||||||
const canEnter = !['미참여', '협상거부'].includes(item.session_status)
|
const canEnter = !['미참여', '협상거부'].includes(item.session_status)
|
||||||
const canReject = ['협상생성', '협상중'].includes(item.session_status)
|
const canReject = ['협상생성', '협상중'].includes(item.session_status)
|
||||||
const enterLabel = item.session_status === '협상완료' ? '결과 보기' : '협상 입장'
|
const isDone = item.session_status === '협상완료'
|
||||||
|
const enterLabel = isDone ? '결과 보기' : '협상 입장'
|
||||||
|
const hasExtra = item.custom && Object.keys(item.custom).length > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="rounded-md bg-neutral-20 px-1.5 py-0.5 text-[11px] font-semibold text-neutral-70">
|
|
||||||
{item.qt_type || '-'}
|
|
||||||
</span>
|
|
||||||
<span className="truncate font-mono text-[12px] font-semibold text-neutral-70">{item.qt_number || '-'}</span>
|
<span className="truncate font-mono text-[12px] font-semibold text-neutral-70">{item.qt_number || '-'}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1.5 truncate text-sm font-bold text-neutral-90">{item.item_name || '-'}</p>
|
<p className="mt-1.5 truncate text-sm font-bold text-neutral-90">{item.item_name || '-'}</p>
|
||||||
@ -76,8 +78,17 @@ function Card({
|
|||||||
<span>마감 {formatKstDateTime(item.qt_end_time)}</span>
|
<span>마감 {formatKstDateTime(item.qt_end_time)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(canEnter || canReject) && (
|
{(canEnter || canReject || isDone) && (
|
||||||
<div className="mt-3 flex gap-2">
|
<div className="mt-3 flex gap-2">
|
||||||
|
{isDone && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onExtraInfo(item)}
|
||||||
|
className="flex-1 rounded-lg border border-brand-600/40 py-2 text-xs font-bold text-brand-700 transition-all hover:bg-brand-50 active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
{hasExtra ? '부가정보 수정' : '부가정보 입력'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{canReject && (
|
{canReject && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@ -10,19 +10,19 @@ interface WorkspaceTableProps {
|
|||||||
busyId?: string | null
|
busyId?: string | null
|
||||||
onEnter: (item: ListItem) => void
|
onEnter: (item: ListItem) => void
|
||||||
onReject: (item: ListItem) => void
|
onReject: (item: ListItem) => void
|
||||||
|
onExtraInfo: (item: ListItem) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const HEAD = 'px-5 py-3.5 text-left text-[11px] font-bold uppercase tracking-wider text-neutral-60 whitespace-nowrap'
|
const HEAD = 'px-5 py-3.5 text-left text-[11px] font-bold uppercase tracking-wider text-neutral-60 whitespace-nowrap'
|
||||||
const CELL = 'px-5 py-4 align-middle text-sm text-neutral-80'
|
const CELL = 'px-5 py-4 align-middle text-sm text-neutral-80'
|
||||||
|
|
||||||
export function WorkspaceTable({ items, isLoading, busyId, onEnter, onReject }: WorkspaceTableProps) {
|
export function WorkspaceTable({ items, isLoading, busyId, onEnter, onReject, onExtraInfo }: WorkspaceTableProps) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full overflow-x-auto">
|
<div className="w-full overflow-x-auto">
|
||||||
<table className="w-full min-w-[900px] border-collapse">
|
<table className="w-full min-w-[900px] border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border bg-table-header">
|
<tr className="border-b border-border bg-table-header">
|
||||||
<th className={HEAD}>견적번호</th>
|
<th className={HEAD}>견적번호</th>
|
||||||
<th className={HEAD}>구분</th>
|
|
||||||
<th className={HEAD}>상품</th>
|
<th className={HEAD}>상품</th>
|
||||||
<th className={HEAD}>제조사</th>
|
<th className={HEAD}>제조사</th>
|
||||||
<th className={cn(HEAD, 'text-center')}>상태</th>
|
<th className={cn(HEAD, 'text-center')}>상태</th>
|
||||||
@ -41,7 +41,7 @@ export function WorkspaceTable({ items, isLoading, busyId, onEnter, onReject }:
|
|||||||
</StateRow>
|
</StateRow>
|
||||||
) : (
|
) : (
|
||||||
items.map((item) => (
|
items.map((item) => (
|
||||||
<Row key={item.session_id} item={item} busy={busyId === item.session_id} onEnter={onEnter} onReject={onReject} />
|
<Row key={item.session_id} item={item} busy={busyId === item.session_id} onEnter={onEnter} onReject={onReject} onExtraInfo={onExtraInfo} />
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -55,27 +55,26 @@ function Row({
|
|||||||
busy,
|
busy,
|
||||||
onEnter,
|
onEnter,
|
||||||
onReject,
|
onReject,
|
||||||
|
onExtraInfo,
|
||||||
}: {
|
}: {
|
||||||
item: ListItem
|
item: ListItem
|
||||||
busy: boolean
|
busy: boolean
|
||||||
onEnter: (item: ListItem) => void
|
onEnter: (item: ListItem) => void
|
||||||
onReject: (item: ListItem) => void
|
onReject: (item: ListItem) => void
|
||||||
|
onExtraInfo: (item: ListItem) => void
|
||||||
}) {
|
}) {
|
||||||
const meta = statusMeta(item.session_status)
|
const meta = statusMeta(item.session_status)
|
||||||
const canEnter = !['미참여', '협상거부'].includes(item.session_status)
|
const canEnter = !['미참여', '협상거부'].includes(item.session_status)
|
||||||
const canReject = ['협상생성', '협상중'].includes(item.session_status)
|
const canReject = ['협상생성', '협상중'].includes(item.session_status)
|
||||||
const enterLabel = item.session_status === '협상완료' ? '결과 보기' : '협상 입장'
|
const isDone = item.session_status === '협상완료'
|
||||||
|
const enterLabel = isDone ? '결과 보기' : '협상 입장'
|
||||||
|
const hasExtra = item.custom && Object.keys(item.custom).length > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className="border-b border-border/60 transition-colors last:border-0 hover:bg-table-hover/70">
|
<tr className="border-b border-border/60 transition-colors last:border-0 hover:bg-table-hover/70">
|
||||||
<td className={cn(CELL, 'font-mono text-[13px] font-semibold text-neutral-90 whitespace-nowrap')}>
|
<td className={cn(CELL, 'font-mono text-[13px] font-semibold text-neutral-90 whitespace-nowrap')}>
|
||||||
{item.qt_number || '-'}
|
{item.qt_number || '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className={cn(CELL, 'whitespace-nowrap')}>
|
|
||||||
<span className="rounded-md bg-neutral-20 px-2 py-0.5 text-xs font-semibold text-neutral-70">
|
|
||||||
{item.qt_type || '-'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className={CELL}>
|
<td className={CELL}>
|
||||||
<p className="font-semibold text-neutral-90">{item.item_name || '-'}</p>
|
<p className="font-semibold text-neutral-90">{item.item_name || '-'}</p>
|
||||||
<p className="mt-0.5 text-xs text-neutral-60">
|
<p className="mt-0.5 text-xs text-neutral-60">
|
||||||
@ -93,6 +92,15 @@ function Row({
|
|||||||
<td className={cn(CELL, 'whitespace-nowrap text-neutral-70')}>{formatKstDateTime(item.qt_end_time)}</td>
|
<td className={cn(CELL, 'whitespace-nowrap text-neutral-70')}>{formatKstDateTime(item.qt_end_time)}</td>
|
||||||
<td className={CELL}>
|
<td className={CELL}>
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{isDone && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onExtraInfo(item)}
|
||||||
|
className="rounded-lg border border-brand-600/40 px-3 py-1.5 text-xs font-bold text-brand-700 transition-all hover:bg-brand-50 active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
{hasExtra ? '부가정보 수정' : '부가정보 입력'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{canReject && (
|
{canReject && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -124,7 +132,7 @@ function Row({
|
|||||||
function StateRow({ children }: { children: ReactNode }) {
|
function StateRow({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="py-20">
|
<td colSpan={6} className="py-20">
|
||||||
<div className="flex items-center justify-center">{children}</div>
|
<div className="flex items-center justify-center">{children}</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { useState } from 'react'
|
|||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import { Search } from 'lucide-react'
|
import { Search } from 'lucide-react'
|
||||||
import { getApiErrorMessage, useMeQuery, useParticipateMutation, useRejectMutation } from '@/apis'
|
import { getApiErrorMessage, useMeQuery, useParticipateMutation, useRejectMutation, useSaveExtraInfoMutation } from '@/apis'
|
||||||
import { cn, toast } from '@/lib'
|
import { cn, toast } from '@/lib'
|
||||||
import { useList } from '@/features/list/hooks/useList'
|
import { useList } from '@/features/list/hooks/useList'
|
||||||
import { useListStore } from '@/features/list/stores/useListStore'
|
import { useListStore } from '@/features/list/stores/useListStore'
|
||||||
@ -12,6 +12,7 @@ import { WorkspaceTable } from '@/features/list/components/WorkspaceTable'
|
|||||||
import { WorkspaceCards } from '@/features/list/components/WorkspaceCards'
|
import { WorkspaceCards } from '@/features/list/components/WorkspaceCards'
|
||||||
import { Pagination } from '@/features/list/components/Pagination'
|
import { Pagination } from '@/features/list/components/Pagination'
|
||||||
import { RejectPopup } from '@/features/list/components/RejectPopup'
|
import { RejectPopup } from '@/features/list/components/RejectPopup'
|
||||||
|
import { ExtraInfoPopup } from '@/features/list/components/ExtraInfoPopup'
|
||||||
import type { ListItem } from '@/features/list/types'
|
import type { ListItem } from '@/features/list/types'
|
||||||
|
|
||||||
// 상태별 거부 불가 안내
|
// 상태별 거부 불가 안내
|
||||||
@ -21,8 +22,6 @@ const REJECT_BLOCKED: Record<string, string> = {
|
|||||||
협상완료: '협상완료 상태인 협상은 거부할 수 없습니다.',
|
협상완료: '협상완료 상태인 협상은 거부할 수 없습니다.',
|
||||||
}
|
}
|
||||||
|
|
||||||
const TYPE_OPTIONS = ['재견적', '재협상', '신규견적', '신규협상']
|
|
||||||
|
|
||||||
export function ListWorkspace() {
|
export function ListWorkspace() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { data: user } = useMeQuery()
|
const { data: user } = useMeQuery()
|
||||||
@ -30,19 +29,19 @@ export function ListWorkspace() {
|
|||||||
const { items, isLoading, totalPages, currentPage, setCurrentPage } = useList()
|
const { items, isLoading, totalPages, currentPage, setCurrentPage } = useList()
|
||||||
const participate = useParticipateMutation()
|
const participate = useParticipateMutation()
|
||||||
const reject = useRejectMutation()
|
const reject = useRejectMutation()
|
||||||
|
const saveExtra = useSaveExtraInfoMutation()
|
||||||
|
|
||||||
const { selectedStatus, setStatus, selectedType, setType } = useListStore(
|
const { selectedStatus, setStatus } = useListStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
selectedStatus: s.selectedStatus,
|
selectedStatus: s.selectedStatus,
|
||||||
setStatus: s.setStatus,
|
setStatus: s.setStatus,
|
||||||
selectedType: s.selectedType,
|
|
||||||
setType: s.setType,
|
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [enteringId, setEnteringId] = useState<string | null>(null)
|
const [enteringId, setEnteringId] = useState<string | null>(null)
|
||||||
const [rejectTarget, setRejectTarget] = useState<ListItem | null>(null)
|
const [rejectTarget, setRejectTarget] = useState<ListItem | null>(null)
|
||||||
|
const [extraTarget, setExtraTarget] = useState<ListItem | null>(null)
|
||||||
|
|
||||||
const handleEnter = (item: ListItem) => {
|
const handleEnter = (item: ListItem) => {
|
||||||
setEnteringId(item.session_id)
|
setEnteringId(item.session_id)
|
||||||
@ -75,6 +74,17 @@ export function ListWorkspace() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleExtraSubmit = (custom: Record<string, unknown>) => {
|
||||||
|
if (!extraTarget) return
|
||||||
|
saveExtra.mutate(
|
||||||
|
{ sessionId: extraTarget.session_id, request: { custom } },
|
||||||
|
{
|
||||||
|
onSuccess: () => toast.success('부가정보가 저장되었습니다.'),
|
||||||
|
onError: (error) => toast.error(getApiErrorMessage(error, '부가정보 저장에 실패했습니다.')),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{/* 환영 배너 */}
|
{/* 환영 배너 */}
|
||||||
@ -97,18 +107,6 @@ export function ListWorkspace() {
|
|||||||
<div className="flex flex-col gap-3 border-b border-border p-4 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex flex-col gap-3 border-b border-border p-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<StatusTabs selected={selectedStatus} onSelect={setStatus} />
|
<StatusTabs selected={selectedStatus} onSelect={setStatus} />
|
||||||
<div className="flex w-full items-center gap-2 lg:w-auto">
|
<div className="flex w-full items-center gap-2 lg:w-auto">
|
||||||
<select
|
|
||||||
value={selectedType ?? ''}
|
|
||||||
onChange={(e) => setType(e.target.value || null)}
|
|
||||||
className="h-9 shrink-0 rounded-xl border border-border bg-white px-3 text-xs font-semibold text-neutral-80 outline-none transition-all focus:border-brand-600 focus:ring-1 focus:ring-brand-600"
|
|
||||||
>
|
|
||||||
<option value="">구분 전체</option>
|
|
||||||
{TYPE_OPTIONS.map((t) => (
|
|
||||||
<option key={t} value={t}>
|
|
||||||
{t}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<div className="relative flex-1 lg:flex-none">
|
<div className="relative flex-1 lg:flex-none">
|
||||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-neutral-50" />
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-neutral-50" />
|
||||||
<input
|
<input
|
||||||
@ -132,6 +130,7 @@ export function ListWorkspace() {
|
|||||||
busyId={participate.isPending ? enteringId : null}
|
busyId={participate.isPending ? enteringId : null}
|
||||||
onEnter={handleEnter}
|
onEnter={handleEnter}
|
||||||
onReject={handleReject}
|
onReject={handleReject}
|
||||||
|
onExtraInfo={setExtraTarget}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="lg:hidden">
|
<div className="lg:hidden">
|
||||||
@ -141,6 +140,7 @@ export function ListWorkspace() {
|
|||||||
busyId={participate.isPending ? enteringId : null}
|
busyId={participate.isPending ? enteringId : null}
|
||||||
onEnter={handleEnter}
|
onEnter={handleEnter}
|
||||||
onReject={handleReject}
|
onReject={handleReject}
|
||||||
|
onExtraInfo={setExtraTarget}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -152,6 +152,10 @@ export function ListWorkspace() {
|
|||||||
{rejectTarget && (
|
{rejectTarget && (
|
||||||
<RejectPopup onClose={() => setRejectTarget(null)} onSubmit={handleRejectSubmit} />
|
<RejectPopup onClose={() => setRejectTarget(null)} onSubmit={handleRejectSubmit} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{extraTarget && (
|
||||||
|
<ExtraInfoPopup target={extraTarget} onClose={() => setExtraTarget(null)} onSubmit={handleExtraSubmit} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,17 +1,16 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useSessionListQuery } from '@/apis'
|
import { useSessionListQuery } from '@/apis'
|
||||||
import { useListStore } from '@/features/list/stores/useListStore'
|
import { useListStore } from '@/features/list/stores/useListStore'
|
||||||
import { deadlineToOrder, statusLabelToCode, toListItem, typeLabelToCode } from '@/features/list/lib/adapter'
|
import { deadlineToOrder, statusLabelToCode, toListItem } from '@/features/list/lib/adapter'
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
// 필터/정렬/페이지는 서버에 위임하고, 응답(정수 코드)을 화면용 라벨로 변환한다.
|
// 필터/정렬/페이지는 서버에 위임하고, 응답(정수 코드)을 화면용 라벨로 변환한다.
|
||||||
export function useList() {
|
export function useList() {
|
||||||
const { selectedType, selectedStatus, selectedDeadline, currentPage, setCurrentPage } = useListStore()
|
const { selectedStatus, selectedDeadline, currentPage, setCurrentPage } = useListStore()
|
||||||
|
|
||||||
const query = useSessionListQuery({
|
const query = useSessionListQuery({
|
||||||
status: selectedStatus ? statusLabelToCode(selectedStatus) : undefined,
|
status: selectedStatus ? statusLabelToCode(selectedStatus) : undefined,
|
||||||
qt_type: selectedType ? typeLabelToCode(selectedType) : undefined,
|
|
||||||
order: selectedDeadline ? deadlineToOrder(selectedDeadline) : undefined,
|
order: selectedDeadline ? deadlineToOrder(selectedDeadline) : undefined,
|
||||||
page: currentPage,
|
page: currentPage,
|
||||||
page_size: PAGE_SIZE,
|
page_size: PAGE_SIZE,
|
||||||
|
|||||||
@ -37,5 +37,6 @@ export function toListItem(api: SessionListItem): ListItem {
|
|||||||
item_name: api.item_name,
|
item_name: api.item_name,
|
||||||
model_name: api.model_name,
|
model_name: api.model_name,
|
||||||
maker_name: api.maker_name,
|
maker_name: api.maker_name,
|
||||||
|
custom: api.custom ?? {},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,4 +8,5 @@ export type ListItem = {
|
|||||||
item_name: string
|
item_name: string
|
||||||
model_name: string
|
model_name: string
|
||||||
maker_name: string
|
maker_name: string
|
||||||
|
custom: Record<string, unknown> // 협상완료 부가정보(입력값). 미입력이면 {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { type ReactNode } from 'react'
|
import { type ReactNode } from 'react'
|
||||||
import { Logo } from '@/components'
|
import { Logo } from '@/components'
|
||||||
|
import { useMeQuery } from '@/apis'
|
||||||
import { cn } from '@/lib'
|
import { cn } from '@/lib'
|
||||||
|
|
||||||
// 좌측 폭: list=반응형 비율 / chat=고정폭 단계 축소
|
// 좌측 폭: list=반응형 비율 / chat=고정폭 단계 축소
|
||||||
@ -43,11 +44,12 @@ export function MainLayout({
|
|||||||
header,
|
header,
|
||||||
children,
|
children,
|
||||||
}: MainLayoutProps) {
|
}: MainLayoutProps) {
|
||||||
|
const { data: user } = useMeQuery() // 회사 브랜딩(서비스명/로고) 주입
|
||||||
const panes = (
|
const panes = (
|
||||||
<>
|
<>
|
||||||
<aside className={cn(styles.sidebar, SIDEBAR_WIDTH[sidebarWidth])}>
|
<aside className={cn(styles.sidebar, SIDEBAR_WIDTH[sidebarWidth])}>
|
||||||
<div className={styles.logoHeader}>
|
<div className={styles.logoHeader}>
|
||||||
<Logo size="sm" />
|
<Logo size="sm" serviceName={user?.branding?.service_name} logoUrl={user?.branding?.logo_url} />
|
||||||
{logoAction}
|
{logoAction}
|
||||||
</div>
|
</div>
|
||||||
{sidebar}
|
{sidebar}
|
||||||
|
|||||||
@ -15,7 +15,7 @@ export function PortalHeader() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-40 flex h-16 items-center justify-between border-b border-border bg-white/90 px-4 shadow-sm backdrop-blur sm:px-6">
|
<header className="sticky top-0 z-40 flex h-16 items-center justify-between border-b border-border bg-white/90 px-4 shadow-sm backdrop-blur sm:px-6">
|
||||||
<Logo size="md" />
|
<Logo size="md" serviceName={user?.branding?.service_name} logoUrl={user?.branding?.logo_url} />
|
||||||
|
|
||||||
<div className="flex items-center gap-2 sm:gap-3">
|
<div className="flex items-center gap-2 sm:gap-3">
|
||||||
<button
|
<button
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user