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 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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 = (
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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). 재진입 시 폼 프리필용")
|
||||
|
||||
|
||||
# 대화 히스토리(재진입 복원)
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)]
|
||||
# 가격 입력 턴 → 마지막 제시가를 봇 메시지 저장과 같은 트랜잭션으로 갱신.
|
||||
# 앵커링 표본 판정의 "가격 흔적"(가격을 써낸 협상만 집계 — 중간 이탈해도 실패로 측정 가능).
|
||||
|
||||
@ -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) 을 반환한다.
|
||||
|
||||
@ -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 ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
@ -46,6 +46,7 @@ export interface ChatInitResponse {
|
||||
item_min_order_quantity?: string
|
||||
item_vat_yn?: boolean
|
||||
item_delivery_fee_yn?: boolean
|
||||
custom?: Record<string, unknown>
|
||||
}
|
||||
|
||||
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 ?? '',
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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<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 { 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() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -55,6 +55,7 @@ export interface SessionListItem {
|
||||
item_name: string
|
||||
model_name: string
|
||||
maker_name: string
|
||||
custom: Record<string, unknown> // 협상완료 부가정보(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<string, unknown>
|
||||
}
|
||||
export interface ExtraInfoResponse {
|
||||
result: ApiResult
|
||||
session_id: string
|
||||
}
|
||||
|
||||
// --- 참여 (POST /v1/negotiation/sessions/{id}/participate) ----------------
|
||||
export interface ParticipateResponse {
|
||||
result: ApiResult
|
||||
|
||||
@ -22,6 +22,9 @@ export interface LogoProps extends Omit<ComponentProps<'div'>, '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 (
|
||||
<div className={cn('flex items-center', s.gap, className)} {...props}>
|
||||
<img
|
||||
src={sources[variant]}
|
||||
alt={withText ? '' : alt}
|
||||
src={logoUrl || sources[variant]}
|
||||
alt={withText ? '' : (serviceName || alt)}
|
||||
className={cn('w-auto select-none', s.img)}
|
||||
/>
|
||||
{withText && (
|
||||
<span className={cn('font-bold tracking-[-0.4px] text-foreground', s.text)}>
|
||||
iMarket Korea
|
||||
{name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -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 && (
|
||||
<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 && (
|
||||
<BidSummary
|
||||
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: '',
|
||||
quotation_memo: '',
|
||||
quotation_end_time: '',
|
||||
custom: {},
|
||||
}
|
||||
|
||||
export const useChatInitStore = create<ChatInitStore>((set) => ({
|
||||
|
||||
@ -81,4 +81,5 @@ export type ChatInitData = {
|
||||
item_spec: string
|
||||
quotation_memo: 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
|
||||
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 (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
@ -27,7 +28,7 @@ export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject }:
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{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>
|
||||
)
|
||||
@ -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 (
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{(canEnter || canReject) && (
|
||||
{(canEnter || canReject || isDone) && (
|
||||
<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 && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@ -10,19 +10,19 @@ interface WorkspaceTableProps {
|
||||
busyId?: string | null
|
||||
onEnter: (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 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 (
|
||||
<div className="w-full overflow-x-auto">
|
||||
<table className="w-full min-w-[900px] border-collapse">
|
||||
<thead>
|
||||
<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={cn(HEAD, 'text-center')}>상태</th>
|
||||
@ -41,7 +41,7 @@ export function WorkspaceTable({ items, isLoading, busyId, onEnter, onReject }:
|
||||
</StateRow>
|
||||
) : (
|
||||
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>
|
||||
@ -55,27 +55,26 @@ function Row({
|
||||
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 (
|
||||
<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')}>
|
||||
{item.qt_number || '-'}
|
||||
</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}>
|
||||
<p className="font-semibold text-neutral-90">{item.item_name || '-'}</p>
|
||||
<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={CELL}>
|
||||
<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 && (
|
||||
<button
|
||||
type="button"
|
||||
@ -124,7 +132,7 @@ function Row({
|
||||
function StateRow({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={7} className="py-20">
|
||||
<td colSpan={6} className="py-20">
|
||||
<div className="flex items-center justify-center">{children}</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@ -2,7 +2,7 @@ import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
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 { useList } from '@/features/list/hooks/useList'
|
||||
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 { Pagination } from '@/features/list/components/Pagination'
|
||||
import { RejectPopup } from '@/features/list/components/RejectPopup'
|
||||
import { ExtraInfoPopup } from '@/features/list/components/ExtraInfoPopup'
|
||||
import type { ListItem } from '@/features/list/types'
|
||||
|
||||
// 상태별 거부 불가 안내
|
||||
@ -21,8 +22,6 @@ const REJECT_BLOCKED: Record<string, string> = {
|
||||
협상완료: '협상완료 상태인 협상은 거부할 수 없습니다.',
|
||||
}
|
||||
|
||||
const TYPE_OPTIONS = ['재견적', '재협상', '신규견적', '신규협상']
|
||||
|
||||
export function ListWorkspace() {
|
||||
const navigate = useNavigate()
|
||||
const { data: user } = useMeQuery()
|
||||
@ -30,19 +29,19 @@ export function ListWorkspace() {
|
||||
const { items, isLoading, totalPages, currentPage, setCurrentPage } = useList()
|
||||
const participate = useParticipateMutation()
|
||||
const reject = useRejectMutation()
|
||||
const saveExtra = useSaveExtraInfoMutation()
|
||||
|
||||
const { selectedStatus, setStatus, selectedType, setType } = useListStore(
|
||||
const { selectedStatus, setStatus } = useListStore(
|
||||
useShallow((s) => ({
|
||||
selectedStatus: s.selectedStatus,
|
||||
setStatus: s.setStatus,
|
||||
selectedType: s.selectedType,
|
||||
setType: s.setType,
|
||||
})),
|
||||
)
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const [enteringId, setEnteringId] = useState<string | null>(null)
|
||||
const [rejectTarget, setRejectTarget] = useState<ListItem | null>(null)
|
||||
const [extraTarget, setExtraTarget] = useState<ListItem | null>(null)
|
||||
|
||||
const handleEnter = (item: ListItem) => {
|
||||
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 (
|
||||
<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">
|
||||
<StatusTabs selected={selectedStatus} onSelect={setStatus} />
|
||||
<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">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-neutral-50" />
|
||||
<input
|
||||
@ -132,6 +130,7 @@ export function ListWorkspace() {
|
||||
busyId={participate.isPending ? enteringId : null}
|
||||
onEnter={handleEnter}
|
||||
onReject={handleReject}
|
||||
onExtraInfo={setExtraTarget}
|
||||
/>
|
||||
</div>
|
||||
<div className="lg:hidden">
|
||||
@ -141,6 +140,7 @@ export function ListWorkspace() {
|
||||
busyId={participate.isPending ? enteringId : null}
|
||||
onEnter={handleEnter}
|
||||
onReject={handleReject}
|
||||
onExtraInfo={setExtraTarget}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -152,6 +152,10 @@ export function ListWorkspace() {
|
||||
{rejectTarget && (
|
||||
<RejectPopup onClose={() => setRejectTarget(null)} onSubmit={handleRejectSubmit} />
|
||||
)}
|
||||
|
||||
{extraTarget && (
|
||||
<ExtraInfoPopup target={extraTarget} onClose={() => setExtraTarget(null)} onSubmit={handleExtraSubmit} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,17 +1,16 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useSessionListQuery } from '@/apis'
|
||||
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
|
||||
|
||||
// 필터/정렬/페이지는 서버에 위임하고, 응답(정수 코드)을 화면용 라벨로 변환한다.
|
||||
export function useList() {
|
||||
const { selectedType, selectedStatus, selectedDeadline, currentPage, setCurrentPage } = useListStore()
|
||||
const { selectedStatus, selectedDeadline, currentPage, setCurrentPage } = useListStore()
|
||||
|
||||
const query = useSessionListQuery({
|
||||
status: selectedStatus ? statusLabelToCode(selectedStatus) : undefined,
|
||||
qt_type: selectedType ? typeLabelToCode(selectedType) : undefined,
|
||||
order: selectedDeadline ? deadlineToOrder(selectedDeadline) : undefined,
|
||||
page: currentPage,
|
||||
page_size: PAGE_SIZE,
|
||||
|
||||
@ -37,5 +37,6 @@ export function toListItem(api: SessionListItem): ListItem {
|
||||
item_name: api.item_name,
|
||||
model_name: api.model_name,
|
||||
maker_name: api.maker_name,
|
||||
custom: api.custom ?? {},
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,4 +8,5 @@ export type ListItem = {
|
||||
item_name: string
|
||||
model_name: string
|
||||
maker_name: string
|
||||
custom: Record<string, unknown> // 협상완료 부가정보(입력값). 미입력이면 {}
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { type ReactNode } from 'react'
|
||||
import { Logo } from '@/components'
|
||||
import { useMeQuery } from '@/apis'
|
||||
import { cn } from '@/lib'
|
||||
|
||||
// 좌측 폭: list=반응형 비율 / chat=고정폭 단계 축소
|
||||
@ -43,11 +44,12 @@ export function MainLayout({
|
||||
header,
|
||||
children,
|
||||
}: MainLayoutProps) {
|
||||
const { data: user } = useMeQuery() // 회사 브랜딩(서비스명/로고) 주입
|
||||
const panes = (
|
||||
<>
|
||||
<aside className={cn(styles.sidebar, SIDEBAR_WIDTH[sidebarWidth])}>
|
||||
<div className={styles.logoHeader}>
|
||||
<Logo size="sm" />
|
||||
<Logo size="sm" serviceName={user?.branding?.service_name} logoUrl={user?.branding?.logo_url} />
|
||||
{logoAction}
|
||||
</div>
|
||||
{sidebar}
|
||||
|
||||
@ -15,7 +15,7 @@ export function PortalHeader() {
|
||||
|
||||
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">
|
||||
<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">
|
||||
<button
|
||||
|
||||
Loading…
Reference in New Issue
Block a user