From 5d0fbbcf6a2b9f7d717e43b7a8d93870740b1975 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 22 Jul 2026 15:20:24 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20=EA=B3=B5=EA=B8=89=EC=82=AC=20?= =?UTF-8?q?=ED=8F=AC=ED=84=B8=C2=B7backend:=20=ED=98=91=EC=83=81=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=EB=B6=80=EA=B0=80=EC=A0=95=EB=B3=B4=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5(#5)=C2=B7=ED=8F=AC=ED=84=B8=20=EB=B8=8C=EB=9E=9C?= =?UTF-8?q?=EB=94=A9/=EB=A1=9C=EA=B3=A0=C2=B7=EA=B5=AC=EB=B6=84=EC=82=AD?= =?UTF-8?q?=EC=A0=9C(#19)=C2=B7=EC=B9=B4=EB=93=9C=EB=A9=94=ED=83=80=20nego?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/crud/chat_crud.py | 18 ++- backend/crud/session_crud.py | 18 +++ backend/crud/user_crud.py | 25 +++- backend/router/v1/auth/protocol.py | 2 + backend/router/v1/chat/protocol.py | 1 + backend/router/v1/negotiation/protocol.py | 9 ++ backend/router/v1/negotiation/session.py | 18 ++- backend/services/auth_service.py | 9 ++ backend/services/chat_service.py | 18 ++- backend/services/negotiation_service.py | 50 +++++++- frontend/src/apis/auth/auth.type.ts | 20 +++ frontend/src/apis/chat/chat.type.ts | 2 + frontend/src/apis/negotiation/index.ts | 2 +- .../src/apis/negotiation/negotiation.api.ts | 11 ++ .../apis/negotiation/negotiation.mutations.ts | 16 ++- .../src/apis/negotiation/negotiation.type.ts | 10 ++ frontend/src/components/Logo.tsx | 12 +- .../features/chat/components/ChatMessage.tsx | 8 +- .../components/templates/ExtraInfoForm.tsx | 109 +++++++++++++++++ .../features/chat/stores/useChatInitStore.ts | 1 + frontend/src/features/chat/types.ts | 1 + .../list/components/ExtraInfoPopup.tsx | 115 ++++++++++++++++++ .../list/components/WorkspaceCards.tsx | 25 ++-- .../list/components/WorkspaceTable.tsx | 28 +++-- .../list/containers/ListWorkspace.tsx | 40 +++--- frontend/src/features/list/hooks/useList.ts | 5 +- frontend/src/features/list/lib/adapter.ts | 1 + frontend/src/features/list/types.ts | 1 + frontend/src/layouts/MainLayout.tsx | 4 +- frontend/src/layouts/PortalHeader.tsx | 2 +- 30 files changed, 529 insertions(+), 52 deletions(-) create mode 100644 frontend/src/features/chat/components/templates/ExtraInfoForm.tsx create mode 100644 frontend/src/features/list/components/ExtraInfoPopup.tsx diff --git a/backend/crud/chat_crud.py b/backend/crud/chat_crud.py index 05d8678..6e36228 100644 --- a/backend/crud/chat_crud.py +++ b/backend/crud/chat_crud.py @@ -6,7 +6,7 @@ from sqlalchemy import asc, desc, select, update from sqlalchemy.ext.asyncio import AsyncSession 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.logger import LOG @@ -47,6 +47,10 @@ class IChatCRUD(ABC): async def update_last_offer_price(self, cdb: AsyncSession, session_id, price: int) -> ErrorType: pass + @abstractmethod + async def get_nego_card_id_by_number(self, cdb: AsyncSession, number: str): + pass + class ChatCRUD(IChatCRUD): 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) 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( self, cdb: AsyncSession, session_id, status: int, bid_price: Optional[int] = None, reject_reason: Optional[str] = None, reject_price: Optional[int] = None, diff --git a/backend/crud/session_crud.py b/backend/crud/session_crud.py index 0e0c22e..9059ca0 100644 --- a/backend/crud/session_crud.py +++ b/backend/crud/session_crud.py @@ -41,6 +41,10 @@ class ISessionCRUD(ABC): async def update_session_reject(self, cdb: AsyncSession, session_id, status: int, reject_reason: str) -> ErrorType: pass + @abstractmethod + async def update_session_custom(self, cdb: AsyncSession, session_id, supplier_id, custom: dict) -> ErrorType: + pass + class SessionCRUD(ISessionCRUD): @staticmethod @@ -86,6 +90,7 @@ class SessionCRUD(ISessionCRUD): items.name, items.model_name, items.manufacturer, + sessions.custom, ) .join(items, items.item_id == sessions.item_id) .join(quotations, quotations.qt_id == sessions.quotation_id) @@ -173,3 +178,16 @@ class SessionCRUD(ISessionCRUD): except Exception as ex: LOG.e_no_callstack(ex) 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 diff --git a/backend/crud/user_crud.py b/backend/crud/user_crud.py index 21eb679..04db9a7 100644 --- a/backend/crud/user_crud.py +++ b/backend/crud/user_crud.py @@ -5,7 +5,7 @@ from sqlalchemy import delete, select, update from sqlalchemy.ext.asyncio import AsyncSession 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.logger import LOG 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]: pass + @abstractmethod + async def get_company_settings(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, dict]: + pass + @abstractmethod async def is_account(self, cdb: AsyncSession, login_id: str) -> ErrorType: pass @@ -117,6 +121,25 @@ class UserCRUD(IUserCRUD): LOG.e_no_callstack(ex) 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: try: query = ( diff --git a/backend/router/v1/auth/protocol.py b/backend/router/v1/auth/protocol.py index d642957..4367aa1 100644 --- a/backend/router/v1/auth/protocol.py +++ b/backend/router/v1/auth/protocol.py @@ -48,6 +48,8 @@ class Res_Me(Res_WebPacketProtocol): supplier_id: str = Field("", description="소속 공급사 uuid") supplier_name: str = Field("", description="공급사명") 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): diff --git a/backend/router/v1/chat/protocol.py b/backend/router/v1/chat/protocol.py index 71ba77c..cdc1892 100644 --- a/backend/router/v1/chat/protocol.py +++ b/backend/router/v1/chat/protocol.py @@ -77,6 +77,7 @@ class Res_ChatInit(Res_WebPacketProtocol): item_min_order_quantity: str = Field("", description="최소 주문 수량") item_vat_yn: Optional[bool] = Field(None, description="VAT 포함 여부(미설정 시 null)") item_delivery_fee_yn: Optional[bool] = Field(None, description="배송비 포함 여부(미설정 시 null)") + custom: dict = Field(default_factory=dict, description="협상완료 부가정보 기존 입력값(sessions.custom). 재진입 시 폼 프리필용") # 대화 히스토리(재진입 복원) diff --git a/backend/router/v1/negotiation/protocol.py b/backend/router/v1/negotiation/protocol.py index 5908855..e1dba6e 100644 --- a/backend/router/v1/negotiation/protocol.py +++ b/backend/router/v1/negotiation/protocol.py @@ -14,6 +14,7 @@ class ListItem(WebPacketProtocol): item_name: str = Field("", description="상품명") model_name: str = Field("", description="모델명") maker_name: str = Field("", description="제조사") + custom: dict = Field(default_factory=dict, description="협상완료 부가정보 값(sessions.custom). 미입력이면 빈 dict") class Res_SessionList(Res_WebPacketProtocol): @@ -33,3 +34,11 @@ class Req_Reject(WebPacketProtocol): class Res_Reject(Res_WebPacketProtocol): 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") diff --git a/backend/router/v1/negotiation/session.py b/backend/router/v1/negotiation/session.py index 86a6de3..323d85d 100644 --- a/backend/router/v1/negotiation/session.py +++ b/backend/router/v1/negotiation/session.py @@ -6,7 +6,7 @@ from fastapi.security import HTTPAuthorizationCredentials from common.models.gmodel import UserInfo from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse, security 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"}}) @@ -61,3 +61,19 @@ async def reject( service: NegotiationService = Depends(), ): 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)) diff --git a/backend/services/auth_service.py b/backend/services/auth_service.py index 2b0a829..d06511d 100644 --- a/backend/services/auth_service.py +++ b/backend/services/auth_service.py @@ -250,6 +250,15 @@ class AuthService: res.supplier_id = info.supplier_id res.supplier_name = info.supplier_name 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 async def popup_status(self, user_info: UserInfo, access_token: str) -> Res_PopupStatus: diff --git a/backend/services/chat_service.py b/backend/services/chat_service.py index ac6ab27..d434f0d 100644 --- a/backend/services/chat_service.py +++ b/backend/services/chat_service.py @@ -103,13 +103,18 @@ class ChatService: ) @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 는 전용 컬럼에도 적재. + # nego_card_uuid: turn.card_id(번호)를 UUID 로 변환한 값(nego 카드). 있으면 chats.card_id/card_type/card_used_yn 컬럼에 적재 + # → negodata 가 이 컬럼으로 카드 사용/효과를 조인한다. (wild 카드는 agent 가 card_id 미제공 — 별도 작업) return chats( chat_id=uuid.uuid4(), session_id=sess.session_id, seq=seq, sender=ChatSender.BOT.value, target_price=int(sess.target_price or 0), 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={ "script": turn.script, "step": turn.step, "client_step": turn.client_step, "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_vat_yn = item.vat_yn res.item_delivery_fee_yn = item.delivery_fee_yn + res.custom = sess.custom or {} return res # ---- messages ------------------------------------------------------- @@ -371,8 +377,16 @@ class ChatService: # 유저 미입력 가격 타결 케이스 — 마지막 유저 제시가와 다를 수 있다). 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+거부사유·제시가). 한 트랜잭션. - 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)] # 가격 입력 턴 → 마지막 제시가를 봇 메시지 저장과 같은 트랜잭션으로 갱신. # 앵커링 표본 판정의 "가격 흔적"(가격을 써낸 협상만 집계 — 중간 이탈해도 실패로 측정 가능). diff --git a/backend/services/negotiation_service.py b/backend/services/negotiation_service.py index f551bf2..5f6c1b3 100644 --- a/backend/services/negotiation_service.py +++ b/backend/services/negotiation_service.py @@ -8,7 +8,7 @@ from common.database.model.models import sessions from common.enums import DBWRType, ErrorType, QuotationStatus, SessionStatus from common.models.gmodel import UserInfo 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 @@ -65,6 +65,7 @@ class NegotiationService: item_name=r[6] or "", model_name=r[7] or "", maker_name=r[8] or "", + custom=r[9] or {}, ) for r in rows ] @@ -73,6 +74,53 @@ class NegotiationService: res.page_size = page_size 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): """참여/거부 공통 전처리: 인증 → 세션/견적 로드 → 소유·상태·견적마감·마감시간 검증. 성공 시 (SUCCESS, sess, quote), 실패 시 (err_type, None, None) 을 반환한다. diff --git a/frontend/src/apis/auth/auth.type.ts b/frontend/src/apis/auth/auth.type.ts index cc0e02d..ba607bd 100644 --- a/frontend/src/apis/auth/auth.type.ts +++ b/frontend/src/apis/auth/auth.type.ts @@ -54,6 +54,20 @@ export interface RefreshTokenResponse { } // --- 내 정보 (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 { result: ApiResult su_id: string @@ -62,6 +76,8 @@ export interface MeResponse { supplier_id: string supplier_name: string role: number + branding?: Branding + session_fields?: SessionField[] } // --- 로그아웃 ------------------------------------------------------------- @@ -94,6 +110,8 @@ export interface AuthUser { supplierId: string supplierName: string role: number + branding: Branding + sessionFields: SessionField[] } export function toAuthUser(res: MeResponse): AuthUser { @@ -104,5 +122,7 @@ export function toAuthUser(res: MeResponse): AuthUser { supplierId: res.supplier_id, supplierName: res.supplier_name, role: res.role, + branding: res.branding ?? {}, + sessionFields: res.session_fields ?? [], } } diff --git a/frontend/src/apis/chat/chat.type.ts b/frontend/src/apis/chat/chat.type.ts index 0455fa5..1f76d03 100644 --- a/frontend/src/apis/chat/chat.type.ts +++ b/frontend/src/apis/chat/chat.type.ts @@ -46,6 +46,7 @@ export interface ChatInitResponse { item_min_order_quantity?: string item_vat_yn?: boolean item_delivery_fee_yn?: boolean + custom?: Record } export interface ChatMessagesResponse { @@ -98,6 +99,7 @@ export function mapInit(r: ChatInitResponse): ChatInitData { item_delivery_fee_yn: r.item_delivery_fee_yn == null ? '' : r.item_delivery_fee_yn ? '배송비포함' : '배송비별도', item_min_order_quantity: r.item_min_order_quantity ?? '', + custom: r.custom ?? {}, item_lead_time: r.item_lead_time ?? '', item_spec: r.item_spec ?? '', quotation_memo: r.quotation_memo ?? '', diff --git a/frontend/src/apis/negotiation/index.ts b/frontend/src/apis/negotiation/index.ts index f82d7e4..49deade 100644 --- a/frontend/src/apis/negotiation/index.ts +++ b/frontend/src/apis/negotiation/index.ts @@ -2,5 +2,5 @@ export { negotiationApi } from './negotiation.api' export { negotiationKeys } from './negotiation.keys' export { useSessionListQuery } from './negotiation.queries' -export { useParticipateMutation, useRejectMutation } from './negotiation.mutations' +export { useParticipateMutation, useRejectMutation, useSaveExtraInfoMutation } from './negotiation.mutations' export * from './negotiation.type' diff --git a/frontend/src/apis/negotiation/negotiation.api.ts b/frontend/src/apis/negotiation/negotiation.api.ts index 0f78a67..2e430c6 100644 --- a/frontend/src/apis/negotiation/negotiation.api.ts +++ b/frontend/src/apis/negotiation/negotiation.api.ts @@ -1,6 +1,8 @@ // 협상 엔드포인트 호출 함수 (순수 HTTP 레이어, React 의존 없음). import { http } from '@/apis/http' import type { + ExtraInfoRequest, + ExtraInfoResponse, ParticipateResponse, RejectRequest, RejectResponse, @@ -31,4 +33,13 @@ export const negotiationApi = { ) return res.data }, + + /** POST /v1/negotiation/sessions/{id}/extra-info — 협상완료 부가정보 저장 */ + saveExtraInfo: async (sessionId: string, body: ExtraInfoRequest): Promise => { + const res = await http.post( + `/v1/negotiation/sessions/${sessionId}/extra-info`, + body, + ) + return res.data + }, } diff --git a/frontend/src/apis/negotiation/negotiation.mutations.ts b/frontend/src/apis/negotiation/negotiation.mutations.ts index 947565f..10030a4 100644 --- a/frontend/src/apis/negotiation/negotiation.mutations.ts +++ b/frontend/src/apis/negotiation/negotiation.mutations.ts @@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { negotiationApi } from './negotiation.api' 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() }) + }, + }) +} diff --git a/frontend/src/apis/negotiation/negotiation.type.ts b/frontend/src/apis/negotiation/negotiation.type.ts index c54b48a..ccd0fd5 100644 --- a/frontend/src/apis/negotiation/negotiation.type.ts +++ b/frontend/src/apis/negotiation/negotiation.type.ts @@ -55,6 +55,7 @@ export interface SessionListItem { item_name: string model_name: string maker_name: string + custom: Record // 협상완료 부가정보(sessions.custom). 미입력이면 {} } export interface SessionListResponse { @@ -65,6 +66,15 @@ export interface SessionListResponse { page_size: number } +// --- 협상완료 부가정보 (POST /v1/negotiation/sessions/{id}/extra-info) ----- +export interface ExtraInfoRequest { + custom: Record +} +export interface ExtraInfoResponse { + result: ApiResult + session_id: string +} + // --- 참여 (POST /v1/negotiation/sessions/{id}/participate) ---------------- export interface ParticipateResponse { result: ApiResult diff --git a/frontend/src/components/Logo.tsx b/frontend/src/components/Logo.tsx index f4efe89..ee6b3db 100644 --- a/frontend/src/components/Logo.tsx +++ b/frontend/src/components/Logo.tsx @@ -22,6 +22,9 @@ export interface LogoProps extends Omit, 'children'> { size?: LogoSize withText?: boolean alt?: string + /** 회사 브랜딩 오버라이드(companies.settings.branding). 없으면 기본 iMarket Korea. */ + serviceName?: string + logoUrl?: string } export function Logo({ @@ -29,21 +32,24 @@ export function Logo({ size = 'md', withText = true, alt = 'iMarket Korea', + serviceName, + logoUrl, className, ...props }: LogoProps) { const s = sizes[size] + const name = serviceName || 'iMarket Korea' return (
{withText {withText && ( - iMarket Korea + {name} )}
diff --git a/frontend/src/features/chat/components/ChatMessage.tsx b/frontend/src/features/chat/components/ChatMessage.tsx index 281c44d..a046996 100644 --- a/frontend/src/features/chat/components/ChatMessage.tsx +++ b/frontend/src/features/chat/components/ChatMessage.tsx @@ -6,6 +6,7 @@ import type { ChatMessage as ChatMessageType } from '@/features/chat/types' import { renderEmphasis } from '@/features/chat/lib/emphasis' import { Indicator } from '@/features/chat/components/templates/Indicator' 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 { RejectRSP } from '@/features/chat/components/templates/RejectRSP' 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 && ( )} - {message.bot_chat_type === 'summaryRSP' && message.summary && } + {message.bot_chat_type === 'summaryRSP' && message.summary && ( + <> + + + + )} {message.bot_chat_type === 'summaryCM' && message.summary && ( s.sessionId) + const { data: user } = useMeQuery() + const fields: SessionField[] = user?.sessionFields ?? [] + const save = useSaveExtraInfoMutation() + const existing = useChatInitStore((s) => s.custom) // 기존 입력값(재진입 프리필) + + const [values, setValues] = useState>({}) + const [inited, setInited] = useState(false) + const [saved, setSaved] = useState(false) + + // fields(회사 설정)와 기존값(sessions.custom)이 준비된 첫 시점에 프리필 — 이후 사용자 편집은 보존. + useEffect(() => { + if (inited || fields.length === 0) return + const init: Record = {} + 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 = {} + 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 ( +
+
+ +

부가정보 입력

+
+

+ 협상이 완료되었습니다. 아래 정보를 입력해 주세요. (저장 후에도 세션 목록에서 수정할 수 있습니다.) +

+ +
+ {fields.map((f) => ( +
+ + {f.type === 'boolean' ? ( + + ) : ( + 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} + /> + )} +
+ ))} +
+ + +
+ ) +} diff --git a/frontend/src/features/chat/stores/useChatInitStore.ts b/frontend/src/features/chat/stores/useChatInitStore.ts index 6f23dc3..30a82af 100644 --- a/frontend/src/features/chat/stores/useChatInitStore.ts +++ b/frontend/src/features/chat/stores/useChatInitStore.ts @@ -29,6 +29,7 @@ const initialState: ChatInitData = { item_spec: '', quotation_memo: '', quotation_end_time: '', + custom: {}, } export const useChatInitStore = create((set) => ({ diff --git a/frontend/src/features/chat/types.ts b/frontend/src/features/chat/types.ts index 86ec723..691ae2f 100644 --- a/frontend/src/features/chat/types.ts +++ b/frontend/src/features/chat/types.ts @@ -81,4 +81,5 @@ export type ChatInitData = { item_spec: string quotation_memo: string quotation_end_time: string + custom: Record // 협상완료 부가정보 기존 입력값(프리필용) } diff --git a/frontend/src/features/list/components/ExtraInfoPopup.tsx b/frontend/src/features/list/components/ExtraInfoPopup.tsx new file mode 100644 index 0000000..1613531 --- /dev/null +++ b/frontend/src/features/list/components/ExtraInfoPopup.tsx @@ -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) => 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>(() => { + const init: Record = {} + 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 = {} + 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 ( + +
+
+
+

협상완료 부가정보

+

{target.qt_number} · {target.item_name}

+
+ +
+ +
+ {fields.length === 0 ? ( +

입력할 부가정보 항목이 없습니다.

+ ) : ( + fields.map((f) => ( +
+ + {f.type === 'boolean' ? ( + + ) : ( + 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} + /> + )} +
+ )) + )} +
+ +
+ + +
+
+
+ ) +} diff --git a/frontend/src/features/list/components/WorkspaceCards.tsx b/frontend/src/features/list/components/WorkspaceCards.tsx index 406dc2f..07e43de 100644 --- a/frontend/src/features/list/components/WorkspaceCards.tsx +++ b/frontend/src/features/list/components/WorkspaceCards.tsx @@ -9,10 +9,11 @@ interface WorkspaceCardsProps { busyId?: string | null onEnter: (item: ListItem) => void onReject: (item: ListItem) => void + onExtraInfo: (item: ListItem) => void } // 모바일(lg 미만) 협상 목록: 테이블 대신 카드 스택. -export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject }: WorkspaceCardsProps) { +export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject, onExtraInfo }: WorkspaceCardsProps) { if (isLoading) { return (
@@ -27,7 +28,7 @@ export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject }: return (
{items.map((item) => ( - + ))}
) @@ -38,25 +39,26 @@ function Card({ busy, onEnter, onReject, + onExtraInfo, }: { item: ListItem busy: boolean onEnter: (item: ListItem) => void onReject: (item: ListItem) => void + onExtraInfo: (item: ListItem) => void }) { const meta = statusMeta(item.session_status) const canEnter = !['미참여', '협상거부'].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 (
- - {item.qt_type || '-'} - {item.qt_number || '-'}

{item.item_name || '-'}

@@ -76,8 +78,17 @@ function Card({ 마감 {formatKstDateTime(item.qt_end_time)}
- {(canEnter || canReject) && ( + {(canEnter || canReject || isDone) && (
+ {isDone && ( + + )} {canReject && (